Compare commits

..

No commits in common. "master" and "v1.6.2" have entirely different histories.

68 changed files with 797 additions and 2413 deletions

View file

@ -4,18 +4,12 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using CRD.ViewModels;
using MainWindow = CRD.Views.MainWindow;
using Avalonia.Controls;
using Avalonia.Platform;
using System.Linq;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
namespace CRD;
public class App : Application{
private TrayIcon? trayIcon;
private bool exitRequested;
public partial class App : Application{
public override void Initialize(){
AvaloniaXamlLoader.Load(this);
}
@ -27,21 +21,11 @@ public class App : Application{
var manager = ProgramManager.Instance;
if (!isHeadless){
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var mainWindow = new MainWindow{
desktop.MainWindow = new MainWindow{
DataContext = new MainWindowViewModel(manager),
};
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
desktop.MainWindow = mainWindow;
}
SetupTrayIcon(desktop, mainWindow, manager);
SetupMinimizeToTray(desktop,mainWindow,manager);
desktop.MainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
}
@ -51,80 +35,5 @@ public class App : Application{
base.OnFrameworkInitializationCompleted();
}
private void SetupTrayIcon(IClassicDesktopStyleApplicationLifetime desktop, Window mainWindow, ProgramManager programManager){
trayIcon = new TrayIcon{
ToolTipText = "CRD",
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://CRD/Assets/app_icon.ico"))),
IsVisible = CrunchyrollManager.Instance.CrunOptions.TrayIconEnabled,
};
var menu = new NativeMenu();
var exitItem = new NativeMenuItem("Exit");
exitItem.Click += (_, _) => {
exitRequested = true;
trayIcon?.Dispose();
desktop.Shutdown();
};
menu.Items.Add(exitItem);
trayIcon.Menu = menu;
trayIcon.Clicked += (_, _) => ShowFromTray(desktop, mainWindow);
TrayIcon.SetIcons(this, new TrayIcons{ trayIcon });
}
private void SetupMinimizeToTray(IClassicDesktopStyleApplicationLifetime desktop, Window window , ProgramManager programManager){
window.Closing += (_, e) => {
if (exitRequested)
return;
if (CrunchyrollManager.Instance.CrunOptions is{ MinimizeToTrayOnClose: true, TrayIconEnabled: true }){
HideToTray(window);
e.Cancel = true;
return;
}
exitRequested = true;
trayIcon?.Dispose();
desktop.Shutdown();
};
window.GetObservable(Window.WindowStateProperty).Subscribe(state => {
if (CrunchyrollManager.Instance.CrunOptions is{ TrayIconEnabled: true, MinimizeToTray: true } && state == WindowState.Minimized)
HideToTray(window);
});
}
private static void HideToTray(Window window){
window.ShowInTaskbar = false;
window.Hide();
}
private void ShowFromTray(IClassicDesktopStyleApplicationLifetime desktop, Window mainWindow){
desktop.MainWindow ??= mainWindow;
RestoreFromTray(mainWindow);
}
private static void RestoreFromTray(Window window){
window.ShowInTaskbar = true;
window.Show();
if (window.WindowState == WindowState.Minimized)
window.WindowState = WindowState.Normal;
window.Activate();
}
public void SetTrayIconVisible(bool enabled){
trayIcon?.IsVisible = enabled;
if (!enabled && ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){
if (desktop.MainWindow is{ } w)
RestoreFromTray(w);
}
}
}

View file

@ -10,7 +10,6 @@ using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Views;
@ -235,17 +234,8 @@ public class CalendarManager{
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data ?? [];
var newEpisodes = newEpisodesBase.Data;
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){
Console.Error.WriteLine("Failed to update History from calendar");
}
}
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false;

View file

@ -1,15 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
@ -21,12 +15,9 @@ using ReactiveUI;
namespace CRD.Downloader.Crunchyroll;
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
public CrToken? Token;
public CrProfile Profile = new();
public Subscription? Subscription{ get; set; }
public CrMultiProfile MultiProfile = new();
public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown;
public CrAuthSettings AuthSettings = authSettings;
@ -41,6 +32,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
PreferredContentSubtitleLanguage = crunInstance.DefaultLocale,
HasPremium = false,
};
}
private string GetTokenFilePath(){
@ -57,10 +49,9 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
case "console/ps5":
case "console/xbox_one":
return CfgManager.PathCrToken.Replace(".json", "_console.json");
case "---":
return CfgManager.PathCrToken.Replace(".json", "_guest.json");
default:
return CfgManager.PathCrToken;
}
}
@ -74,14 +65,12 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
}
public void SetETPCookie(string refreshToken){
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken), cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"), cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken),cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"),cookieStore);
}
public async Task AuthAnonymous(){
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
Subscription = new Subscription();
string uuid = Guid.NewGuid().ToString();
var formData = new Dictionary<string, string>{
{ "grant_type", "client_id" },
@ -174,10 +163,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
} else{
if (response.ResponseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", ToastType.Error, 5));
} else if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
} else if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5));
} else{
@ -190,64 +179,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetMultiProfile();
}
}
public async Task ChangeProfile(string profileId){
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous();
}
if (Profile.Username == "???"){
return;
}
if (string.IsNullOrEmpty(profileId) || Token?.refresh_token == null){
return;
}
string uuid = string.IsNullOrEmpty(Token.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
SetETPCookie(Token.refresh_token);
var formData = new Dictionary<string, string>{
{ "grant_type", "refresh_token_profile_id" },
{ "profile_id", profileId },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
if (Token?.refresh_token != null) SetETPCookie(Token.refresh_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
}
await GetMultiProfile();
} else{
Console.Error.WriteLine("Refresh Token Auth Failed");
await GetProfile();
}
}
@ -267,69 +199,42 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (profileTemp != null){
Profile = profileTemp;
await GetSubscription();
}
}
}
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
private async Task GetSubscription(){
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
if (responseSubs.IsOk){
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Subscription = subsc;
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
var remaining = expiration - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = expiration;
if (responseSubs.IsOk){
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Profile.Subscription = subsc;
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
var remaining = expiration - DateTime.Now;
Profile.HasPremium = true;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.Subscription.NextRenewalDate = expiration;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
}
}
private async Task GetMultiProfile(){
if (Token?.access_token == null){
Console.Error.WriteLine("Missing Access Token");
return;
}
var request = HttpClientReq.CreateRequestMessage(ApiUrls.MultiProfile, HttpMethod.Get, true, Token?.access_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
MultiProfile = Helpers.Deserialize<CrMultiProfile>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? new CrMultiProfile();
var selectedProfile = MultiProfile.Profiles.FirstOrDefault( e => e.IsSelected);
if (selectedProfile != null) Profile = selectedProfile;
await GetSubscription();
}
}
}
}
}
@ -374,41 +279,33 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5));
Console.Error.WriteLine($"Failed to login - Cloudflare error try to change to BetaAPI in settings");
}
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetMultiProfile();
await GetProfile();
}
} else{
Console.Error.WriteLine("Token Auth Failed");
await AuthAnonymous();
MainWindow.Instance.ShowError("Login failed. Please check the log for more details.");
}
}
public async Task RefreshToken(bool needsToken){
if (EndpointEnum == CrunchyrollEndpoints.Guest){
if (Token != null && !(DateTime.Now > Token.expires)){
return;
}
await AuthAnonymousFoxy();
return;
}
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous();

View file

@ -6,7 +6,6 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
@ -74,102 +73,92 @@ public class CrEpisode(){
public async Task<CrunchyRollEpisodeData> EpisodeData(CrunchyEpisode dlEpisode, bool updateHistory = false){
bool serieshasversions = true;
var episode = new CrunchyRollEpisodeData();
// Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
CrunchyRollEpisodeData episode = new CrunchyRollEpisodeData();
if (crunInstance.CrunOptions.History && updateHistory){
await crunInstance.History.UpdateWithEpisodeList([dlEpisode]);
var historySeries = crunInstance.HistoryList
.FirstOrDefault(series => series.SeriesId == dlEpisode.SeriesId);
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == dlEpisode.SeriesId);
if (historySeries != null){
crunInstance.History.MatchHistorySeriesWithSonarr(false);
await crunInstance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(false);
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
CfgManager.UpdateHistoryFile();
}
}
// initial key
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier)
? dlEpisode.Identifier.Split('|')[1]
: $"S{dlEpisode.SeasonNumber}";
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier) ? dlEpisode.Identifier.Split('|')[1] : $"S{dlEpisode.SeasonNumber}";
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}";
episode.EpisodeAndLanguages = new EpisodeAndLanguage{
Items = new List<CrunchyEpisode>(),
Langs = new List<LanguageItem>()
};
episode.EpisodeAndLanguages = new EpisodeAndLanguage();
// Build Variants
if (dlEpisode.Versions != null){
foreach (var version in dlEpisode.Versions){
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)
?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
// Ensure there is only one of the same language
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != version.AudioLocale)){
// Push to arrays if there are no duplicates of the same language
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? Languages.DEFAULT_lang);
}
}
} else{
// Episode didn't have versions, mark it as such to be logged.
serieshasversions = false;
var lang = Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale)
?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
// Ensure there is only one of the same language
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != dlEpisode.AudioLocale)){
// Push to arrays if there are no duplicates of the same language
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale) ?? Languages.DEFAULT_lang);
}
}
if (episode.EpisodeAndLanguages.Variants.Count == 0)
return episode;
var baseEp = episode.EpisodeAndLanguages.Variants[0].Item;
int specialIndex = 1;
int epIndex = 1;
var isSpecial = baseEp.IsSpecialEpisode();
var isSpecial = !Regex.IsMatch(episode.EpisodeAndLanguages.Items[0].Episode ?? string.Empty, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special).
string newKey;
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = baseEp.Episode;
if (isSpecial && !string.IsNullOrEmpty(episode.EpisodeAndLanguages.Items[0].Episode)){
newKey = episode.EpisodeAndLanguages.Items[0].Episode ?? "SP" + (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id);
} else{
var epPart = baseEp.Episode ?? (baseEp.EpisodeNumber?.ToString() ?? "1");
newKey = isSpecial
? $"SP{epPart} {baseEp.Id}"
: $"E{epPart}";
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id) : episode.EpisodeAndLanguages.Items[0].Episode ?? epIndex + "")}";
}
episode.Key = newKey;
var seasonTitle =
episode.EpisodeAndLanguages.Variants
.Select(v => v.Item.SeasonTitle)
.FirstOrDefault(t => !DownloadQueueItemFactory.HasDubSuffix(t))
?? DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle);
var seasonTitle = episode.EpisodeAndLanguages.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
?? Regex.Replace(episode.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
var title = baseEp.Title;
var seasonNumber = baseEp.GetSeasonNum();
var title = episode.EpisodeAndLanguages.Items[0].Title;
var seasonNumber = Helpers.ExtractNumberAfterS(episode.EpisodeAndLanguages.Items[0].Identifier) ?? episode.EpisodeAndLanguages.Items[0].SeasonNumber.ToString();
var languages = episode.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
var languages = episode.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{episode.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆
Console.WriteLine($"[{episode.Key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
if (!serieshasversions)
if (!serieshasversions){
Console.WriteLine("Couldn\'t find versions on episode, added languages with language array.");
}
return episode;
}
public CrunchyEpMeta EpisodeMeta(CrunchyRollEpisodeData episodeP, List<string> dubLang){
CrunchyEpMeta? retMeta = null;
// var ret = new Dictionary<string, CrunchyEpMeta>();
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var hslang = crunInstance.CrunOptions.Hslang;
var retMeta = new CrunchyEpMeta();
var selectedDubs = dubLang
.Where(d => episodeP.EpisodeAndLanguages.Variants.Any(v => v.Lang.CrLocale == d))
.ToList();
foreach (var v in episodeP.EpisodeAndLanguages.Variants){
var item = v.Item;
var lang = v.Lang;
for (int index = 0; index < episodeP.EpisodeAndLanguages.Items.Count; index++){
var item = episodeP.EpisodeAndLanguages.Items[index];
if (!dubLang.Contains(lang.CrLocale))
if (!dubLang.Contains(episodeP.EpisodeAndLanguages.Langs[index].CrLocale))
continue;
item.HideSeasonTitle = true;
@ -184,54 +173,67 @@ public class CrEpisode(){
item.SeriesTitle = "NO_TITLE";
}
item.SeqId = epNum;
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var images = (item.Images?.Thumbnail ?? [new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
if (retMeta == null){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeriesTitle));
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeasonTitle));
var epMeta = new CrunchyEpMeta();
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
epMeta.SeriesTitle = episodeP.EpisodeAndLanguages.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle))?.SeriesTitle ??
Regex.Replace(episodeP.EpisodeAndLanguages.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
epMeta.SeasonTitle = episodeP.EpisodeAndLanguages.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle))?.SeasonTitle ??
Regex.Replace(episodeP.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
epMeta.EpisodeNumber = item.Episode;
epMeta.EpisodeTitle = item.Title;
epMeta.SeasonId = item.SeasonId;
epMeta.Season = Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber + "";
epMeta.SeriesId = item.SeriesId;
epMeta.AbsolutEpisodeNumberE = epNum;
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty;
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source ?? string.Empty;
epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false,
Done = false,
Error = false,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0
};
epMeta.AvailableSubs = item.SubtitleLocales;
epMeta.Description = item.Description;
epMeta.Hslang = CrunchyrollManager.Instance.CrunOptions.Hslang;
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
retMeta = DownloadQueueItemFactory.CreateShell(
service: StreamingService.Crunchyroll,
seriesTitle: seriesTitle,
seasonTitle: seasonTitle,
episodeNumber: item.Episode,
episodeTitle: item.GetEpisodeTitle(),
description: item.Description,
seriesId: item.SeriesId,
seasonId: item.SeasonId,
season: item.GetSeasonNum(),
absolutEpisodeNumberE: epNum,
image: img,
imageBig: imgBig,
hslang: hslang,
availableSubs: item.SubtitleLocales,
selectedDubs: selectedDubs
);
if (episodeP.EpisodeAndLanguages.Langs.Count > 0){
epMeta.SelectedDubs = dubLang
.Where(language => episodeP.EpisodeAndLanguages.Langs.Any(epLang => epLang.CrLocale == language))
.ToList();
}
var playback = item.Playback;
var epMetaData = epMeta.Data[0];
if (!string.IsNullOrEmpty(item.StreamsLink)){
playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback))
epMetaData.Playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){
item.Playback = item.StreamsLink;
}
}
retMeta.Data.Add(DownloadQueueItemFactory.CreateVariant(
mediaId: item.Id,
lang: lang,
playback: playback,
versions: item.Versions,
isSubbed: item.IsSubbed,
isDubbed: item.IsDubbed
));
if (retMeta.Data is{ Count: > 0 }){
epMetaData.Lang = episodeP.EpisodeAndLanguages.Langs[index];
retMeta.Data.Add(epMetaData);
} else{
epMetaData.Lang = episodeP.EpisodeAndLanguages.Langs[index];
epMeta.Data[0] = epMetaData;
retMeta = epMeta;
}
// show ep
item.SeqId = epNum;
}
return retMeta ?? new CrunchyEpMeta();
return retMeta;
}
public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){

View file

@ -6,7 +6,6 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
@ -18,44 +17,32 @@ namespace CRD.Downloader.Crunchyroll;
public class CrSeries{
private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance;
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? all, List<string>? e){
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>();
var hasPremium = crunInstance.CrAuthEndpoint1.Profile.HasPremium;
var hslang = crunInstance.CrunOptions.Hslang;
foreach (var kvp in eps){
var key = kvp.Key;
var episode = kvp.Value;
bool ShouldInclude(string epNum) =>
all is true || (e != null && e.Contains(epNum));
for (int index = 0; index < episode.Items.Count; index++){
var item = episode.Items[index];
foreach (var (key, episode) in eps){
var epNum = key.StartsWith('E') ? key[1..] : key;
foreach (var v in episode.Variants){
var item = v.Item;
var lang = v.Lang;
item.SeqId = epNum;
if (item.IsPremiumOnly && !hasPremium){
MessageBus.Current.SendMessage(new ToastMessage(
"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription",
ToastType.Error, 3));
if (item.IsPremiumOnly && !crunInstance.CrAuthEndpoint1.Profile.HasPremium){
MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription", ToastType.Error, 3));
continue;
}
// history override
var effectiveDubs = dubLang;
if (crunInstance.CrunOptions.History){
var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId);
if (dubLangList.Count > 0)
effectiveDubs = dubLangList;
if (dubLangList.Count > 0){
dubLang = dubLangList;
}
}
if (!effectiveDubs.Contains(lang.CrLocale))
if (!dubLang.Contains(episode.Langs[index].CrLocale))
continue;
// season title fallbacks (same behavior)
item.HideSeasonTitle = true;
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
item.SeasonTitle = item.SeriesTitle;
@ -68,65 +55,66 @@ public class CrSeries{
item.SeriesTitle = "NO_TITLE";
}
// selection gate
if (!ShouldInclude(epNum))
continue;
var epNum = key.StartsWith('E') ? key[1..] : key;
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
// Create base queue item once per "key"
if (!ret.TryGetValue(key, out var qItem)){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episode.Variants.Select(x => (string?)x.Item.SeriesTitle));
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
episode.Variants.Select(x => (string?)x.Item.SeasonTitle));
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
var selectedDubs = effectiveDubs
.Where(d => episode.Variants.Any(x => x.Lang.CrLocale == d))
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 = Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber + "";
epMeta.SeriesId = item.SeriesId;
epMeta.AbsolutEpisodeNumberE = epNum;
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty;
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source ?? string.Empty;
epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false,
Done = false,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0
};
epMeta.Hslang = CrunchyrollManager.Instance.CrunOptions.Hslang;
epMeta.Description = item.Description;
epMeta.AvailableSubs = item.SubtitleLocales;
if (episode.Langs.Count > 0){
epMeta.SelectedDubs = dubLang
.Where(language => episode.Langs.Any(epLang => epLang.CrLocale == language))
.ToList();
qItem = DownloadQueueItemFactory.CreateShell(
service: StreamingService.Crunchyroll,
seriesTitle: seriesTitle,
seasonTitle: seasonTitle,
episodeNumber: item.Episode,
episodeTitle: item.Title,
description: item.Description,
seriesId: item.SeriesId,
seasonId: item.SeasonId,
season: Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber.ToString(),
absolutEpisodeNumberE: epNum,
image: img,
imageBig: imgBig,
hslang: hslang,
availableSubs: item.SubtitleLocales,
selectedDubs: selectedDubs
);
ret.Add(key, qItem);
}
// playback preference
var playback = item.Playback;
var epMetaData = epMeta.Data[0];
if (!string.IsNullOrEmpty(item.StreamsLink)){
playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback))
epMetaData.Playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){
item.Playback = item.StreamsLink;
}
}
// Add variant
ret[key].Data.Add(DownloadQueueItemFactory.CreateVariant(
mediaId: item.Id,
lang: lang,
playback: playback,
versions: item.Versions,
isSubbed: item.IsSubbed,
isDubbed: item.IsDubbed
));
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;
}
@ -136,58 +124,64 @@ public class CrSeries{
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
if (parsedSeries?.Data == null){
if (parsedSeries == null || parsedSeries.Data == null){
Console.Error.WriteLine("Parse Data Invalid");
return null;
}
var episodes = new Dictionary<string, EpisodeAndLanguage>();
// var result = ParseSeriesResult(parsedSeries);
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
if (crunInstance.CrunOptions.History)
if (crunInstance.CrunOptions.History){
_ = crunInstance.History.CrUpdateSeries(id, "");
}
var cachedSeasonId = "";
var seasonData = new CrunchyEpisodeList();
foreach (var s in parsedSeries.Data){
if (data?.S != null && s.Id != data.S)
continue;
if (data?.S != null && s.Id != data.S) continue;
int fallbackIndex = 0;
if (cachedSeasonId != s.Id){
seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : "");
cachedSeasonId = s.Id;
}
if (seasonData.Data == null)
continue;
if (seasonData.Data != null){
foreach (var episode in seasonData.Data){
// Prepare the episode array
EpisodeAndLanguage item;
foreach (var episode in seasonData.Data){
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}";
string episodeNum = (episode.Episode != String.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}")) ?? string.Empty;
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}";
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
if (!episodes.TryGetValue(episodeKey, out var item)){
item = new EpisodeAndLanguage(); // must have Variants
episodes[episodeKey] = item;
}
if (episode.Versions != null){
foreach (var version in episode.Versions){
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang); // must enforce uniqueness by CrLocale
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){
if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){
item.Items.Add(episode);
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem());
}
}
} else{
serieshasversions = false;
if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){
item.Items.Add(episode);
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem());
}
}
} else{
serieshasversions = false;
var lang = Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang);
}
}
}
@ -204,25 +198,22 @@ public class CrSeries{
int specialIndex = 1;
int epIndex = 1;
var keys = new List<string>(episodes.Keys);
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){
var item = episodes[key];
if (item.Variants.Count == 0)
continue;
var baseEp = item.Variants[0].Item;
var epStr = baseEp.Episode;
var isSpecial = epStr != null && !Regex.IsMatch(epStr, @"^\d+(\.\d+)?$");
EpisodeAndLanguage item = episodes[key];
var episode = item.Items[0].Episode;
var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+(\.\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(baseEp.Episode)){
newKey = $"SP{specialIndex}_" + baseEp.Episode;
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){
newKey = $"SP{specialIndex}_" + item.Items[0].Episode;// ?? "SP" + (specialIndex + " " + item.Items[0].Id);
} else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}";
}
episodes.Remove(key);
int counter = 1;
@ -234,95 +225,63 @@ public class CrSeries{
episodes.Add(newKey, item);
if (isSpecial) specialIndex++;
else epIndex++;
if (isSpecial){
specialIndex++;
} else{
epIndex++;
}
}
var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
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;
if (item.Variants.Count == 0)
continue;
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
var baseEp = item.Variants[0].Item;
var title = item.Items[0].Title;
var seasonNumber = Helpers.ExtractNumberAfterS(item.Items[0].Identifier) ?? item.Items[0].SeasonNumber.ToString();
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
item.Variants.Select(string? (v) => v.Item.SeasonTitle)
);
var title = baseEp.Title;
var seasonNumber = Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString();
var languages = item.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
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)
if (!serieshasversions){
Console.WriteLine("Couldn\'t find versions on some episodes, added languages with language array.");
}
var crunchySeriesList = new CrunchySeriesList{
Data = sortedEpisodes
};
CrunchySeriesList crunchySeriesList = new CrunchySeriesList();
crunchySeriesList.Data = sortedEpisodes;
crunchySeriesList.List = sortedEpisodes.Select(kvp => {
var key = kvp.Key;
var value = kvp.Value;
if (value.Variants.Count == 0){
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = new List<string>(),
Name = string.Empty,
Season = string.Empty,
SeriesTitle = string.Empty,
SeasonTitle = string.Empty,
EpisodeNum = key,
Id = string.Empty,
Img = string.Empty,
Description = string.Empty,
EpisodeType = EpisodeType.Episode,
Time = "0:00"
};
}
var baseEp = value.Variants[0].Item;
var thumbRow = baseEp.Images.Thumbnail.FirstOrDefault();
var img = thumbRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
var seconds = (int)Math.Floor((baseEp.DurationMs) / 1000.0);
var langList = value.Variants
.Select(v => v.Lang.CrLocale)
.Distinct()
.ToList();
var images = (value.Items.FirstOrDefault()?.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
var seconds = (int)Math.Floor((value.Items.FirstOrDefault()?.DurationMs ?? 0) / 1000.0);
var langList = value.Langs.Select(a => a.CrLocale).ToList();
Languages.SortListByLangList(langList);
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = langList,
Name = baseEp.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString()) ?? string.Empty,
SeriesTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeriesTitle),
SeasonTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle),
EpisodeNum = key.StartsWith("SP")
? key
: (baseEp.EpisodeNumber?.ToString() ?? baseEp.Episode ?? "?"),
Id = baseEp.SeasonId ?? string.Empty,
Img = img,
Description = baseEp.Description ?? string.Empty,
Name = value.Items.FirstOrDefault()?.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(value.Items.FirstOrDefault()?.Identifier?? string.Empty) ?? value.Items.FirstOrDefault()?.SeasonNumber.ToString()) ?? string.Empty,
SeriesTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeriesTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(),
SeasonTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeasonTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(),
EpisodeNum = key.StartsWith("SP") ? key : value.Items.FirstOrDefault()?.EpisodeNumber?.ToString() ?? value.Items.FirstOrDefault()?.Episode ?? "?",
Id = value.Items.FirstOrDefault()?.SeasonId ?? string.Empty,
Img = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty,
Description = value.Items.FirstOrDefault()?.Description ?? string.Empty,
EpisodeType = EpisodeType.Episode,
Time = $"{seconds / 60}:{seconds % 60:D2}"
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds.
};
}).ToList();
@ -374,7 +333,7 @@ public class CrSeries{
Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}");
} else{
episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ??
new CrunchyEpisodeList(){ Data = [], Total = 0, Meta = new Meta() };
new CrunchyEpisodeList(){ Data =[], Total = 0, Meta = new Meta() };
}
if (episodeList.Total < 1){
@ -418,8 +377,8 @@ public class CrSeries{
public async Task<CrSeriesBase?> SeriesById(string id, string? crLocale, bool forced = false){
await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
query["preferred_audio_language"] = "ja-JP";
@ -452,7 +411,7 @@ public class CrSeries{
public async Task<CrSearchSeriesBase?> Search(string searchString, string? crLocale, bool forced = false){
await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
if (!string.IsNullOrEmpty(crLocale)){
@ -497,7 +456,7 @@ public class CrSeries{
public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true);
CrBrowseSeriesBase complete = new CrBrowseSeriesBase();
complete.Data = [];
complete.Data =[];
var i = 0;
@ -536,7 +495,7 @@ public class CrSeries{
return complete;
}
public async Task<CrBrowseSeriesBase?> GetSeasonalSeries(string season, string year, string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
@ -544,7 +503,7 @@ public class CrSeries{
if (!string.IsNullOrEmpty(crLocale)){
query["locale"] = crLocale;
}
query["seasonal_tag"] = season.ToLower() + "-" + year;
query["n"] = "100";
@ -561,4 +520,5 @@ public class CrSeries{
return series;
}
}

View file

@ -151,9 +151,6 @@ public class CrunchyrollManager{
};
options.History = true;
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
options.HistoryAutoRefreshIntervalMinutes = 0;
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
@ -584,36 +581,23 @@ public class CrunchyrollManager{
if (options.MarkAsWatched && data.Data is{ Count: > 0 }){
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
}
if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
if (CrunOptions.DownloadFinishedPlaySound){
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer();
player.Play(audioPath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
if (QueueManager.Instance.Queue.Count == 0 || QueueManager.Instance.Queue.All(e => e.DownloadProgress.Done)){
QueueManager.Instance.ResetDownloads();
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer();
player.Play(audioPath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
}
if (CrunOptions.DownloadFinishedExecute){
try{
var filePath = CrunOptions.DownloadFinishedExecutePath;
if (!string.IsNullOrEmpty(filePath)){
Helpers.ExecuteFile(filePath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to execute file: " + exception);
}
}
if (CrunOptions.ShutdownWhenQueueEmpty){
Helpers.ShutdownComputer();
}
}
return true;
}
@ -1567,10 +1551,8 @@ public class CrunchyrollManager{
string qualityConsoleLog = sb.ToString();
Console.WriteLine(qualityConsoleLog);
if (!options.DlVideoOnce || string.IsNullOrEmpty(data.AvailableQualities)){
data.AvailableQualities = qualityConsoleLog;
}
data.AvailableQualities = qualityConsoleLog;
Console.WriteLine("Stream URL:" + chosenVideoSegments.segments[0].uri.Split(new[]{ ",.urlset" }, StringSplitOptions.None)[0]);

View file

@ -1,89 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using CRD.Utils;
using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class DownloadQueueItemFactory{
private static readonly Regex DubSuffix = new(@"\(\w+ Dub\)", RegexOptions.Compiled);
public static bool HasDubSuffix(string? s)
=> !string.IsNullOrWhiteSpace(s) && DubSuffix.IsMatch(s);
public static string StripDubSuffix(string? s)
=> string.IsNullOrWhiteSpace(s) ? "" : DubSuffix.Replace(s, "").TrimEnd();
public static string CanonicalTitle(IEnumerable<string?> candidates){
var noDub = candidates.FirstOrDefault(t => !HasDubSuffix(t));
return !string.IsNullOrWhiteSpace(noDub)
? noDub!
: StripDubSuffix(candidates.FirstOrDefault());
}
public static (string small, string big) GetThumbSmallBig(Images? images){
var firstRow = images?.Thumbnail?.FirstOrDefault();
var small = firstRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
var big = firstRow?.LastOrDefault()?.Source ?? small;
return (small, big);
}
public static CrunchyEpMeta CreateShell(
StreamingService service,
string? seriesTitle,
string? seasonTitle,
string? episodeNumber,
string? episodeTitle,
string? description,
string? seriesId,
string? seasonId,
string? season,
string? absolutEpisodeNumberE,
string? image,
string? imageBig,
string hslang,
List<string>? availableSubs = null,
List<string>? selectedDubs = null,
bool music = false){
return new CrunchyEpMeta(){
SeriesTitle = seriesTitle,
SeasonTitle = seasonTitle,
EpisodeNumber = episodeNumber,
EpisodeTitle = episodeTitle,
Description = description,
SeriesId = seriesId,
SeasonId = seasonId,
Season = season,
AbsolutEpisodeNumberE = absolutEpisodeNumberE,
Image = image,
ImageBig = imageBig,
Hslang = hslang,
AvailableSubs = availableSubs,
SelectedDubs = selectedDubs,
Music = music
};
}
public static CrunchyEpMetaData CreateVariant(
string mediaId,
LanguageItem? lang,
string? playback,
List<EpisodeVersion>? versions,
bool isSubbed,
bool isDubbed,
bool isAudioRoleDescription = false){
return new CrunchyEpMetaData{
MediaId = mediaId,
Lang = lang,
Playback = playback,
Versions = versions,
IsSubbed = isSubbed,
IsDubbed = isDubbed,
IsAudioRoleDescription = isAudioRoleDescription
};
}
}

View file

@ -1,122 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CRD.Utils;
using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class EpisodeMapper{
public static CrunchyEpisode ToCrunchyEpisode(this CrBrowseEpisode src){
if (src == null) throw new ArgumentNullException(nameof(src));
var meta = src.EpisodeMetadata ?? new CrBrowseEpisodeMetaData();
return new CrunchyEpisode{
Id = src.Id ?? string.Empty,
Slug = src.Slug ?? string.Empty,
SlugTitle = src.SlugTitle ?? string.Empty,
Title = src.Title ?? string.Empty,
Description = src.Description ?? src.PromoDescription ?? string.Empty,
MediaType = src.Type,
ChannelId = src.ChannelId,
StreamsLink = src.StreamsLink,
Images = src.Images ?? new Images(),
SeoTitle = src.PromoTitle ?? string.Empty,
SeoDescription = src.PromoDescription ?? string.Empty,
ProductionEpisodeId = src.ExternalId ?? string.Empty,
ListingId = src.LinkedResourceKey ?? string.Empty,
SeriesId = meta.SeriesId ?? string.Empty,
SeasonId = meta.SeasonId ?? string.Empty,
SeriesTitle = meta.SeriesTitle ?? string.Empty,
SeriesSlugTitle = meta.SeriesSlugTitle ?? string.Empty,
SeasonTitle = meta.SeasonTitle ?? string.Empty,
SeasonSlugTitle = meta.SeasonSlugTitle ?? string.Empty,
SeasonNumber = SafeInt(meta.SeasonNumber),
SequenceNumber = (float)meta.SequenceNumber,
Episode = meta.Episode,
EpisodeNumber = meta.EpisodeCount,
DurationMs = meta.DurationMs,
Identifier = meta.Identifier ?? string.Empty,
AvailabilityNotes = meta.AvailabilityNotes ?? string.Empty,
EligibleRegion = meta.EligibleRegion ?? string.Empty,
AvailabilityStarts = meta.AvailabilityStarts,
AvailabilityEnds = meta.AvailabilityEnds,
PremiumAvailableDate = meta.PremiumAvailableDate,
FreeAvailableDate = meta.FreeAvailableDate,
AvailableDate = meta.AvailableDate,
PremiumDate = meta.PremiumDate,
UploadDate = meta.UploadDate,
EpisodeAirDate = meta.EpisodeAirDate,
IsDubbed = meta.IsDubbed,
IsSubbed = meta.IsSubbed,
IsMature = meta.IsMature,
IsClip = meta.IsClip,
IsPremiumOnly = meta.IsPremiumOnly,
MatureBlocked = meta.MatureBlocked,
AvailableOffline = meta.AvailableOffline,
ClosedCaptionsAvailable = meta.ClosedCaptionsAvailable,
MaturityRatings = meta.MaturityRatings ?? new List<string>(),
AudioLocale = (meta.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
SubtitleLocales = (meta.SubtitleLocales ?? new List<Locale>())
.Select(l => l.GetEnumMemberValue())
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
ExtendedMaturityRating = ToStringKeyDict(meta.ExtendedMaturityRating),
Versions = meta.versions?.Select(ToEpisodeVersion).ToList()
};
}
private static EpisodeVersion ToEpisodeVersion(CrBrowseEpisodeVersion v){
return new EpisodeVersion{
AudioLocale = (v.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
Guid = v.Guid ?? string.Empty,
Original = v.Original,
Variant = v.Variant ?? string.Empty,
SeasonGuid = v.SeasonGuid ?? string.Empty,
MediaGuid = v.MediaGuid,
IsPremiumOnly = v.IsPremiumOnly,
roles = Array.Empty<string>()
};
}
private static int SafeInt(double value){
if (double.IsNaN(value) || double.IsInfinity(value)) return 0;
return (int)Math.Round(value, MidpointRounding.AwayFromZero);
}
private static Dictionary<string, object> ToStringKeyDict(Dictionary<object, object>? dict){
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
if (dict == null) return result;
foreach (var kv in dict){
var key = kv.Key?.ToString();
if (string.IsNullOrWhiteSpace(key)) continue;
result[key] = kv.Value ?? new object();
}
return result;
}
}

View file

@ -4,7 +4,6 @@ using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Sonarr;
@ -39,7 +38,7 @@ public class History{
}
} else{
var matchingSeason = historySeries.Seasons.FirstOrDefault(historySeason => historySeason.SeasonId == seasonId);
if (matchingSeason != null){
foreach (var historyEpisode in matchingSeason.EpisodesList){
historyEpisode.IsEpisodeAvailableOnStreamingService = false;
@ -130,129 +129,6 @@ public class History{
}
}
public async Task UpdateWithEpisode(List<CrBrowseEpisode> episodes){
var historyIndex = crunInstance.HistoryList
.Where(h => !string.IsNullOrWhiteSpace(h.SeriesId))
.ToDictionary(
h => h.SeriesId!,
h => (h.Seasons)
.Where(s => !string.IsNullOrWhiteSpace(s.SeasonId))
.ToDictionary(
s => s.SeasonId ?? "UNKNOWN",
s => (s.EpisodesList)
.Select(ep => ep.EpisodeId)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.Ordinal),
StringComparer.Ordinal
),
StringComparer.Ordinal
);
episodes = episodes
.Where(e => !string.IsNullOrWhiteSpace(e.EpisodeMetadata?.SeriesId) &&
historyIndex.ContainsKey(e.EpisodeMetadata!.SeriesId!))
.ToList();
foreach (var seriesGroup in episodes.GroupBy(e => e.EpisodeMetadata?.SeriesId ?? "UNKNOWN_SERIES")){
var seriesId = seriesGroup.Key;
var originalEntries = seriesGroup
.Select(e => new{ OriginalId = TryGetOriginalId(e), SeasonId = TryGetOriginalSeasonId(e) })
.Where(x => !string.IsNullOrWhiteSpace(x.OriginalId))
.GroupBy(x => x.OriginalId!, StringComparer.Ordinal)
.Select(g => new{
OriginalId = g.Key,
SeasonId = g.Select(x => x.SeasonId).FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
})
.ToList();
var hasAnyOriginalInfo = originalEntries.Count > 0;
var allOriginalsInHistory =
hasAnyOriginalInfo
&& originalEntries.All(x => IsOriginalInHistory(historyIndex, seriesId, x.SeasonId, x.OriginalId));
var originalItems = seriesGroup.Where(IsOriginalItem).ToList();
if (originalItems.Count > 0){
if (allOriginalsInHistory){
var sT = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} {sT} - all ORIGINAL episodes already in history.");
continue;
}
var convertedList = originalItems.Select(crBrowseEpisode => crBrowseEpisode.ToCrunchyEpisode()).ToList();
await crunInstance.History.UpdateWithSeasonData(convertedList.ToList<IHistorySource>());
continue;
}
var seriesTitle = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
if (allOriginalsInHistory){
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} - originals implied by Versions already in history.");
continue;
}
Console.WriteLine($"[WARN] No original ITEM found for SeriesId={seriesId} {seriesTitle}");
if (HasAllSeriesEpisodesInHistory(historyIndex, seriesId, seriesGroup)){
Console.WriteLine($"[History] Skip (already in history): {seriesId}");
} else{
await CrUpdateSeries(seriesId, null);
Console.WriteLine($"[History] Updating (full series): {seriesId}");
}
}
return;
}
private string? TryGetOriginalId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.Guid))
?.Guid;
private string? TryGetOriginalSeasonId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.SeasonGuid))
?.SeasonGuid
?? e.EpisodeMetadata?.SeasonId;
private bool IsOriginalItem(CrBrowseEpisode e){
var originalId = TryGetOriginalId(e);
return !string.IsNullOrWhiteSpace(originalId)
&& !string.IsNullOrWhiteSpace(e.Id)
&& string.Equals(e.Id, originalId, StringComparison.Ordinal);
}
private bool IsOriginalInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, string? seasonId, string originalEpisodeId){
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
if (!string.IsNullOrWhiteSpace(seasonId))
return seasons.TryGetValue(seasonId, out var eps) && eps.Contains(originalEpisodeId);
return seasons.Values.Any(eps => eps.Contains(originalEpisodeId));
}
private bool HasAllSeriesEpisodesInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, IEnumerable<CrBrowseEpisode> seriesEpisodes){
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
var allHistoryEpisodeIds = seasons.Values
.SelectMany(set => set)
.ToHashSet(StringComparer.Ordinal);
foreach (var e in seriesEpisodes){
if (string.IsNullOrWhiteSpace(e.Id)) return false;
if (!allHistoryEpisodeIds.Contains(e.Id)) return false;
}
return true;
}
/// <summary>
/// This method updates the History with a list of episodes. The episodes have to be from the same season.
/// </summary>
@ -330,7 +206,7 @@ public class History{
historySeries = new HistorySeries{
SeriesTitle = firstEpisode.GetSeriesTitle(),
SeriesId = firstEpisode.GetSeriesId(),
Seasons = [],
Seasons =[],
HistorySeriesAddDate = DateTime.Now,
SeriesType = firstEpisode.GetSeriesType(),
SeriesStreamingService = StreamingService.Crunchyroll
@ -426,8 +302,8 @@ public class History{
var downloadDirPath = "";
var videoQuality = "";
List<string> dublist = [];
List<string> sublist = [];
List<string> dublist =[];
List<string> sublist =[];
if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -477,7 +353,7 @@ public class History{
public List<string> GetDubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> dublist = [];
List<string> dublist =[];
if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -496,7 +372,7 @@ public class History{
public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> sublist = [];
List<string> sublist =[];
var videoQuality = "";
if (historySeries != null){
@ -554,8 +430,8 @@ public class History{
SeriesId = artisteData.Id,
SeriesTitle = artisteData.Name ?? "",
ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "",
HistorySeriesAvailableDubLang = [],
HistorySeriesAvailableSoftSubs = []
HistorySeriesAvailableDubLang =[],
HistorySeriesAvailableSoftSubs =[]
};
historySeries.SeriesDescription = cachedSeries.SeriesDescription;
@ -687,7 +563,7 @@ public class History{
SeasonTitle = firstEpisode.GetSeasonTitle(),
SeasonId = firstEpisode.GetSeasonId(),
SeasonNum = firstEpisode.GetSeasonNum(),
EpisodesList = [],
EpisodesList =[],
SpecialSeason = firstEpisode.IsSpecialSeason()
};
@ -755,7 +631,7 @@ public class History{
historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
List<HistoryEpisode> allHistoryEpisodes = [];
List<HistoryEpisode> allHistoryEpisodes =[];
foreach (var historySeriesSeason in historySeries.Seasons){
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
@ -783,7 +659,7 @@ public class History{
.ToList();
}
List<HistoryEpisode> failedEpisodes = [];
List<HistoryEpisode> failedEpisodes =[];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
@ -13,12 +12,10 @@ using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Utils.Updater;
using FluentAvalonia.Styling;
using ProtoBuf.Meta;
namespace CRD.Downloader;
@ -78,12 +75,10 @@ public partial class ProgramManager : ObservableObject{
#endregion
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
public IStorageProvider StorageProvider;
public ProgramManager(){
checkForNewEpisodesRunner = new PeriodicWorkRunner(async ct => { await CheckForDownloadsAsync(ct); });
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme;
foreach (var arg in Environment.GetCommandLineArgs()){
@ -111,7 +106,7 @@ public partial class ProgramManager : ObservableObject{
}
}
_ = Init();
Init();
CleanUpOldUpdater();
}
@ -177,53 +172,12 @@ public partial class ProgramManager : ObservableObject{
await Task.WhenAll(tasks);
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress.Done != true)){
Console.WriteLine("Waiting for downloads to complete...");
await Task.Delay(2000);
await Task.Delay(2000);
}
}
private async Task CheckForDownloadsAsync(CancellationToken ct){
var crunchyManager = CrunchyrollManager.Instance;
var crunOptions = crunchyManager.CrunOptions;
if (!crunOptions.History){
return;
}
switch (crunOptions.HistoryAutoRefreshMode){
case HistoryRefreshMode.DefaultAll:
await RefreshHistory(FilterType.All);
break;
case HistoryRefreshMode.DefaultActive:
await RefreshHistory(FilterType.Active);
break;
case HistoryRefreshMode.FastNewReleases:
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data ?? [];
try{
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){
Console.Error.WriteLine("Failed to update History");
}
}
break;
default:
return;
}
var tasks = crunchyManager.HistoryList
.Select(item => item.AddNewMissingToDownloads(true));
await Task.WhenAll(tasks);
}
public void SetBackgroundImage(){
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){
Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity,
@ -232,41 +186,33 @@ public partial class ProgramManager : ObservableObject{
}
private async Task Init(){
try{
CrunchyrollManager.Instance.InitOptions();
CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
OpacityButton = UpdateAvailable ? 1.0 : 0.4;
OpacityButton = UpdateAvailable ? 1.0 : 0.4;
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
}
if (_faTheme != null && Application.Current != null){
if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){
_faTheme.PreferSystemTheme = true;
} else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
}
}
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
StartRunners(true);
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
NavigationLock = false;
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
}
if (_faTheme != null && Application.Current != null){
if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){
_faTheme.PreferSystemTheme = true;
} else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
}
}
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
}
@ -292,7 +238,8 @@ public partial class ProgramManager : ObservableObject{
}
}
}
private void CleanUpOldUpdater(){
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
@ -309,17 +256,4 @@ public partial class ProgramManager : ObservableObject{
Console.WriteLine("No old updater file found to delete.");
}
}
public DateTime GetLastRefreshTime(){
return checkForNewEpisodesRunner.LastRunTime;
}
public void StartRunners(bool runImmediately = false){
checkForNewEpisodesRunner.StartOrRestartMinutes(CrunchyrollManager.Instance.CrunOptions.HistoryAutoRefreshIntervalMinutes,runImmediately);
}
public void StopBackgroundTasks(){
checkForNewEpisodesRunner.Stop();
}
}

View file

@ -139,8 +139,8 @@ public partial class QueueManager : ObservableObject{
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
var variant = sList.EpisodeAndLanguages.Variants.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
var episode = sList.EpisodeAndLanguages.Items.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(episode.SeriesId, episode.SeasonId, episode.Id);
if (historyEpisode.dublist.Count > 0){
dubLang = historyEpisode.dublist;
}
@ -238,9 +238,8 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.CrLocale ?? "Unknown"}")
.ToArray();
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray();
Console.Error.WriteLine(
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]");
@ -253,9 +252,8 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Episode couldn't be added to Queue");
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.CrLocale ?? "Unknown"}")
.ToArray();
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray();
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
@ -376,7 +374,7 @@ public partial class QueueManager : ObservableObject{
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.But, data.AllEpisodes, data.E);
var failed = false;
var partialAdd = false;

View file

@ -6,19 +6,29 @@ using ReactiveUI.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){
var isHeadless = args.Contains("--headless");
BuildAvaloniaApp(isHeadless).StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
// public static AppBuilder BuildAvaloniaApp()
// => AppBuilder.Configure<App>()
// .UsePlatformDetect()
// .WithInterFont()
// .LogToTrace();
public static AppBuilder BuildAvaloniaApp(bool isHeadless){
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI(_ => { });
.UseReactiveUI() ;
if (isHeadless){
Console.WriteLine("Running in headless mode...");

View file

@ -276,12 +276,6 @@ public enum EpisodeDownloadMode{
OnlySubs,
}
public enum HistoryRefreshMode{
DefaultAll = 0,
DefaultActive = 1,
FastNewReleases = 50
}
public enum SonarrCoverType{
Banner,
FanArt,

View file

@ -7,7 +7,6 @@ using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@ -361,201 +360,158 @@ public class Helpers{
}
}
private static IEnumerable<string> GetQualityOption(VideoPreset preset){
private static string GetQualityOption(VideoPreset preset){
return preset.Codec switch{
"h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()],
"h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()],
"h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
_ =>["-crf", preset.Crf.ToString()]
"h264_nvenc" or "hevc_nvenc" => $"-cq {preset.Crf}", // For NVENC
"h264_qsv" or "hevc_qsv" => $"-global_quality {preset.Crf}", // For Intel QSV
"h264_amf" or "hevc_amf" => $"-qp {preset.Crf}", // For AMD VCE
_ => $"-crf {preset.Crf}", // For software codecs like libx264/libx265
};
}
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
string inputFilePath,
VideoPreset preset,
CrunchyEpMeta? data = null){
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(string inputFilePath, VideoPreset preset, CrunchyEpMeta? data = null){
try{
string ext = Path.GetExtension(inputFilePath);
string dir = Path.GetDirectoryName(inputFilePath)!;
string name = Path.GetFileNameWithoutExtension(inputFilePath);
string outputExtension = Path.GetExtension(inputFilePath);
string directory = Path.GetDirectoryName(inputFilePath);
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath);
string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}");
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
string additionalParams = string.Join(" ", preset.AdditionalParameters.Select(param => {
var splitIndex = param.IndexOf(' ');
if (splitIndex > 0){
var prefix = param[..splitIndex];
var value = param[(splitIndex + 1)..];
if (value.Contains(' ') && !(value.StartsWith("\"") && value.EndsWith("\""))){
value = $"\"{value}\"";
}
return $"{prefix} {value}";
}
return param;
}));
string qualityOption = GetQualityOption(preset);
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
var args = new List<string>{
"-nostdin",
"-hide_banner",
"-loglevel", "error",
"-i", inputFilePath,
};
if (!string.IsNullOrWhiteSpace(preset.Codec)){
args.Add("-c:v");
args.Add(preset.Codec);
if (totalDuration == null){
Console.Error.WriteLine("Unable to retrieve input file duration.");
} else{
Console.WriteLine($"Total Duration: {totalDuration}");
}
args.AddRange(GetQualityOption(preset));
args.Add("-vf");
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
string ffmpegCommand = $"-loglevel info -i \"{inputFilePath}\" -c:v {preset.Codec} {qualityOption} -vf \"scale={preset.Resolution},fps={preset.FrameRate}\" {additionalParams} \"{tempOutputFilePath}\"";
using (var process = new Process()){
process.StartInfo.FileName = CfgManager.PathFFMPEG;
process.StartInfo.Arguments = ffmpegCommand;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.EnableRaisingEvents = true;
foreach (var param in preset.AdditionalParameters){
args.AddRange(SplitArguments(param));
}
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
}
};
args.Add(tempOutput);
process.ErrorDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine($"{e.Data}");
if (data != null && totalDuration != null){
ParseProgress(e.Data, totalDuration.Value, data);
}
}
};
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
int exitCode;
try{
exitCode = await RunFFmpegAsync(
CfgManager.PathFFMPEG,
args,
data?.Cts.Token ?? CancellationToken.None,
onStdErr: line => { Console.Error.WriteLine(line); },
onStdOut: Console.WriteLine
);
} catch (OperationCanceledException){
if (File.Exists(tempOutput)){
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var reg = data?.Cts.Token.Register(() => {
try{
File.Delete(tempOutput);
if (!process.HasExited)
process.Kill(true);
} catch{
// ignored
}
});
try{
await process.WaitForExitAsync(data.Cts.Token);
} catch (OperationCanceledException){
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
// ignored
}
}
return (IsOk: false, ErrorCode: -2);
}
Console.Error.WriteLine("FFMPEG task was canceled");
return (false, -2);
}
bool isSuccess = process.ExitCode == 0;
bool success = exitCode == 0;
if (isSuccess){
// Delete the original input file
File.Delete(inputFilePath);
if (success){
File.Delete(inputFilePath);
File.Move(tempOutput, inputFilePath);
} else{
if (File.Exists(tempOutput)){
File.Delete(tempOutput);
// Rename the output file to the original name
File.Move(tempOutputFilePath, inputFilePath);
} else{
// If something went wrong, delete the temporary output file
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
/* ignore */
}
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine($"Command: {ffmpegCommand}");
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine("Command:");
Console.Error.WriteLine(commandString);
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
}
return (success, exitCode);
} catch (Exception ex){
Console.Error.WriteLine(ex);
return (false, -1);
Console.Error.WriteLine($"An error occurred: {ex.Message}");
return (IsOk: false, ErrorCode: -1);
}
}
private static IEnumerable<string> SplitArguments(string commandLine){
var args = new List<string>();
var current = new StringBuilder();
bool inQuotes = false;
foreach (char c in commandLine){
if (c == '"'){
inQuotes = !inQuotes;
continue;
}
if (char.IsWhiteSpace(c) && !inQuotes){
if (current.Length > 0){
args.Add(current.ToString());
current.Clear();
}
} else{
current.Append(c);
}
}
if (current.Length > 0)
args.Add(current.ToString());
return args;
}
private static string BuildCommandString(string exe, IEnumerable<string> args){
static string Quote(string s){
if (string.IsNullOrWhiteSpace(s))
return "\"\"";
return s.Contains(' ') || s.Contains('"')
? $"\"{s.Replace("\"", "\\\"")}\""
: s;
}
return exe + " " + string.Join(" ", args.Select(Quote));
}
public static async Task<int> RunFFmpegAsync(
string ffmpegPath,
IEnumerable<string> args,
CancellationToken token,
Action<string>? onStdErr = null,
Action<string>? onStdOut = null){
using var process = new Process();
process.StartInfo = new ProcessStartInfo{
FileName = ffmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var arg in args)
process.StartInfo.ArgumentList.Add(arg);
process.Start();
// capture streams instead of process
var stdout = process.StandardOutput;
var stderr = process.StandardError;
async Task ReadStreamAsync(StreamReader reader, Action<string>? callback){
while (await reader.ReadLineAsync(token) is{ } line)
callback?.Invoke(line);
}
var stdoutTask = ReadStreamAsync(stdout, onStdOut);
var stderrTask = ReadStreamAsync(stderr, onStdErr);
var proc = process;
await using var reg = token.Register(() => {
try{
proc.Kill(true);
} catch{
// ignored
}
});
private static void ParseProgress(string progressString, TimeSpan totalDuration, CrunchyEpMeta data){
try{
await process.WaitForExitAsync(token);
} catch (OperationCanceledException){
try{
if (!process.HasExited)
process.Kill(true);
} catch{
// ignored
if (progressString.Contains("time=")){
var timeIndex = progressString.IndexOf("time=") + 5;
var timeString = progressString.Substring(timeIndex, 11);
if (TimeSpan.TryParse(timeString, out var currentTime)){
int progress = (int)(currentTime.TotalSeconds / totalDuration.TotalSeconds * 100);
Console.WriteLine($"Progress: {progress:F2}%");
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = progress,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Encoding"
};
QueueManager.Instance.Queue.Refresh();
}
}
throw;
} catch (Exception e){
Console.Error.WriteLine("Failed to calculate encoding progess");
Console.Error.WriteLine(e.Message);
}
await Task.WhenAll(stdoutTask, stderrTask);
return process.ExitCode;
}
public static async Task<TimeSpan?> GetMediaDurationAsync(string ffmpegPath, string inputFilePath){
try{
using (var process = new Process()){
@ -899,7 +855,7 @@ public class Helpers{
bool mergeVideo){
if (target == null) throw new ArgumentNullException(nameof(target));
if (source == null) throw new ArgumentNullException(nameof(source));
var serverSet = new HashSet<string>(target.servers);
void AddServer(string s){
@ -910,14 +866,14 @@ public class Helpers{
foreach (var kvp in source){
var key = kvp.Key;
var src = kvp.Value;
if (!src.servers.Contains(key))
src.servers.Add(key);
AddServer(key);
foreach (var s in src.servers)
AddServer(s);
if (mergeAudio && src.audio != null){
target.audio ??= [];
target.audio.AddRange(src.audio);
@ -955,8 +911,6 @@ public class Helpers{
if (result == ContentDialogResult.Primary){
timer.Stop();
}
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
ShutdownLock.Release();
}
@ -1013,18 +967,4 @@ public class Helpers{
Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}");
}
}
public static bool ExecuteFile(string filePath){
try{
Process.Start(new ProcessStartInfo{
FileName = filePath,
UseShellExecute = true
});
return true;
} catch (Exception ex){
Console.Error.WriteLine($"Execution failed: {ex.Message}");
return false;
}
}
}

View file

@ -296,7 +296,7 @@ public static class ApiUrls{
public static string Auth => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/auth/v1/token";
public static string Profile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/profile";
public static string MultiProfile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
public static string Profiles => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
public static string CmsToken => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/index/v2";
public static string Search => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/search";
public static string Browse => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/browse";

View file

@ -2,40 +2,37 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Views;
using SixLabors.Fonts;
namespace CRD.Utils.Muxing;
public class FontsManager{
#region Singelton
private static readonly Lock Padlock = new Lock();
private static FontsManager? instance;
private static readonly object padlock = new object();
public static FontsManager Instance{
get{
if (field == null){
lock (Padlock){
if (field == null){
field = new FontsManager();
if (instance == null){
lock (padlock){
if (instance == null){
instance = new FontsManager();
}
}
}
return field;
return instance;
}
}
#endregion
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
{ "Adobe Arabic", "AdobeArabic-Bold.otf" },
{ "Andale Mono", "andalemo.ttf" },
{ "Arial", "arial.ttf" },
@ -106,14 +103,9 @@ public class FontsManager{
{ "Webdings", "webdings.ttf" }
};
private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
private readonly FontIndex index = new();
private void EnsureIndex(string fontsDir){
index.Rebuild(fontsDir);
}
public async Task GetFontsAsync(){
Console.WriteLine("Downloading fonts...");
@ -123,334 +115,134 @@ public class FontsManager{
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
continue;
}
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0)
File.Delete(fontLoc);
try{
if (!Directory.Exists(fontFolder))
Directory.CreateDirectory(fontFolder!);
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontUrl = root + font;
var httpClient = HttpClientReq.Instance.GetHttpClient();
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
// Console.WriteLine($"{font} already downloaded!");
} else{
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){
File.Delete(fontLoc);
}
try{
if (!Directory.Exists(fontFolder)){
Directory.CreateDirectory(fontFolder);
}
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontUrl = root + font;
var httpClient = HttpClientReq.Instance.GetHttpClient();
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
}
}
Console.WriteLine("All required fonts downloaded!");
}
public static List<string> ExtractFontsFromAss(string ass){
if (string.IsNullOrWhiteSpace(ass))
return new List<string>();
ass = ass.Replace("\r", "");
var lines = ass.Split('\n');
var fonts = new List<string>();
var lines = ass.Replace("\r", "").Split('\n');
var styles = new List<string>();
foreach (var line in lines){
if (line.StartsWith("Style: ", StringComparison.OrdinalIgnoreCase)){
var parts = line.Substring(7).Split(',');
if (parts.Length > 1){
var fontName = parts[1].Trim();
fonts.Add(NormalizeFontKey(fontName));
}
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){
var fontName = match.Groups[1].Value.Trim();
fonts.Add(NormalizeFontKey(fontName));
}
if (match.Groups.Count > 1)
styles.Add(match.Groups[1].Value);
}
return fonts
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
return styles.Distinct().ToList(); // Using Linq to remove duplicates
}
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList, bool keepUnknown = true){
Dictionary<string, string> filteredDictionary = new(StringComparer.OrdinalIgnoreCase);
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList){
Dictionary<string, string> filteredDictionary = new Dictionary<string, string>();
foreach (string key in keysList){
var k = NormalizeFontKey(key);
if (Fonts.TryGetValue(k, out var fontFile)){
filteredDictionary[k] = fontFile;
} else if (keepUnknown){
filteredDictionary[k] = k;
if (Fonts.TryGetValue(key, out var font)){
filteredDictionary.Add(key, font);
}
}
return filteredDictionary;
}
public static string GetFontMimeType(string fontFileOrPath){
var ext = Path.GetExtension(fontFileOrPath);
if (ext.Equals(".otf", StringComparison.OrdinalIgnoreCase))
public static string GetFontMimeType(string fontFile){
if (Regex.IsMatch(fontFile, @"\.otf$"))
return "application/vnd.ms-opentype";
if (ext.Equals(".ttf", StringComparison.OrdinalIgnoreCase))
else if (Regex.IsMatch(fontFile, @"\.ttf$"))
return "application/x-truetype-font";
if (ext.Equals(".ttc", StringComparison.OrdinalIgnoreCase) || ext.Equals(".otc", StringComparison.OrdinalIgnoreCase))
return "application/x-truetype-font";
if (ext.Equals(".woff", StringComparison.OrdinalIgnoreCase))
return "font/woff";
if (ext.Equals(".woff2", StringComparison.OrdinalIgnoreCase))
return "font/woff2";
return "application/octet-stream";
else
return "application/octet-stream";
}
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
EnsureIndex(fontsDir);
var required = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var subsLocales = new List<string>();
var fontsList = new List<ParsedFont>();
var missing = new List<string>();
Dictionary<string, string> fontsNameList = new Dictionary<string, string>();
List<string> subsList = new List<string>();
List<ParsedFont> fontsList = new List<ParsedFont>();
bool isNstr = true;
foreach (var s in subs){
subsLocales.Add(s.Language.Locale);
foreach (var keyValuePair in s.Fonts){
if (!fontsNameList.ContainsKey(keyValuePair.Key)){
fontsNameList.Add(keyValuePair.Key, keyValuePair.Value);
}
}
foreach (var kv in s.Fonts)
required.Add(NormalizeFontKey(kv.Key));
subsList.Add(s.Language.Locale);
}
if (subsLocales.Count > 0)
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsLocales), subsLocales.Count);
if (subsList.Count > 0){
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count);
isNstr = false;
}
if (required.Count > 0)
Console.WriteLine("Required fonts: {0} (Total: {1})", string.Join(", ", required), required.Count);
if (fontsNameList.Count > 0){
Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count);
}
foreach (var requested in required){
if (TryResolveFontPath(requested, fontsDir, out var resolvedPath, out var exact)){
if (!File.Exists(resolvedPath) || new FileInfo(resolvedPath).Length == 0){
missing.Add(requested);
continue;
List<string> missingFonts = new List<string>();
foreach (var f in fontsNameList){
if (Fonts.TryGetValue(f.Key, out var fontFile)){
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 });
}
var attachName = MakeUniqueAttachmentName(resolvedPath, fontsList);
fontsList.Add(new ParsedFont{
Name = attachName,
Path = resolvedPath,
Mime = GetFontMimeType(resolvedPath)
});
if (!exact) Console.WriteLine($"Soft-resolved '{requested}' -> '{Path.GetFileName(resolvedPath)}'");
} else{
missing.Add(requested);
missingFonts.Add(f.Key);
}
}
if (missing.Count > 0)
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}");
if (missingFonts.Count > 0){
MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}");
}
return fontsList;
}
private bool TryResolveFontPath(string requestedName, string fontsDir, out string resolvedPath, out bool isExactMatch){
resolvedPath = string.Empty;
isExactMatch = true;
var req = NormalizeFontKey(requestedName);
if (index.TryResolve(req, out resolvedPath))
return true;
if (Fonts.TryGetValue(req, out var crFile)){
var p = Path.Combine(fontsDir, crFile);
if (File.Exists(p)){
resolvedPath = p;
return true;
}
}
var family = StripStyleSuffix(req);
if (!family.Equals(req, StringComparison.OrdinalIgnoreCase)){
isExactMatch = false;
if (index.TryResolve(family, out resolvedPath))
return true;
if (Fonts.TryGetValue(family, out var crFamilyFile)){
var p = Path.Combine(fontsDir, crFamilyFile);
if (File.Exists(p)){
resolvedPath = p;
return true;
}
}
}
return false;
}
private static string StripStyleSuffix(string name){
var n = name;
n = Regex.Replace(n, @"\s+(Bold\s+Italic|Bold\s+Oblique|Black\s+Italic|Black|Bold|Italic|Oblique|Regular)$",
"", RegexOptions.IgnoreCase).Trim();
return n;
}
public static string NormalizeFontKey(string s){
if (string.IsNullOrWhiteSpace(s))
return string.Empty;
s = s.Trim().Trim('"');
if (s.StartsWith("@"))
s = s.Substring(1);
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
s = s.Replace('_', ' ').Replace('-', ' ');
s = Regex.Replace(s, @"\s+", " ").Trim();
s = Regex.Replace(s, @"\s+Regular$", "", RegexOptions.IgnoreCase);
return s;
}
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
var baseName = Path.GetFileName(path);
if (existing.All(e => !baseName.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
return baseName;
var hash = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(path)))
.Substring(0, 8)
.ToLowerInvariant();
return $"{hash}-{baseName}";
}
private sealed class FontIndex{
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
public void Rebuild(string fontsDir){
map.Clear();
if (!Directory.Exists(fontsDir)) return;
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
continue;
foreach (var desc in LoadDescriptions(path)){
foreach (var alias in BuildAliases(desc)){
Add(alias, path);
}
}
}
}
public bool TryResolve(string fontName, out string path){
path = string.Empty;
if (string.IsNullOrWhiteSpace(fontName)) return false;
var key = NormalizeFontKey(fontName);
if (map.TryGetValue(fontName, out var c1)){
path = c1.Path;
return true;
}
if (map.TryGetValue(key, out var c2)){
path = c2.Path;
return true;
}
return false;
}
private void Add(string alias, string path){
if (string.IsNullOrWhiteSpace(alias)) return;
var a1 = alias.Trim();
var a2 = NormalizeFontKey(a1);
Upsert(a1, path);
Upsert(a2, path);
}
private void Upsert(string key, string path){
if (string.IsNullOrWhiteSpace(key)) return;
var cand = new Candidate(path, GetScore(path));
if (map.TryGetValue(key, out var existing)){
if (cand.Score > existing.Score)
map[key] = cand;
} else{
map[key] = cand;
}
}
private static int GetScore(string path){
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch{
".ttf" => 100,
".otf" => 95,
".ttc" => 90,
".otc" => 85,
".woff" => 40,
".woff2" => 35,
_ => 0
};
}
private static IEnumerable<FontDescription> LoadDescriptions(string fontPath){
var ext = Path.GetExtension(fontPath).ToLowerInvariant();
if (ext is ".ttc" or ".otc")
return FontDescription.LoadFontCollectionDescriptions(fontPath);
return new[]{ FontDescription.LoadDescription(fontPath) };
}
private static IEnumerable<string> BuildAliases(FontDescription d){
var family = d.FontFamilyInvariantCulture.Trim();
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic
var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily"
if (!string.IsNullOrWhiteSpace(family)) yield return family;
if (!string.IsNullOrWhiteSpace(full)) yield return full;
if (!string.IsNullOrWhiteSpace(family) &&
!string.IsNullOrWhiteSpace(sub) &&
!sub.Equals("Regular", StringComparison.OrdinalIgnoreCase)){
yield return $"{family} {sub}";
}
}
private readonly record struct Candidate(string Path, int Score);
}
}
public class SubtitleFonts{
public LanguageItem Language{ get; set; } = new();
public Dictionary<string, string> Fonts{ get; set; } = new();
public LanguageItem Language{ get; set; }
public Dictionary<string, string> Fonts{ get; set; }
}

View file

@ -1,83 +0,0 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils;
public class PeriodicWorkRunner(Func<CancellationToken, Task> work) : IDisposable{
private CancellationTokenSource? cts;
private Task? loopTask;
private TimeSpan currentInterval;
public DateTime LastRunTime = DateTime.MinValue;
public void StartOrRestart(TimeSpan interval, bool runImmediately = false, bool force = false){
if (interval <= TimeSpan.Zero){
Stop();
currentInterval = Timeout.InfiniteTimeSpan;
return;
}
if (!force && interval == currentInterval){
return;
}
currentInterval = interval;
Stop();
cts = new CancellationTokenSource();
loopTask = RunLoopAsync(interval, runImmediately, cts.Token);
}
public void StartOrRestartMinutes(int minutes, bool runImmediately = false, bool force = false)
=> StartOrRestart(TimeSpan.FromMinutes(minutes), runImmediately);
public void Stop(){
if (cts is null) return;
try{
cts.Cancel();
} finally{
cts.Dispose();
cts = null;
}
}
private async Task RunLoopAsync(TimeSpan interval, bool runImmediately, CancellationToken token){
if (runImmediately){
await SafeRunWork(token).ConfigureAwait(false);
}
using var timer = new PeriodicTimer(interval);
try{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)){
await SafeRunWork(token).ConfigureAwait(false);
}
} catch (OperationCanceledException){
}
}
private int running = 0;
private async Task SafeRunWork(CancellationToken token){
if (Interlocked.Exchange(ref running, 1) == 1){
Console.Error.WriteLine("Task is already running!");
return;
}
try{
await work(token).ConfigureAwait(false);
LastRunTime = DateTime.Now;
} catch (OperationCanceledException) when (token.IsCancellationRequested){
} catch (Exception ex){
Console.Error.WriteLine(ex);
} finally{
Interlocked.Exchange(ref running, 0);
}
}
public void Dispose() => Stop();
}

View file

@ -56,11 +56,6 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; }
[JsonProperty("download_finished_execute")]
public bool DownloadFinishedExecute{ get; set; }
[JsonProperty("download_finished_execute_path")]
public string? DownloadFinishedExecutePath{ get; set; }
[JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; }
@ -97,13 +92,6 @@ public class CrDownloadOptions{
[JsonProperty("history_count_sonarr")]
public bool HistoryCountSonarr{ get; set; }
[JsonProperty("history_auto_refresh_interval_minutes")]
public int HistoryAutoRefreshIntervalMinutes{ get; set; }
[JsonProperty("history_auto_refresh_mode")]
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
[JsonProperty("sonarr_properties")]
public SonarrProperties? SonarrProperties{ get; set; }
@ -153,17 +141,6 @@ public class CrDownloadOptions{
[JsonProperty("flare_solverr_properties")]
public FlareSolverrProperties? FlareSolverrProperties{ get; set; }
[JsonProperty("tray_icon_enabled")]
public bool TrayIconEnabled{ get; set; }
[JsonProperty("tray_start_minimized")]
public bool StartMinimizedToTray{ get; set; }
[JsonProperty("tray_on_minimize")]
public bool MinimizeToTray{ get; set; }
[JsonProperty("tray_on_close")]
public bool MinimizeToTrayOnClose{ get; set; }
#endregion
@ -333,9 +310,6 @@ public class CrDownloadOptions{
[JsonProperty("calendar_show_upcoming_episodes")]
public bool CalendarShowUpcomingEpisodes{ get; set; }
[JsonProperty("calendar_update_history")]
public bool UpdateHistoryFromCalendar{ get; set; }
[JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; }

View file

@ -4,20 +4,7 @@ using Newtonsoft.Json;
namespace CRD.Utils.Structs.Crunchyroll;
public class CrMultiProfile{
[JsonProperty("tier_max_profiles")]
public int? TierMaxProfiles{ get; set; }
[JsonProperty("max_profiles")]
public int? MaxProfiles{ get; set; }
[JsonProperty("profiles")]
public List<CrProfile> Profiles{ get; set; } = [];
}
public class CrProfile{
public string? Avatar{ get; set; }
public string? Email{ get; set; }
public string? Username{ get; set; }
@ -33,14 +20,8 @@ public class CrProfile{
[JsonProperty("preferred_content_subtitle_language")]
public string? PreferredContentSubtitleLanguage{ get; set; }
[JsonProperty("can_switch")]
public bool CanSwitch{ get; set; }
[JsonProperty("is_selected")]
public bool IsSelected{ get; set; }
[JsonProperty("is_pin_protected")]
public bool IsPinProtected{ get; set; }
[JsonIgnore]
public Subscription? Subscription{ get; set; }
[JsonIgnore]
public bool HasPremium{ get; set; }

View file

@ -190,7 +190,7 @@ public class CrBrowseEpisodeVersion{
public Locale? AudioLocale{ get; set; }
public string? Guid{ get; set; }
public bool Original{ get; set; }
public bool? Original{ get; set; }
public string? Variant{ get; set; }
[JsonProperty("season_guid")]
@ -200,6 +200,6 @@ public class CrBrowseEpisodeVersion{
public string? MediaGuid{ get; set; }
[JsonProperty("is_premium_only")]
public bool IsPremiumOnly{ get; set; }
public bool? IsPremiumOnly{ get; set; }
}

View file

@ -1,7 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Structs.History;
using CRD.Views;
@ -14,23 +12,6 @@ public class AuthData{
public string Password{ get; set; }
}
public partial class AccountProfile : ObservableObject{
[ObservableProperty]
private string _profileName = "";
[ObservableProperty]
private string _avatarUrl = "";
[ObservableProperty]
private Bitmap? _profileImage;
[ObservableProperty]
private bool _canBeSelected;
public string? ProfileId{ get; set; }
}
public class CrAuthSettings{
public string Endpoint{ get; set; }
public string Authorization{ get; set; }
@ -70,18 +51,9 @@ public class LanguageItem{
public string Language{ get; set; }
}
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
public class EpisodeAndLanguage{
public List<EpisodeVariant> Variants{ get; set; } = new();
public bool AddUnique(CrunchyEpisode item, LanguageItem lang){
if (Variants.Any(v => v.Lang.CrLocale == lang.CrLocale))
return false;
Variants.Add(new EpisodeVariant(item, lang));
return true;
}
public List<CrunchyEpisode> Items{ get; set; }
public List<LanguageItem> Langs{ get; set; }
}
public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
@ -159,11 +131,6 @@ public class StringItemWithDisplayName{
public string value{ get; set; }
}
public class RefreshModeOption{
public string DisplayName{ get; set; }
public HistoryRefreshMode value{ get; set; }
}
public class WindowSettings{
public double Width{ get; set; }
public double Height{ get; set; }

View file

@ -1,7 +1,6 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using CRD.Downloader;
@ -141,16 +140,10 @@ public class HistoryEpisode : INotifyPropertyChanged{
}
public async Task DownloadEpisodeDefault(){
await DownloadEpisode(EpisodeDownloadMode.Default,"",false);
await DownloadEpisode();
}
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode, string overrideDownloadPath,bool chekQueueForId){
if (chekQueueForId && QueueManager.Instance.Queue.Any(item => item.Data.Any(epmeta => epmeta.MediaId == EpisodeId))){
Console.Error.WriteLine($"Episode already in queue! E{EpisodeSeasonNum}-{EpisodeTitle}");
return;
}
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){
switch (EpisodeType){
case EpisodeType.MusicVideo:
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);

View file

@ -64,16 +64,16 @@ public class HistorySeries : INotifyPropertyChanged{
public string HistorySeriesVideoQualityOverride{ get; set; } = "";
[JsonProperty("history_series_available_soft_subs")]
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } = [];
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } =[];
[JsonProperty("history_series_available_dub_lang")]
public List<string> HistorySeriesAvailableDubLang{ get; set; } = [];
public List<string> HistorySeriesAvailableDubLang{ get; set; } =[];
[JsonProperty("history_series_soft_subs_override")]
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } = [];
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } =[];
[JsonProperty("history_series_dub_lang_override")]
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } = [];
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } =[];
public event PropertyChangedEventHandler? PropertyChanged;
@ -329,7 +329,7 @@ public class HistorySeries : INotifyPropertyChanged{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData)));
}
public async Task AddNewMissingToDownloads(bool chekQueueForId = false){
public async Task AddNewMissingToDownloads(){
bool foundWatched = false;
var options = CrunchyrollManager.Instance.CrunOptions;
@ -355,7 +355,7 @@ public class HistorySeries : INotifyPropertyChanged{
}
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId);
await ep.DownloadEpisode();
}
}
}
@ -372,14 +372,14 @@ public class HistorySeries : INotifyPropertyChanged{
if (ep.SpecialEpisode){
if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId);
await ep.DownloadEpisode();
}
continue;
}
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId);
await ep.DownloadEpisode();
} else{
foundWatched = true;
if (!historyAddSpecials && !countMissing){
@ -467,61 +467,54 @@ public class HistorySeries : INotifyPropertyChanged{
}
public void UpdateSeriesFolderPath(){
// Reset state first
SeriesFolderPath = string.Empty;
SeriesFolderPathExists = false;
var season = Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath));
var season = Seasons.FirstOrDefault(s => !string.IsNullOrEmpty(s.SeasonDownloadPath));
// Series path
if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){
SeriesFolderPath = SeriesDownloadPath;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
// Season path
if (!string.IsNullOrEmpty(season?.SeasonDownloadPath)){
if (season is{ SeasonDownloadPath: not null }){
try{
var directoryInfo = new DirectoryInfo(season.SeasonDownloadPath);
var seasonPath = season.SeasonDownloadPath;
var directoryInfo = new DirectoryInfo(seasonPath);
var parentFolder = directoryInfo.Parent?.FullName;
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){
SeriesFolderPath = parentFolder;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
if (Directory.Exists(parentFolderPath)){
SeriesFolderPath = parentFolderPath;
SeriesFolderPathExists = true;
}
}
} catch (Exception e){
Console.Error.WriteLine($"Error resolving season folder: {e.Message}");
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}");
}
}
} else{
string customPath;
// Auto generated path
if (string.IsNullOrEmpty(SeriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
if (string.IsNullOrEmpty(SeriesTitle))
return;
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
if (string.IsNullOrEmpty(seriesTitle))
return;
string basePath =
!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DownloadDirPath)
? CrunchyrollManager.Instance.CrunOptions.DownloadDirPath
: CfgManager.PathVIDEOS_DIR;
// Check Crunchyroll download directory
var downloadDirPath = CrunchyrollManager.Instance.CrunOptions.DownloadDirPath;
if (!string.IsNullOrEmpty(downloadDirPath)){
customPath = Path.Combine(downloadDirPath, seriesTitle);
} else{
// Fallback to configured VIDEOS_DIR path
customPath = Path.Combine(CfgManager.PathVIDEOS_DIR, seriesTitle);
}
var customPath = Path.Combine(basePath, seriesTitle);
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
// Check if custom path exists
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
}
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));

View file

@ -127,7 +127,8 @@ public class EpisodeHighlightTextBlock : TextBlock{
streamingService == StreamingService.Crunchyroll ? new HashSet<string>(CrunchyrollManager.Instance.CrunOptions.DlSubs) :
new HashSet<string>();
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && (subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []) || subSet.Contains("all"));
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) &&
subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []);
if (higlight){
Foreground = Brushes.Orange;

View file

@ -155,7 +155,7 @@ public class HighlightingTextBlock : TextBlock{
foreach (var item in Items){
var run = new Run(item);
if (highlightSet.Contains(item) || highlightSet.Contains("all")){
if (highlightSet.Contains(item)){
run.Foreground = Brushes.Orange;
// run.FontWeight = FontWeight.Bold;
}

View file

@ -7,9 +7,6 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using CRD.ViewModels.Utils;
using CRD.Views.Utils;
using FluentAvalonia.UI.Controls;
using Newtonsoft.Json;
@ -25,9 +22,6 @@ public partial class AccountPageViewModel : ViewModelBase{
[ObservableProperty]
private string _loginLogoutText = "";
[ObservableProperty]
private bool _hasMultiProfile;
[ObservableProperty]
private string _remainingTime = "";
@ -56,8 +50,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RemainingTime = "Subscription maybe ended";
}
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
}
} else{
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
@ -65,18 +59,13 @@ public partial class AccountPageViewModel : ViewModelBase{
}
public void UpdatetProfile(){
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
var firstEndpointProfile = firstEndpoint.Profile;
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1;
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state
ProfileName = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username ?? CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.ProfileName ?? "???"; // Default or fetched user name
LoginLogoutText = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???" ? "Login" : "Logout"; // Default state
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
(string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar));
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription;
if (subscriptions != null){
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
@ -95,8 +84,8 @@ public partial class AccountPageViewModel : ViewModelBase{
UnknownEndDate = true;
}
if (!UnknownEndDate){
_targetTime = subscriptions.NextRenewalDate;
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){
_targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate;
_timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1)
};
@ -112,8 +101,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RaisePropertyChanged(nameof(RemainingTime));
if (subscriptions != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
}
}
@ -148,41 +137,6 @@ public partial class AccountPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public async Task OpenMultiProfileDialog(){
var multiProfile = CrunchyrollManager.Instance.CrAuthEndpoint1.MultiProfile;
var profiels = multiProfile.Profiles.Select(multiProfileProfile => new AccountProfile{
AvatarUrl = string.IsNullOrEmpty(multiProfileProfile.Avatar) ? "" : ("https://static.crunchyroll.com/assets/avatar/170x170/" + multiProfileProfile.Avatar),
ProfileName = multiProfileProfile.Username ?? multiProfileProfile.ProfileName ?? "???", CanBeSelected = multiProfileProfile is{ IsSelected: false, CanSwitch: true, IsPinProtected: false },
ProfileId = multiProfileProfile.ProfileId,
}).ToList();
var dialog = new CustomContentDialog(){
Name = "CRD Select Profile",
Title = "Select Profile",
IsPrimaryButtonEnabled = false,
CloseButtonText = "Close",
FullSizeDesired = true,
};
var viewModel = new ContentDialogMultiProfileSelectViewModel(dialog, profiels);
dialog.Content = new ContentDialogMultiProfileSelectView(){
DataContext = viewModel
};
var dialogResult = await dialog.ShowAsync();
if (dialogResult == ContentDialogResult.Primary){
var selectedProfile = viewModel.SelectedItem;
await CrunchyrollManager.Instance.CrAuthEndpoint1.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
await CrunchyrollManager.Instance.CrAuthEndpoint2.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
UpdatetProfile();
}
}
public async void LoadProfileImage(string imageUrl){
try{
ProfileImage = await Helpers.LoadImage(imageUrl);

View file

@ -31,9 +31,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _showUpcomingEpisodes;
[ObservableProperty]
private bool _updateHistoryFromCalendar;
[ObservableProperty]
private bool _hideDubs;
@ -77,7 +74,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar;
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes;
UpdateHistoryFromCalendar = CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar;
ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0];
@ -293,14 +289,4 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings();
}
}
partial void OnUpdateHistoryFromCalendarChanged(bool value){
if (loading){
return;
}
CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar = value;
CfgManager.WriteCrSettings();
}
}

View file

@ -44,17 +44,17 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem? _selectedView;
public ObservableCollection<ComboBoxItem> ViewsList{ get; } = [];
public ObservableCollection<ComboBoxItem> ViewsList{ get; } =[];
[ObservableProperty]
private SortingListElement? _selectedSorting;
public ObservableCollection<SortingListElement> SortingList{ get; } = [];
public ObservableCollection<SortingListElement> SortingList{ get; } =[];
[ObservableProperty]
private FilterListElement? _selectedFilter;
public ObservableCollection<FilterListElement> FilterList{ get; } = [];
public ObservableCollection<FilterListElement> FilterList{ get; } =[];
[ObservableProperty]
private double _posterWidth;
@ -115,16 +115,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private static string _progressText;
[ObservableProperty]
private string _searchInput;
[ObservableProperty]
private bool _isSearchOpen;
[ObservableProperty]
public bool _isSearchActiveClosed;
#region Table Mode
[ObservableProperty]
@ -134,8 +125,8 @@ public partial class HistoryPageViewModel : ViewModelBase{
public Symbol _selectedDownloadIcon = Symbol.ClosedCaption;
#endregion
public Vector LastScrollOffset{ get; set; } = Vector.Zero;
public Vector LastScrollOffset { get; set; } = Vector.Zero;
public HistoryPageViewModel(){
ProgramManager = ProgramManager.Instance;
@ -333,32 +324,11 @@ public partial class HistoryPageViewModel : ViewModelBase{
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
}
if (!string.IsNullOrWhiteSpace(SearchInput)){
var tokens = SearchInput
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
filteredItems.RemoveAll(item => {
var title = item.SeriesTitle ?? string.Empty;
return tokens.Any(t => title.IndexOf(t, StringComparison.OrdinalIgnoreCase) < 0);
});
}
FilteredItems.Clear();
FilteredItems.AddRange(filteredItems);
}
partial void OnSearchInputChanged(string value){
ApplyFilter();
}
partial void OnIsSearchOpenChanged(bool value){
IsSearchActiveClosed = !string.IsNullOrEmpty(SearchInput) && !IsSearchOpen;
}
partial void OnScaleValueChanged(double value){
double t = (ScaleValue - 0.5) / (1 - 0.5);
@ -404,10 +374,6 @@ public partial class HistoryPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public void ClearSearchCommand(){
SearchInput = "";
}
[RelayCommand]
public void NavToSeries(){
@ -453,7 +419,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
.SelectMany(item => item.Seasons)
.SelectMany(season => season.EpisodesList)
.Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile)
.Select(historyEpisode => historyEpisode.DownloadEpisodeDefault())
.Select(historyEpisode => historyEpisode.DownloadEpisode())
);
}
@ -549,7 +515,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -562,7 +528,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{
foreach (var episode in missingEpisodes){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
}
@ -570,16 +536,16 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
[RelayCommand]
public async Task DownloadEpisodeOnlyOptions(HistoryEpisode episode){
var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
@ -589,7 +555,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
}
@ -601,10 +567,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
historyEpisode.ToggleWasDownloaded();
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId);
}
}
seriesArgs.Season.UpdateDownloaded();
}
seriesArgs.Series?.UpdateNewEpisodes();
@ -641,7 +606,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
public void ToggleInactive(){
CfgManager.UpdateHistoryFile();
}
partial void OnSelectedDownloadModeChanged(EpisodeDownloadMode value){
SelectedDownloadIcon = SelectedDownloadMode switch{
EpisodeDownloadMode.OnlyVideo => Symbol.Video,

View file

@ -108,20 +108,6 @@ public partial class SeriesPageViewModel : ViewModelBase{
SelectedSeries.UpdateSeriesFolderPath();
}
[RelayCommand]
public void ClearFolderPathCommand(HistorySeason? season){
if (season != null){
season.SeasonDownloadPath = string.Empty;
} else{
SelectedSeries.SeriesDownloadPath = string.Empty;
}
CfgManager.UpdateHistoryFile();
SelectedSeries.UpdateSeriesFolderPath();
}
[RelayCommand]
public async Task OpenFeaturedMusicDialog(){
@ -227,7 +213,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -240,7 +226,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{
foreach (var episode in missingEpisodes){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
}
@ -250,7 +236,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
@ -260,7 +246,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode,"",false);
await episode.DownloadEpisode(downloadMode);
}
}
}
@ -268,7 +254,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisodeDefault();
await episode.DownloadEpisode();
}
}
@ -276,11 +262,11 @@ public partial class SeriesPageViewModel : ViewModelBase{
public void ToggleDownloadedMark(HistorySeason season){
bool allDownloaded = season.EpisodesList.All(ep => ep.WasDownloaded);
foreach (var historyEpisode in season.EpisodesList.Where(historyEpisode => historyEpisode.WasDownloaded == allDownloaded)){
historyEpisode.ToggleWasDownloaded();
foreach (var historyEpisode in season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
season.UpdateDownloaded(historyEpisode.EpisodeId);
}
}
season.UpdateDownloaded();
}
[RelayCommand]

View file

@ -81,7 +81,7 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{
[RelayCommand]
public void DownloadEpisode(HistoryEpisode episode){
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath,false);
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){

View file

@ -1,55 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using FluentAvalonia.UI.Controls;
namespace CRD.ViewModels.Utils;
public partial class ContentDialogMultiProfileSelectViewModel: ViewModelBase{
private readonly CustomContentDialog dialog;
[ObservableProperty]
private AccountProfile _selectedItem;
[ObservableProperty]
private ObservableCollection<AccountProfile> _profileList = [];
public ContentDialogMultiProfileSelectViewModel(CustomContentDialog contentDialog, List<AccountProfile> profiles){
ArgumentNullException.ThrowIfNull(contentDialog);
dialog = contentDialog;
dialog.Closed += DialogOnClosed;
dialog.PrimaryButtonClick += SaveButton;
try{
_ = LoadProfiles(profiles);
} catch (Exception e){
Console.WriteLine(e);
}
}
private async Task LoadProfiles(List<AccountProfile> profiles){
foreach (var accountProfile in profiles){
accountProfile.ProfileImage = await Helpers.LoadImage(accountProfile.AvatarUrl);
ProfileList.Add(accountProfile);
}
}
partial void OnSelectedItemChanged(AccountProfile value){
dialog.Hide(ContentDialogResult.Primary);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
dialog.PrimaryButtonClick -= SaveButton;
}
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
dialog.Closed -= DialogOnClosed;
}
}

View file

@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
@ -20,7 +19,6 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Sonarr;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
using FluentAvalonia.Styling;
@ -52,24 +50,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _historyCountSonarr;
[ObservableProperty]
private double? _historyAutoRefreshIntervalMinutes;
[ObservableProperty]
private HistoryRefreshMode _historyAutoRefreshMode;
[ObservableProperty]
private string _historyAutoRefreshModeHint;
[ObservableProperty]
private string _historyAutoRefreshLastRunTime;
public ObservableCollection<RefreshModeOption> HistoryAutoRefreshModes{ get; } = new(){
new RefreshModeOption(){ DisplayName = "Default All", value = HistoryRefreshMode.DefaultAll },
new RefreshModeOption(){ DisplayName = "Default Active", value = HistoryRefreshMode.DefaultActive },
new RefreshModeOption(){ DisplayName = "Fast New Releases", value = HistoryRefreshMode.FastNewReleases },
};
[ObservableProperty]
private double? _simultaneousDownloads;
@ -94,18 +74,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private double? _retryDelay;
[ObservableProperty]
private bool _trayIconEnabled;
[ObservableProperty]
private bool _startMinimizedToTray;
[ObservableProperty]
private bool _minimizeToTray;
[ObservableProperty]
private bool _minimizeToTrayOnClose;
[ObservableProperty]
private ComboBoxItem _selectedHistoryLang;
@ -263,12 +231,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private string _downloadFinishedSoundPath;
[ObservableProperty]
private bool _downloadFinishedExecute;
[ObservableProperty]
private string _downloadFinishedExecutePath;
[ObservableProperty]
private string _currentIp = "";
@ -301,9 +263,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
DownloadFinishedExecute = options.DownloadFinishedExecute;
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -341,9 +300,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
HistoryAddSpecials = options.HistoryAddSpecials;
HistorySkipUnmonitored = options.HistorySkipUnmonitored;
HistoryCountSonarr = options.HistoryCountSonarr;
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
DownloadSpeed = options.DownloadSpeedLimit;
DownloadSpeedInBits = options.DownloadSpeedInBits;
DownloadMethodeNew = options.DownloadMethodeNew;
@ -354,11 +310,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
LogMode = options.LogMode;
TrayIconEnabled = options.TrayIconEnabled;
StartMinimizedToTray = options.StartMinimizedToTray;
MinimizeToTray = options.MinimizeToTray;
MinimizeToTrayOnClose = options.MinimizeToTrayOnClose;
ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
CurrentAppTheme = theme ?? AppThemes[0];
@ -380,8 +331,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
var settings = CrunchyrollManager.Instance.CrunOptions;
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
settings.DownloadFinishedExecute = DownloadFinishedExecute;
settings.DownloadMethodeNew = DownloadMethodeNew;
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
@ -398,8 +347,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
settings.HistoryCountSonarr = HistoryCountSonarr;
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.DownloadSpeedInBits = DownloadSpeedInBits;
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
@ -457,11 +404,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
settings.FlareSolverrProperties = propsFlareSolverr;
settings.TrayIconEnabled = TrayIconEnabled;
settings.StartMinimizedToTray = StartMinimizedToTray;
settings.MinimizeToTray = MinimizeToTray;
settings.MinimizeToTrayOnClose = MinimizeToTrayOnClose;
settings.LogMode = LogMode;
@ -487,7 +429,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path;
DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath,
defaultPath: CfgManager.PathVIDEOS_DIR
);
}
@ -499,7 +441,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path;
TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath,
defaultPath: CfgManager.PathTEMP_DIR
);
}
@ -548,7 +490,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
BackgroundImagePath = path;
Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius);
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath,
defaultPath: string.Empty
);
}
@ -577,39 +519,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
DownloadFinishedSoundPath = path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath,
defaultPath: string.Empty
);
}
#endregion
#region Download Finished Execute File
[RelayCommand]
public void ClearFinishedExectuePath(){
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty;
DownloadFinishedExecutePath = string.Empty;
}
[RelayCommand]
public async Task OpenFileDialogAsyncInternalFinishedExecute(){
await OpenFileDialogAsyncInternal(
title: "Select File",
fileTypes: new List<FilePickerFileType>{
new("All Files"){
Patterns = new[]{ "*.*" }
}
},
pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path;
DownloadFinishedExecutePath = path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
defaultPath: string.Empty
);
}
#endregion
private async Task OpenFileDialogAsyncInternal(
@ -645,10 +559,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
_faTheme.PreferSystemTheme = true;
} else if (value?.Content?.ToString() == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current?.RequestedThemeVariant = ThemeVariant.Dark;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current?.RequestedThemeVariant = ThemeVariant.Light;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
}
UpdateSettings();
@ -690,11 +604,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
_faTheme.CustomAccentColor = color;
UpdateSettings();
}
partial void OnTrayIconEnabledChanged(bool value){
((App)Application.Current!).SetTrayIconVisible(value);
UpdateSettings();
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e){
base.OnPropertyChanged(e);
@ -704,22 +613,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(ListBoxColor)
or nameof(CurrentAppTheme)
or nameof(UseCustomAccent)
or nameof(TrayIconEnabled)
or nameof(LogMode)){
return;
}
UpdateSettings();
HistoryAutoRefreshModeHint = HistoryAutoRefreshMode switch{
HistoryRefreshMode.DefaultAll =>
"Refreshes the full history using the default method and includes all entries",
HistoryRefreshMode.DefaultActive =>
"Refreshes the history using the default method and includes only active entries",
HistoryRefreshMode.FastNewReleases =>
"Uses the faster refresh method, similar to the custom calendar, focusing on newly released items",
_ => ""
};
if (e.PropertyName is nameof(History)){
if (CrunchyrollManager.Instance.CrunOptions.History){
@ -760,7 +658,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
[RelayCommand]
public async Task CheckIp(){
public async void CheckIp(){
var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false));
Console.Error.WriteLine("Your IP: " + result.ResponseContent);
if (result.IsOk){

View file

@ -3,7 +3,6 @@
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"
x:DataType="vm:AccountPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.AccountPageView">
@ -14,33 +13,13 @@
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
<Grid Width="170" Height="170" Margin="20"
HorizontalAlignment="Center"
VerticalAlignment="Top">
<!-- Profile Image -->
<Image Source="{Binding ProfileImage}">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170" />
</Image.Clip>
</Image>
<!-- Switch Button (Overlay Bottom Right) -->
<Button Width="42"
Height="42"
BorderThickness="0"
CornerRadius="21"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,4,4"
Command="{Binding OpenMultiProfileDialogCommand}"
IsVisible="{Binding HasMultiProfile}"
ToolTip.Tip="Switch Profile">
<controls:SymbolIcon Symbol="Switch" FontSize="30"/>
</Button>
</Grid>
<!-- 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}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" />

View file

@ -99,10 +99,6 @@
<CheckBox IsChecked="{Binding ShowUpcomingEpisodes}"
Content="Show Upcoming episodes" Margin="5 5 0 0">
</CheckBox>
<CheckBox IsChecked="{Binding UpdateHistoryFromCalendar}"
Content="Update History from Calendar" Margin="5 5 0 0">
</CheckBox>
</StackPanel>
</controls:SettingsExpander.Footer>
@ -189,8 +185,7 @@
<Grid HorizontalAlignment="Center">
<Grid>
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="../Assets/coming_soon_ep.jpg" />
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" IsVisible="{Binding AnilistEpisode}" MaxHeight="150" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" MaxHeight="150" Source="{Binding ImageBitmap}" />
</Grid>

View file

@ -13,7 +13,7 @@
Unloaded="OnUnloaded"
Loaded="Control_OnLoaded">
<UserControl.Resources>
<UserControl.Resources>
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
<ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" />
<ui:UiListToStringConverter x:Key="UiListToStringConverter" />
@ -70,48 +70,6 @@
</StackPanel>
</ToggleButton>
<StackPanel>
<ToggleButton x:Name="DropdownButtonSearch" Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
VerticalAlignment="Center"
IsChecked="{Binding IsSearchOpen, Mode=TwoWay}"
IsEnabled="{Binding !ProgramManager.FetchingData}">
<Grid>
<StackPanel Orientation="Vertical">
<controls:SymbolIcon Symbol="Zoom" FontSize="32" />
<TextBlock Text="Search" HorizontalAlignment="Center" FontSize="12"></TextBlock>
</StackPanel>
<Ellipse Width="10" Height="10"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,0,0,0"
Fill="Orange"
IsHitTestVisible="False"
IsVisible="{Binding IsSearchActiveClosed}" />
</Grid>
</ToggleButton>
<Popup IsLightDismissEnabled="True"
IsOpen="{Binding IsSearchOpen, Mode=TwoWay}"
Placement="BottomEdgeAlignedRight"
PlacementTarget="{Binding ElementName=DropdownButtonSearch}">
<Border BorderThickness="1" Background="{DynamicResource ComboBoxDropDownBackground}">
<StackPanel Orientation="Horizontal" Margin="10">
<TextBox x:Name="SearchBar" Width="160"
Watermark="Search"
Text="{Binding SearchInput, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="✕" Margin="6,0,0,0"
Command="{Binding ClearSearchCommand}" />
</StackPanel>
</Border>
</Popup>
</StackPanel>
<Rectangle Width="1" Height="50" Fill="Gray" Margin="10,0" />
<StackPanel Margin="10,0">
@ -157,7 +115,6 @@
<!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> -->
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
<Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100"

View file

@ -5,7 +5,6 @@ using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
@ -47,8 +46,6 @@ public partial class MainWindow : AppWindow{
#endregion
private object selectedNavVieItem;
private ToastNotification? toast;
private const int TitleBarHeightAdjustment = 31;
@ -73,7 +70,6 @@ public partial class MainWindow : AppWindow{
PositionChanged += OnPositionChanged;
SizeChanged += OnSizeChanged;
toast = this.FindControl<ToastNotification>("Toast");
//select first element as default
var nv = this.FindControl<NavigationView>("NavView");
@ -154,48 +150,55 @@ public partial class MainWindow : AppWindow{
public void ShowToast(string message, ToastType type, int durationInSeconds = 5){
Dispatcher.UIThread.Post(() => toast?.Show(message, type, durationInSeconds));
var toastControl = this.FindControl<ToastNotification>("Toast");
toastControl?.Show(message, type, durationInSeconds);
}
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){
if (sender is NavigationView{ SelectedItem: NavigationViewItem selectedItem } navView){
switch (selectedItem.Tag){
case "DownloadQueue":
navView.Content = Activator.CreateInstance<DownloadsPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "AddDownload":
navView.Content = Activator.CreateInstance<AddDownloadPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "Calendar":
navView.Content = Activator.CreateInstance<CalendarPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "History":
navView.Content = Activator.CreateInstance<HistoryPageViewModel>();
navigationStack.Clear();
navigationStack.Push(navView.Content);
selectedNavVieItem = selectedItem;
break;
case "Seasons":
navView.Content = Activator.CreateInstance<UpcomingPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "Account":
navView.Content = Activator.CreateInstance<AccountPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "Settings":
var viewModel = Activator.CreateInstance<SettingsPageViewModel>();
navView.Content = viewModel;
selectedNavVieItem = selectedItem;
break;
case "Update":
navView.Content = Activator.CreateInstance<UpdateViewModel>();
selectedNavVieItem = selectedItem;
break;
if (sender is NavigationView navView){
var selectedItem = navView.SelectedItem as NavigationViewItem;
if (selectedItem != null){
switch (selectedItem.Tag){
case "DownloadQueue":
navView.Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "AddDownload":
navView.Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Calendar":
navView.Content = Activator.CreateInstance(typeof(CalendarPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "History":
navView.Content = Activator.CreateInstance(typeof(HistoryPageViewModel));
navigationStack.Clear();
navigationStack.Push(navView.Content);
selectedNavVieItem = selectedItem;
break;
case "Seasons":
navView.Content = Activator.CreateInstance(typeof(UpcomingPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Account":
navView.Content = Activator.CreateInstance(typeof(AccountPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Settings":
var viewModel = (SettingsPageViewModel)Activator.CreateInstance(typeof(SettingsPageViewModel));
navView.Content = viewModel;
selectedNavVieItem = selectedItem;
break;
case "Update":
navView.Content = Activator.CreateInstance(typeof(UpdateViewModel));
selectedNavVieItem = selectedItem;
break;
default:
// (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
break;
}
}
}
}
@ -206,7 +209,7 @@ public partial class MainWindow : AppWindow{
if (settings != null){
var screens = Screens.All;
if (settings.ScreenIndex >= 0 && settings.ScreenIndex < screens.Count){
// var screen = screens[settings.ScreenIndex];
var screen = screens[settings.ScreenIndex];
// Restore the position first
Position = new PixelPoint(settings.PosX, settings.PosY);

View file

@ -161,13 +161,6 @@
<Button Margin="0 0 5 10" FontStyle="Italic"
VerticalAlignment="Center"
Command="{Binding OpenFolderDialogAsync}">
<Button.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Clear Path"
Command="{Binding ClearFolderPathCommand}" />
</MenuFlyout>
</Button.ContextFlyout>
<ToolTip.Tip>
<TextBlock Text="{Binding SelectedSeries.SeriesDownloadPath,
Converter={StaticResource EmptyToDefault},
@ -653,13 +646,6 @@
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).OpenFolderDialogAsync}"
CommandParameter="{Binding .}">
<Button.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Clear Path"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).ClearFolderPathCommand}"
CommandParameter="{Binding .}"/>
</MenuFlyout>
</Button.ContextFlyout>
<ToolTip.Tip>
<TextBlock Text="{Binding SeasonDownloadPath,
Converter={StaticResource EmptyToDefault},

View file

@ -1,6 +1,5 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using CRD.Downloader;
using CRD.Utils.Sonarr;
using CRD.ViewModels;
@ -14,7 +13,6 @@ public partial class SettingsPageView : UserControl{
private void OnUnloaded(object? sender, RoutedEventArgs e){
if (DataContext is SettingsPageViewModel viewModel){
SonarrClient.Instance.RefreshSonarr();
ProgramManager.Instance.StartRunners();
}
}

View file

@ -1,78 +0,0 @@
<ui:CustomContentDialog xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:CRD.ViewModels.Utils"
xmlns:structs="clr-namespace:CRD.Utils.Structs"
xmlns:ui="clr-namespace:CRD.Utils.UI"
x:DataType="vm:ContentDialogMultiProfileSelectViewModel"
x:Class="CRD.Views.Utils.ContentDialogMultiProfileSelectView">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="3"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<ListBox ItemsSource="{Binding ProfileList}"
SelectedItem="{Binding SelectedItem}"
Background="Transparent"
BorderThickness="0">
<!-- Horizontal layout -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ListBoxItem" x:DataType="structs:AccountProfile">
<Setter Property="IsEnabled">
<Setter.Value>
<Binding Path="CanBeSelected"/>
</Setter.Value>
</Setter>
</Style>
<Style Selector="ListBoxItem:disabled">
<Setter Property="Opacity" Value="0.4"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate >
<DataTemplate>
<StackPanel Width="220"
Margin="0,0,12,0"
IsEnabled="{Binding CanBeSelected}">
<Grid Width="170"
Height="170"
Margin="20"
HorizontalAlignment="Center">
<Image Source="{Binding ProfileImage}">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170"/>
</Image.Clip>
</Image>
</Grid>
<TextBlock Text="{Binding ProfileName}"
HorizontalAlignment="Center"
FontSize="20"
Margin="10"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>
</ui:CustomContentDialog>

View file

@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace CRD.Views.Utils;
public partial class ContentDialogMultiProfileSelectView : UserControl{
public ContentDialogMultiProfileSelectView(){
InitializeComponent();
}
}

View file

@ -62,43 +62,6 @@
<CheckBox IsChecked="{Binding HistorySkipUnmonitored}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Auto History Refresh" Description="Automatically refresh your history at a set interval">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="8" Width="520">
<StackPanel Spacing="4">
<TextBlock Text="Refresh interval (minutes)" />
<DockPanel LastChildFill="True">
<controls:NumberBox Minimum="0"
Maximum="1000000000"
Value="{Binding HistoryAutoRefreshIntervalMinutes}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</DockPanel>
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
Text="Set to 0 to disable automatic refresh." />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Refresh mode" />
<ComboBox ItemsSource="{Binding HistoryAutoRefreshModes}"
DisplayMemberBinding="{Binding DisplayName}"
SelectedValueBinding="{Binding value}"
SelectedValue="{Binding HistoryAutoRefreshMode}"/>
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
Text="{Binding HistoryAutoRefreshModeHint}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
@ -278,50 +241,51 @@
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedExecute}"
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding ClearFinishedExectuePath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected file" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
</StackPanel>
<!-- <Grid HorizontalAlignment="Right" Margin="0 5 0 0"> -->
<!-- <Grid.ColumnDefinitions> -->
<!-- <ColumnDefinition Width="Auto" /> -->
<!-- <ColumnDefinition Width="150" /> -->
<!-- </Grid.ColumnDefinitions> -->
<!-- -->
<!-- <Grid.RowDefinitions> -->
<!-- <RowDefinition Height="Auto" /> -->
<!-- <RowDefinition Height="Auto" /> -->
<!-- </Grid.RowDefinitions> -->
<!-- -->
<!-- <TextBlock Text="Opacity" -->
<!-- FontSize="15" -->
<!-- Opacity="0.8" -->
<!-- VerticalAlignment="Center" -->
<!-- HorizontalAlignment="Right" -->
<!-- Margin="0 0 5 10" -->
<!-- Grid.Row="0" Grid.Column="0" /> -->
<!-- <controls:NumberBox Minimum="0" Maximum="1" -->
<!-- SmallChange="0.05" -->
<!-- LargeChange="0.1" -->
<!-- SimpleNumberFormat="F2" -->
<!-- Value="{Binding BackgroundImageOpacity}" -->
<!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" -->
<!-- Margin="0 0 0 10" -->
<!-- Grid.Row="0" Grid.Column="1" /> -->
<!-- -->
<!-- <TextBlock Text="Blur Radius" -->
<!-- FontSize="15" -->
<!-- Opacity="0.8" -->
<!-- VerticalAlignment="Center" -->
<!-- HorizontalAlignment="Right" -->
<!-- Margin="0 0 5 0" -->
<!-- Grid.Row="1" Grid.Column="0" /> -->
<!-- <controls:NumberBox Minimum="0" Maximum="40" -->
<!-- SmallChange="1" -->
<!-- LargeChange="5" -->
<!-- SimpleNumberFormat="F0" -->
<!-- Value="{Binding BackgroundImageBlurRadius}" -->
<!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" -->
<!-- Grid.Row="1" Grid.Column="1" /> -->
<!-- </Grid> -->
</StackPanel>
</controls:SettingsExpanderItem.Footer>
@ -459,38 +423,6 @@
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
<controls:SettingsExpander Header="General Settings"
IconSource="Bullets"
Description="Adjust General settings"
IsExpanded="False">
<controls:SettingsExpanderItem Content="Show tray icon">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding TrayIconEnabled}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Start minimized to tray" Description="Launches in the tray without opening a window">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding StartMinimizedToTray}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Minimize to tray" Description="Minimizing hides the window and removes it from the taskbar">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding MinimizeToTray}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Close to tray" Description="Clicking X hides the app in the tray instead of exiting">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding MinimizeToTrayOnClose}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
<controls:SettingsExpander Header="App Appearance"

BIN
images/Calendar_Custom_Settings.png (Stored with Git LFS)

Binary file not shown.

BIN
images/History_Overview.png (Stored with Git LFS)

Binary file not shown.

BIN
images/History_Overview_Table.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_CR_Download.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_CR_Dub.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_CR_Hardsub.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_CR_Muxing.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

BIN
images/Settings_CR_Softsubs.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_Download.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_Download_CR.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_G.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_G_Download.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_G_FlareSolverr.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_G_General.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_G_History.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_History.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_Muxing.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_Muxing_New_Encoding_Preset.png (Stored with Git LFS) Normal file

Binary file not shown.

BIN
images/Settings_Softsubs.png (Stored with Git LFS) Normal file

Binary file not shown.