Compare commits

...

3 commits

Author SHA1 Message Date
Elwador
199ff9f96c Chg - Updated images 2026-03-04 18:17:53 +01:00
Elwador
985fd9c00f Added **tray icon** [#393](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/393).
Added **ability to switch between account profiles** [#372](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/372).
Added option to **execute a file when the download queue finishes** [#392](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/392).
Added **auto history refresh / auto add to queue** [#394](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/394).
Changed **font loading** to also include fonts from the local fonts folder that are not available on Crunchyroll [#371](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/371).
Updated packages to latest versions
Fixed **history not being saved** after it was updated via the calendar
Fixed **Downloaded toggle in history** being slow for large seasons
2026-03-04 18:17:28 +01:00
Elwador
973c45ce5c - Added option to **update history from the calendar**
- Added **search for currently shown series** in the history view
- Changed **authentication tokens**
- Fixed **download info updates** where the resolution was shown incorrectly
2026-01-31 19:11:58 +01:00
68 changed files with 2424 additions and 808 deletions

View file

@ -4,12 +4,18 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using CRD.ViewModels; using CRD.ViewModels;
using MainWindow = CRD.Views.MainWindow; using MainWindow = CRD.Views.MainWindow;
using System.Linq; using Avalonia.Controls;
using Avalonia.Platform;
using CRD.Downloader; using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
namespace CRD; namespace CRD;
public partial class App : Application{ public class App : Application{
private TrayIcon? trayIcon;
private bool exitRequested;
public override void Initialize(){ public override void Initialize(){
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
@ -21,11 +27,21 @@ public partial class App : Application{
var manager = ProgramManager.Instance; var manager = ProgramManager.Instance;
if (!isHeadless){ if (!isHeadless){
desktop.MainWindow = new MainWindow{ desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var mainWindow = new MainWindow{
DataContext = new MainWindowViewModel(manager), DataContext = new MainWindowViewModel(manager),
}; };
desktop.MainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); }; mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
desktop.MainWindow = mainWindow;
}
SetupTrayIcon(desktop, mainWindow, manager);
SetupMinimizeToTray(desktop,mainWindow,manager);
} }
@ -35,5 +51,80 @@ public partial class App : Application{
base.OnFrameworkInitializationCompleted(); 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,6 +10,7 @@ using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils; using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Views; using CRD.Views;
@ -234,8 +235,17 @@ public class CalendarManager{
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true); var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){ 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 //EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){ foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false; bool filtered = false;

View file

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

View file

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

View file

@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Web; using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Structs; using CRD.Utils.Structs;
@ -17,32 +18,44 @@ namespace CRD.Downloader.Crunchyroll;
public class CrSeries{ public class CrSeries{
private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance; private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance;
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? but, bool? all, List<string>? e){ public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? all, List<string>? e){
var ret = new Dictionary<string, CrunchyEpMeta>(); var ret = new Dictionary<string, CrunchyEpMeta>();
var hasPremium = crunInstance.CrAuthEndpoint1.Profile.HasPremium;
foreach (var kvp in eps){ var hslang = crunInstance.CrunOptions.Hslang;
var key = kvp.Key;
var episode = kvp.Value;
for (int index = 0; index < episode.Items.Count; index++){ bool ShouldInclude(string epNum) =>
var item = episode.Items[index]; all is true || (e != null && e.Contains(epNum));
if (item.IsPremiumOnly && !crunInstance.CrAuthEndpoint1.Profile.HasPremium){ foreach (var (key, episode) in eps){
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)); 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));
continue; continue;
} }
// history override
var effectiveDubs = dubLang;
if (crunInstance.CrunOptions.History){ if (crunInstance.CrunOptions.History){
var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId); var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId);
if (dubLangList.Count > 0){ if (dubLangList.Count > 0)
dubLang = dubLangList; effectiveDubs = dubLangList;
}
} }
if (!dubLang.Contains(episode.Langs[index].CrLocale)) if (!effectiveDubs.Contains(lang.CrLocale))
continue; continue;
// season title fallbacks (same behavior)
item.HideSeasonTitle = true; item.HideSeasonTitle = true;
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){ if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
item.SeasonTitle = item.SeriesTitle; item.SeasonTitle = item.SeriesTitle;
@ -55,66 +68,65 @@ public class CrSeries{
item.SeriesTitle = "NO_TITLE"; item.SeriesTitle = "NO_TITLE";
} }
var epNum = key.StartsWith('E') ? key[1..] : key; // selection gate
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]); if (!ShouldInclude(epNum))
continue;
Regex dubPattern = new Regex(@"\(\w+ Dub\)"); // 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));
var epMeta = new CrunchyEpMeta(); var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } }; episode.Variants.Select(x => (string?)x.Item.SeasonTitle));
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(); var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
epMeta.EpisodeNumber = item.Episode;
epMeta.EpisodeTitle = item.Title; var selectedDubs = effectiveDubs
epMeta.SeasonId = item.SeasonId; .Where(d => episode.Variants.Any(x => x.Lang.CrLocale == d))
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(); .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 epMetaData = epMeta.Data[0]; var playback = item.Playback;
if (!string.IsNullOrEmpty(item.StreamsLink)){ if (!string.IsNullOrEmpty(item.StreamsLink)){
epMetaData.Playback = item.StreamsLink; playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){ if (string.IsNullOrEmpty(item.Playback))
item.Playback = item.StreamsLink; item.Playback = item.StreamsLink;
}
} }
if (all is true || e != null && e.Contains(epNum)){ // Add variant
if (ret.TryGetValue(key, out var epMe)){ ret[key].Data.Add(DownloadQueueItemFactory.CreateVariant(
epMetaData.Lang = episode.Langs[index]; mediaId: item.Id,
epMe.Data.Add(epMetaData); lang: lang,
} else{ playback: playback,
epMetaData.Lang = episode.Langs[index]; versions: item.Versions,
epMeta.Data[0] = epMetaData; isSubbed: item.IsSubbed,
ret.Add(key, epMeta); isDubbed: item.IsDubbed
} ));
}
// show ep
item.SeqId = epNum;
} }
} }
return ret; return ret;
} }
@ -124,64 +136,58 @@ public class CrSeries{
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale); CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
if (parsedSeries == null || parsedSeries.Data == null){ if (parsedSeries?.Data == null){
Console.Error.WriteLine("Parse Data Invalid"); Console.Error.WriteLine("Parse Data Invalid");
return null; return null;
} }
// var result = ParseSeriesResult(parsedSeries); var episodes = new Dictionary<string, EpisodeAndLanguage>();
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
if (crunInstance.CrunOptions.History){ if (crunInstance.CrunOptions.History)
_ = crunInstance.History.CrUpdateSeries(id, ""); _ = crunInstance.History.CrUpdateSeries(id, "");
}
var cachedSeasonId = ""; var cachedSeasonId = "";
var seasonData = new CrunchyEpisodeList(); var seasonData = new CrunchyEpisodeList();
foreach (var s in parsedSeries.Data){ 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; int fallbackIndex = 0;
if (cachedSeasonId != s.Id){ if (cachedSeasonId != s.Id){
seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : ""); seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : "");
cachedSeasonId = s.Id; cachedSeasonId = s.Id;
} }
if (seasonData.Data != null){ if (seasonData.Data == null)
foreach (var episode in seasonData.Data){ continue;
// 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;
string episodeNum = (episode.Episode != String.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}")) ?? string.Empty; var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier)
? s.Identifier.Split('|')[1]
: $"S{episode.SeasonNumber}";
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}"; var episodeKey = $"{seasonIdentifier}E{episodeNum}";
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
if (!episodes.ContainsKey(episodeKey)){ if (!episodes.TryGetValue(episodeKey, out var item)){
item = new EpisodeAndLanguage{ item = new EpisodeAndLanguage(); // must have Variants
Items = new List<CrunchyEpisode>(), episodes[episodeKey] = item;
Langs = new List<LanguageItem>() }
};
episodes[episodeKey] = item; if (episode.Versions != null){
} else{ foreach (var version in episode.Versions){
item = episodes[episodeKey]; var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem();
} item.AddUnique(episode, lang); // must enforce uniqueness by CrLocale
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);
} }
} }
} }
@ -198,22 +204,25 @@ public class CrSeries{
int specialIndex = 1; int specialIndex = 1;
int epIndex = 1; int epIndex = 1;
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating. var keys = new List<string>(episodes.Keys);
foreach (var key in keys){ foreach (var key in keys){
EpisodeAndLanguage item = episodes[key]; var item = episodes[key];
var episode = item.Items[0].Episode; if (item.Variants.Count == 0)
var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special). continue;
// var newKey = $"{(isSpecial ? 'S' : 'E')}{(isSpecial ? specialIndex : epIndex).ToString()}";
var baseEp = item.Variants[0].Item;
var epStr = baseEp.Episode;
var isSpecial = epStr != null && !Regex.IsMatch(epStr, @"^\d+(\.\d+)?$");
string newKey; string newKey;
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){ if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = $"SP{specialIndex}_" + item.Items[0].Episode;// ?? "SP" + (specialIndex + " " + item.Items[0].Id); newKey = $"SP{specialIndex}_" + baseEp.Episode;
} else{ } else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}"; newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
} }
episodes.Remove(key); episodes.Remove(key);
int counter = 1; int counter = 1;
@ -225,63 +234,95 @@ public class CrSeries{
episodes.Add(newKey, item); episodes.Add(newKey, item);
if (isSpecial){ if (isSpecial) specialIndex++;
specialIndex++; else epIndex++;
} else{
epIndex++;
}
} }
var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList(); var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList(); var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
// Combining and sorting episodes with normal first, then specials.
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials)); var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
foreach (var kvp in sortedEpisodes){ foreach (var kvp in sortedEpisodes){
var key = kvp.Key; var key = kvp.Key;
var item = kvp.Value; var item = kvp.Value;
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle if (item.Variants.Count == 0)
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); continue;
var title = item.Items[0].Title; var baseEp = item.Variants[0].Item;
var seasonNumber = Helpers.ExtractNumberAfterS(item.Items[0].Identifier) ?? item.Items[0].SeasonNumber.ToString();
var languages = item.Items.Select((a, index) => var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆ 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();
Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]"); 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."); Console.WriteLine("Couldn\'t find versions on some episodes, added languages with language array.");
}
CrunchySeriesList crunchySeriesList = new CrunchySeriesList(); var crunchySeriesList = new CrunchySeriesList{
crunchySeriesList.Data = sortedEpisodes; Data = sortedEpisodes
};
crunchySeriesList.List = sortedEpisodes.Select(kvp => { crunchySeriesList.List = sortedEpisodes.Select(kvp => {
var key = kvp.Key; var key = kvp.Key;
var value = kvp.Value; var value = kvp.Value;
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); if (value.Variants.Count == 0){
var langList = value.Langs.Select(a => a.CrLocale).ToList(); 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();
Languages.SortListByLangList(langList); Languages.SortListByLangList(langList);
return new Episode{ return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key, E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = langList, Lang = langList,
Name = value.Items.FirstOrDefault()?.Title ?? string.Empty, Name = baseEp.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(value.Items.FirstOrDefault()?.Identifier?? string.Empty) ?? value.Items.FirstOrDefault()?.SeasonNumber.ToString()) ?? string.Empty, Season = (Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString()) ?? string.Empty,
SeriesTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeriesTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(), SeriesTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeriesTitle),
SeasonTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeasonTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(), SeasonTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle),
EpisodeNum = key.StartsWith("SP") ? key : value.Items.FirstOrDefault()?.EpisodeNumber?.ToString() ?? value.Items.FirstOrDefault()?.Episode ?? "?", EpisodeNum = key.StartsWith("SP")
Id = value.Items.FirstOrDefault()?.SeasonId ?? string.Empty, ? key
Img = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty, : (baseEp.EpisodeNumber?.ToString() ?? baseEp.Episode ?? "?"),
Description = value.Items.FirstOrDefault()?.Description ?? string.Empty, Id = baseEp.SeasonId ?? string.Empty,
Img = img,
Description = baseEp.Description ?? string.Empty,
EpisodeType = EpisodeType.Episode, EpisodeType = EpisodeType.Episode,
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds. Time = $"{seconds / 60}:{seconds % 60:D2}"
}; };
}).ToList(); }).ToList();
@ -333,7 +374,7 @@ public class CrSeries{
Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}"); Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}");
} else{ } else{
episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? 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){ if (episodeList.Total < 1){
@ -377,8 +418,8 @@ public class CrSeries{
public async Task<CrSeriesBase?> SeriesById(string id, string? crLocale, bool forced = false){ public async Task<CrSeriesBase?> SeriesById(string id, string? crLocale, bool forced = false){
await crunInstance.CrAuthGuest.RefreshToken(true); await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
query["preferred_audio_language"] = "ja-JP"; query["preferred_audio_language"] = "ja-JP";
@ -411,7 +452,7 @@ public class CrSeries{
public async Task<CrSearchSeriesBase?> Search(string searchString, string? crLocale, bool forced = false){ public async Task<CrSearchSeriesBase?> Search(string searchString, string? crLocale, bool forced = false){
await crunInstance.CrAuthGuest.RefreshToken(true); await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
if (!string.IsNullOrEmpty(crLocale)){ if (!string.IsNullOrEmpty(crLocale)){
@ -456,7 +497,7 @@ public class CrSeries{
public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){ public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true); await crunInstance.CrAuthGuest.RefreshToken(true);
CrBrowseSeriesBase complete = new CrBrowseSeriesBase(); CrBrowseSeriesBase complete = new CrBrowseSeriesBase();
complete.Data =[]; complete.Data = [];
var i = 0; var i = 0;
@ -495,7 +536,7 @@ public class CrSeries{
return complete; return complete;
} }
public async Task<CrBrowseSeriesBase?> GetSeasonalSeries(string season, string year, string? crLocale){ public async Task<CrBrowseSeriesBase?> GetSeasonalSeries(string season, string year, string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true); await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
@ -503,7 +544,7 @@ public class CrSeries{
if (!string.IsNullOrEmpty(crLocale)){ if (!string.IsNullOrEmpty(crLocale)){
query["locale"] = crLocale; query["locale"] = crLocale;
} }
query["seasonal_tag"] = season.ToLower() + "-" + year; query["seasonal_tag"] = season.ToLower() + "-" + year;
query["n"] = "100"; query["n"] = "100";
@ -520,5 +561,4 @@ public class CrSeries{
return series; return series;
} }
} }

View file

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

View file

@ -0,0 +1,89 @@
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

@ -0,0 +1,122 @@
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,6 +4,7 @@ using System.Globalization;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
@ -38,7 +39,7 @@ public class History{
} }
} else{ } else{
var matchingSeason = historySeries.Seasons.FirstOrDefault(historySeason => historySeason.SeasonId == seasonId); var matchingSeason = historySeries.Seasons.FirstOrDefault(historySeason => historySeason.SeasonId == seasonId);
if (matchingSeason != null){ if (matchingSeason != null){
foreach (var historyEpisode in matchingSeason.EpisodesList){ foreach (var historyEpisode in matchingSeason.EpisodesList){
historyEpisode.IsEpisodeAvailableOnStreamingService = false; historyEpisode.IsEpisodeAvailableOnStreamingService = false;
@ -129,6 +130,129 @@ 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> /// <summary>
/// This method updates the History with a list of episodes. The episodes have to be from the same season. /// This method updates the History with a list of episodes. The episodes have to be from the same season.
/// </summary> /// </summary>
@ -206,7 +330,7 @@ public class History{
historySeries = new HistorySeries{ historySeries = new HistorySeries{
SeriesTitle = firstEpisode.GetSeriesTitle(), SeriesTitle = firstEpisode.GetSeriesTitle(),
SeriesId = firstEpisode.GetSeriesId(), SeriesId = firstEpisode.GetSeriesId(),
Seasons =[], Seasons = [],
HistorySeriesAddDate = DateTime.Now, HistorySeriesAddDate = DateTime.Now,
SeriesType = firstEpisode.GetSeriesType(), SeriesType = firstEpisode.GetSeriesType(),
SeriesStreamingService = StreamingService.Crunchyroll SeriesStreamingService = StreamingService.Crunchyroll
@ -302,8 +426,8 @@ public class History{
var downloadDirPath = ""; var downloadDirPath = "";
var videoQuality = ""; var videoQuality = "";
List<string> dublist =[]; List<string> dublist = [];
List<string> sublist =[]; List<string> sublist = [];
if (historySeries != null){ if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId); var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -353,7 +477,7 @@ public class History{
public List<string> GetDubList(string? seriesId, string? seasonId){ public List<string> GetDubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> dublist =[]; List<string> dublist = [];
if (historySeries != null){ if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId); var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -372,7 +496,7 @@ public class History{
public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){ public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> sublist =[]; List<string> sublist = [];
var videoQuality = ""; var videoQuality = "";
if (historySeries != null){ if (historySeries != null){
@ -430,8 +554,8 @@ public class History{
SeriesId = artisteData.Id, SeriesId = artisteData.Id,
SeriesTitle = artisteData.Name ?? "", SeriesTitle = artisteData.Name ?? "",
ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "", ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "",
HistorySeriesAvailableDubLang =[], HistorySeriesAvailableDubLang = [],
HistorySeriesAvailableSoftSubs =[] HistorySeriesAvailableSoftSubs = []
}; };
historySeries.SeriesDescription = cachedSeries.SeriesDescription; historySeries.SeriesDescription = cachedSeries.SeriesDescription;
@ -563,7 +687,7 @@ public class History{
SeasonTitle = firstEpisode.GetSeasonTitle(), SeasonTitle = firstEpisode.GetSeasonTitle(),
SeasonId = firstEpisode.GetSeasonId(), SeasonId = firstEpisode.GetSeasonId(),
SeasonNum = firstEpisode.GetSeasonNum(), SeasonNum = firstEpisode.GetSeasonNum(),
EpisodesList =[], EpisodesList = [],
SpecialSeason = firstEpisode.IsSpecialSeason() SpecialSeason = firstEpisode.IsSpecialSeason()
}; };
@ -631,7 +755,7 @@ public class History{
historySeries.SonarrNextAirDate = GetNextAirDate(episodes); historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
List<HistoryEpisode> allHistoryEpisodes =[]; List<HistoryEpisode> allHistoryEpisodes = [];
foreach (var historySeriesSeason in historySeries.Seasons){ foreach (var historySeriesSeason in historySeries.Seasons){
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList); allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
@ -659,7 +783,7 @@ public class History{
.ToList(); .ToList();
} }
List<HistoryEpisode> failedEpisodes =[]; List<HistoryEpisode> failedEpisodes = [];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => { Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){ if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia; using Avalonia;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
@ -12,10 +13,12 @@ using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Utils.Updater; using CRD.Utils.Updater;
using FluentAvalonia.Styling; using FluentAvalonia.Styling;
using ProtoBuf.Meta;
namespace CRD.Downloader; namespace CRD.Downloader;
@ -75,10 +78,12 @@ public partial class ProgramManager : ObservableObject{
#endregion #endregion
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
public IStorageProvider StorageProvider; public IStorageProvider StorageProvider;
public ProgramManager(){ public ProgramManager(){
checkForNewEpisodesRunner = new PeriodicWorkRunner(async ct => { await CheckForDownloadsAsync(ct); });
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme; _faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme;
foreach (var arg in Environment.GetCommandLineArgs()){ foreach (var arg in Environment.GetCommandLineArgs()){
@ -106,7 +111,7 @@ public partial class ProgramManager : ObservableObject{
} }
} }
Init(); _ = Init();
CleanUpOldUpdater(); CleanUpOldUpdater();
} }
@ -172,12 +177,53 @@ public partial class ProgramManager : ObservableObject{
await Task.WhenAll(tasks); await Task.WhenAll(tasks);
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress.Done != true)){ while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
Console.WriteLine("Waiting for downloads to complete..."); 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(){ public void SetBackgroundImage(){
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){ if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){
Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity, Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity,
@ -186,33 +232,41 @@ public partial class ProgramManager : ObservableObject{
} }
private async Task Init(){ private async Task Init(){
CrunchyrollManager.Instance.InitOptions(); try{
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 (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) _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;
} }
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;
} }
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
} }
@ -238,8 +292,7 @@ public partial class ProgramManager : ObservableObject{
} }
} }
} }
private void CleanUpOldUpdater(){ private void CleanUpOldUpdater(){
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
@ -256,4 +309,17 @@ public partial class ProgramManager : ObservableObject{
Console.WriteLine("No old updater file found to delete."); 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, [], [], "", ""); (HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){ if (CrunchyrollManager.Instance.CrunOptions.History){
var episode = sList.EpisodeAndLanguages.Items.First(); var variant = sList.EpisodeAndLanguages.Variants.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(episode.SeriesId, episode.SeasonId, episode.Id); historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
if (historyEpisode.dublist.Count > 0){ if (historyEpisode.dublist.Count > 0){
dubLang = historyEpisode.dublist; dubLang = historyEpisode.dublist;
} }
@ -238,8 +238,9 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs"); 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: "); Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) => var languages = sList.EpisodeAndLanguages.Variants
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray(); .Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.CrLocale ?? "Unknown"}")
.ToArray();
Console.Error.WriteLine( Console.Error.WriteLine(
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]"); $"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]");
@ -252,8 +253,9 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Episode couldn't be added to Queue"); Console.WriteLine("Episode couldn't be added to Queue");
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: "); Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) => var languages = sList.EpisodeAndLanguages.Variants
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray(); .Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.CrLocale ?? "Unknown"}")
.ToArray();
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]"); 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)); MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
@ -374,7 +376,7 @@ public partial class QueueManager : ObservableObject{
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){ public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.But, data.AllEpisodes, data.E); var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
var failed = false; var failed = false;
var partialAdd = false; var partialAdd = false;

View file

@ -6,29 +6,19 @@ using ReactiveUI.Avalonia;
namespace CRD; namespace CRD;
sealed class Program{ 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] [STAThread]
public static void Main(string[] args){ public static void Main(string[] args){
var isHeadless = args.Contains("--headless"); var isHeadless = args.Contains("--headless");
BuildAvaloniaApp(isHeadless).StartWithClassicDesktopLifetime(args); 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){ public static AppBuilder BuildAvaloniaApp(bool isHeadless){
var builder = AppBuilder.Configure<App>() var builder = AppBuilder.Configure<App>()
.UsePlatformDetect() .UsePlatformDetect()
.WithInterFont() .WithInterFont()
.LogToTrace() .LogToTrace()
.UseReactiveUI() ; .UseReactiveUI(_ => { });
if (isHeadless){ if (isHeadless){
Console.WriteLine("Running in headless mode..."); Console.WriteLine("Running in headless mode...");

View file

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

View file

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

View file

@ -2,37 +2,40 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Structs; using CRD.Utils.Structs;
using CRD.Views; using CRD.Views;
using SixLabors.Fonts;
namespace CRD.Utils.Muxing; namespace CRD.Utils.Muxing;
public class FontsManager{ public class FontsManager{
#region Singelton #region Singelton
private static FontsManager? instance; private static readonly Lock Padlock = new Lock();
private static readonly object padlock = new object();
public static FontsManager Instance{ public static FontsManager Instance{
get{ get{
if (instance == null){ if (field == null){
lock (padlock){ lock (Padlock){
if (instance == null){ if (field == null){
instance = new FontsManager(); field = new FontsManager();
} }
} }
} }
return instance; return field;
} }
} }
#endregion #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" }, { "Adobe Arabic", "AdobeArabic-Bold.otf" },
{ "Andale Mono", "andalemo.ttf" }, { "Andale Mono", "andalemo.ttf" },
{ "Arial", "arial.ttf" }, { "Arial", "arial.ttf" },
@ -103,9 +106,14 @@ public class FontsManager{
{ "Webdings", "webdings.ttf" } { "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(){ public async Task GetFontsAsync(){
Console.WriteLine("Downloading fonts..."); Console.WriteLine("Downloading fonts...");
@ -115,134 +123,334 @@ public class FontsManager{
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font); var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){ if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
// Console.WriteLine($"{font} already downloaded!"); continue;
} else{ }
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){
File.Delete(fontLoc);
}
try{ var fontFolder = Path.GetDirectoryName(fontLoc);
if (!Directory.Exists(fontFolder)){ if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0)
Directory.CreateDirectory(fontFolder); File.Delete(fontLoc);
}
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontUrl = root + font; try{
if (!Directory.Exists(fontFolder))
Directory.CreateDirectory(fontFolder!);
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var httpClient = HttpClientReq.Instance.GetHttpClient(); var fontUrl = root + font;
try{
var response = await httpClient.GetAsync(fontUrl); var httpClient = HttpClientReq.Instance.GetHttpClient();
if (response.IsSuccessStatusCode){ try{
var fontData = await response.Content.ReadAsByteArrayAsync(); var response = await httpClient.GetAsync(fontUrl);
await File.WriteAllBytesAsync(fontLoc, fontData); if (response.IsSuccessStatusCode){
Console.WriteLine($"Downloaded: {font}"); var fontData = await response.Content.ReadAsByteArrayAsync();
} else{ await File.WriteAllBytesAsync(fontLoc, fontData);
Console.Error.WriteLine($"Failed to download: {font}"); Console.WriteLine($"Downloaded: {font}");
} } else{
} catch (Exception e){ Console.Error.WriteLine($"Failed to download: {font}");
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!"); Console.WriteLine("All required fonts downloaded!");
} }
public static List<string> ExtractFontsFromAss(string ass){ public static List<string> ExtractFontsFromAss(string ass){
var lines = ass.Replace("\r", "").Split('\n'); if (string.IsNullOrWhiteSpace(ass))
var styles = new List<string>(); return new List<string>();
ass = ass.Replace("\r", "");
var lines = ass.Split('\n');
var fonts = new List<string>();
foreach (var line in lines){ foreach (var line in lines){
if (line.StartsWith("Style: ")){ if (line.StartsWith("Style: ", StringComparison.OrdinalIgnoreCase)){
var parts = line.Split(','); var parts = line.Substring(7).Split(',');
if (parts.Length > 1) if (parts.Length > 1){
styles.Add(parts[1].Trim()); var fontName = parts[1].Trim();
fonts.Add(NormalizeFontKey(fontName));
}
} }
} }
var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)"); var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)");
foreach (Match match in fontMatches){ foreach (Match match in fontMatches){
if (match.Groups.Count > 1) if (match.Groups.Count > 1){
styles.Add(match.Groups[1].Value); var fontName = match.Groups[1].Value.Trim();
fonts.Add(NormalizeFontKey(fontName));
}
} }
return styles.Distinct().ToList(); // Using Linq to remove duplicates return fonts
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
} }
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList){ public Dictionary<string, string> GetDictFromKeyList(List<string> keysList, bool keepUnknown = true){
Dictionary<string, string> filteredDictionary = new Dictionary<string, string>(); Dictionary<string, string> filteredDictionary = new(StringComparer.OrdinalIgnoreCase);
foreach (string key in keysList){ foreach (string key in keysList){
if (Fonts.TryGetValue(key, out var font)){ var k = NormalizeFontKey(key);
filteredDictionary.Add(key, font);
if (Fonts.TryGetValue(k, out var fontFile)){
filteredDictionary[k] = fontFile;
} else if (keepUnknown){
filteredDictionary[k] = k;
} }
} }
return filteredDictionary; return filteredDictionary;
} }
public static string GetFontMimeType(string fontFileOrPath){
public static string GetFontMimeType(string fontFile){ var ext = Path.GetExtension(fontFileOrPath);
if (Regex.IsMatch(fontFile, @"\.otf$")) if (ext.Equals(".otf", StringComparison.OrdinalIgnoreCase))
return "application/vnd.ms-opentype"; return "application/vnd.ms-opentype";
else if (Regex.IsMatch(fontFile, @"\.ttf$")) if (ext.Equals(".ttf", StringComparison.OrdinalIgnoreCase))
return "application/x-truetype-font"; return "application/x-truetype-font";
else if (ext.Equals(".ttc", StringComparison.OrdinalIgnoreCase) || ext.Equals(".otc", StringComparison.OrdinalIgnoreCase))
return "application/octet-stream"; 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";
} }
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){ public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
Dictionary<string, string> fontsNameList = new Dictionary<string, string>(); EnsureIndex(fontsDir);
List<string> subsList = new List<string>();
List<ParsedFont> fontsList = new List<ParsedFont>(); var required = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
bool isNstr = true; var subsLocales = new List<string>();
var fontsList = new List<ParsedFont>();
var missing = new List<string>();
foreach (var s in subs){ foreach (var s in subs){
foreach (var keyValuePair in s.Fonts){ subsLocales.Add(s.Language.Locale);
if (!fontsNameList.ContainsKey(keyValuePair.Key)){
fontsNameList.Add(keyValuePair.Key, keyValuePair.Value); foreach (var kv in s.Fonts)
required.Add(NormalizeFontKey(kv.Key));
}
if (subsLocales.Count > 0)
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsLocales), subsLocales.Count);
if (required.Count > 0)
Console.WriteLine("Required fonts: {0} (Total: {1})", string.Join(", ", required), required.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;
} }
}
subsList.Add(s.Language.Locale); var attachName = MakeUniqueAttachmentName(resolvedPath, fontsList);
}
if (subsList.Count > 0){ fontsList.Add(new ParsedFont{
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count); Name = attachName,
isNstr = false; Path = resolvedPath,
} Mime = GetFontMimeType(resolvedPath)
});
if (fontsNameList.Count > 0){ if (!exact) Console.WriteLine($"Soft-resolved '{requested}' -> '{Path.GetFileName(resolvedPath)}'");
Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count);
}
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 });
}
} else{ } else{
missingFonts.Add(f.Key); missing.Add(requested);
} }
} }
if (missingFonts.Count > 0){ if (missing.Count > 0)
MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}"); MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}");
}
return fontsList; 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 class SubtitleFonts{
public LanguageItem Language{ get; set; } public LanguageItem Language{ get; set; } = new();
public Dictionary<string, string> Fonts{ get; set; } public Dictionary<string, string> Fonts{ get; set; } = new();
} }

View file

@ -0,0 +1,83 @@
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,6 +56,11 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_sound_path")] [JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; } 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")] [JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; } public double BackgroundImageOpacity{ get; set; }
@ -92,6 +97,13 @@ public class CrDownloadOptions{
[JsonProperty("history_count_sonarr")] [JsonProperty("history_count_sonarr")]
public bool HistoryCountSonarr{ get; set; } 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")] [JsonProperty("sonarr_properties")]
public SonarrProperties? SonarrProperties{ get; set; } public SonarrProperties? SonarrProperties{ get; set; }
@ -141,6 +153,17 @@ public class CrDownloadOptions{
[JsonProperty("flare_solverr_properties")] [JsonProperty("flare_solverr_properties")]
public FlareSolverrProperties? FlareSolverrProperties{ get; set; } 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 #endregion
@ -310,6 +333,9 @@ public class CrDownloadOptions{
[JsonProperty("calendar_show_upcoming_episodes")] [JsonProperty("calendar_show_upcoming_episodes")]
public bool CalendarShowUpcomingEpisodes{ get; set; } public bool CalendarShowUpcomingEpisodes{ get; set; }
[JsonProperty("calendar_update_history")]
public bool UpdateHistoryFromCalendar{ get; set; }
[JsonProperty("stream_endpoint_settings")] [JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; } public CrAuthSettings? StreamEndpoint{ get; set; }

View file

@ -4,7 +4,20 @@ using Newtonsoft.Json;
namespace CRD.Utils.Structs.Crunchyroll; 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 class CrProfile{
public string? Avatar{ get; set; } public string? Avatar{ get; set; }
public string? Email{ get; set; } public string? Email{ get; set; }
public string? Username{ get; set; } public string? Username{ get; set; }
@ -20,8 +33,14 @@ public class CrProfile{
[JsonProperty("preferred_content_subtitle_language")] [JsonProperty("preferred_content_subtitle_language")]
public string? PreferredContentSubtitleLanguage{ get; set; } public string? PreferredContentSubtitleLanguage{ get; set; }
[JsonIgnore] [JsonProperty("can_switch")]
public Subscription? Subscription{ get; set; } public bool CanSwitch{ get; set; }
[JsonProperty("is_selected")]
public bool IsSelected{ get; set; }
[JsonProperty("is_pin_protected")]
public bool IsPinProtected{ get; set; }
[JsonIgnore] [JsonIgnore]
public bool HasPremium{ get; set; } public bool HasPremium{ get; set; }

View file

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

View file

@ -1,5 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using CRD.Views; using CRD.Views;
@ -12,6 +14,23 @@ public class AuthData{
public string Password{ get; set; } 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 class CrAuthSettings{
public string Endpoint{ get; set; } public string Endpoint{ get; set; }
public string Authorization{ get; set; } public string Authorization{ get; set; }
@ -51,9 +70,18 @@ public class LanguageItem{
public string Language{ get; set; } public string Language{ get; set; }
} }
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
public class EpisodeAndLanguage{ public class EpisodeAndLanguage{
public List<CrunchyEpisode> Items{ get; set; } public List<EpisodeVariant> Variants{ get; set; } = new();
public List<LanguageItem> Langs{ get; set; }
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 class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){ public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
@ -131,6 +159,11 @@ public class StringItemWithDisplayName{
public string value{ get; set; } public string value{ get; set; }
} }
public class RefreshModeOption{
public string DisplayName{ get; set; }
public HistoryRefreshMode value{ get; set; }
}
public class WindowSettings{ public class WindowSettings{
public double Width{ get; set; } public double Width{ get; set; }
public double Height{ get; set; } public double Height{ get; set; }

View file

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using Avalonia.Media.Imaging; using Avalonia.Media.Imaging;
using CRD.Downloader; using CRD.Downloader;
@ -140,10 +141,16 @@ public class HistoryEpisode : INotifyPropertyChanged{
} }
public async Task DownloadEpisodeDefault(){ public async Task DownloadEpisodeDefault(){
await DownloadEpisode(); await DownloadEpisode(EpisodeDownloadMode.Default,"",false);
} }
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){ 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;
}
switch (EpisodeType){ switch (EpisodeType){
case EpisodeType.MusicVideo: case EpisodeType.MusicVideo:
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath); await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);

View file

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

View file

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

View file

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

View file

@ -7,6 +7,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input; using CommunityToolkit.Mvvm.Input;
using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using CRD.ViewModels.Utils;
using CRD.Views.Utils; using CRD.Views.Utils;
using FluentAvalonia.UI.Controls; using FluentAvalonia.UI.Controls;
using Newtonsoft.Json; using Newtonsoft.Json;
@ -22,6 +25,9 @@ public partial class AccountPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private string _loginLogoutText = ""; private string _loginLogoutText = "";
[ObservableProperty]
private bool _hasMultiProfile;
[ObservableProperty] [ObservableProperty]
private string _remainingTime = ""; private string _remainingTime = "";
@ -50,8 +56,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RemainingTime = "Subscription maybe ended"; RemainingTime = "Subscription maybe ended";
} }
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){ if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented)); Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
} }
} else{ } else{
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}"; RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
@ -59,13 +65,18 @@ public partial class AccountPageViewModel : ViewModelBase{
} }
public void UpdatetProfile(){ public void UpdatetProfile(){
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 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
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" + LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
(string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar)); (string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription; var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
if (subscriptions != null){ if (subscriptions != null){
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){ if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
@ -84,8 +95,8 @@ public partial class AccountPageViewModel : ViewModelBase{
UnknownEndDate = true; UnknownEndDate = true;
} }
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){ if (!UnknownEndDate){
_targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate; _targetTime = subscriptions.NextRenewalDate;
_timer = new DispatcherTimer{ _timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1) Interval = TimeSpan.FromSeconds(1)
}; };
@ -101,8 +112,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RaisePropertyChanged(nameof(RemainingTime)); RaisePropertyChanged(nameof(RemainingTime));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){ if (subscriptions != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented)); Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
} }
} }
@ -137,6 +148,41 @@ 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){ public async void LoadProfileImage(string imageUrl){
try{ try{
ProfileImage = await Helpers.LoadImage(imageUrl); ProfileImage = await Helpers.LoadImage(imageUrl);

View file

@ -31,6 +31,9 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _showUpcomingEpisodes; private bool _showUpcomingEpisodes;
[ObservableProperty]
private bool _updateHistoryFromCalendar;
[ObservableProperty] [ObservableProperty]
private bool _hideDubs; private bool _hideDubs;
@ -74,6 +77,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar; CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar;
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs; HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes; 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; ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0]; CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0];
@ -289,4 +293,14 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings(); 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] [ObservableProperty]
private ComboBoxItem? _selectedView; private ComboBoxItem? _selectedView;
public ObservableCollection<ComboBoxItem> ViewsList{ get; } =[]; public ObservableCollection<ComboBoxItem> ViewsList{ get; } = [];
[ObservableProperty] [ObservableProperty]
private SortingListElement? _selectedSorting; private SortingListElement? _selectedSorting;
public ObservableCollection<SortingListElement> SortingList{ get; } =[]; public ObservableCollection<SortingListElement> SortingList{ get; } = [];
[ObservableProperty] [ObservableProperty]
private FilterListElement? _selectedFilter; private FilterListElement? _selectedFilter;
public ObservableCollection<FilterListElement> FilterList{ get; } =[]; public ObservableCollection<FilterListElement> FilterList{ get; } = [];
[ObservableProperty] [ObservableProperty]
private double _posterWidth; private double _posterWidth;
@ -115,7 +115,16 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private static string _progressText; private static string _progressText;
[ObservableProperty]
private string _searchInput;
[ObservableProperty]
private bool _isSearchOpen;
[ObservableProperty]
public bool _isSearchActiveClosed;
#region Table Mode #region Table Mode
[ObservableProperty] [ObservableProperty]
@ -125,8 +134,8 @@ public partial class HistoryPageViewModel : ViewModelBase{
public Symbol _selectedDownloadIcon = Symbol.ClosedCaption; public Symbol _selectedDownloadIcon = Symbol.ClosedCaption;
#endregion #endregion
public Vector LastScrollOffset { get; set; } = Vector.Zero; public Vector LastScrollOffset{ get; set; } = Vector.Zero;
public HistoryPageViewModel(){ public HistoryPageViewModel(){
ProgramManager = ProgramManager.Instance; ProgramManager = ProgramManager.Instance;
@ -324,11 +333,32 @@ public partial class HistoryPageViewModel : ViewModelBase{
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series); 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.Clear();
FilteredItems.AddRange(filteredItems); FilteredItems.AddRange(filteredItems);
} }
partial void OnSearchInputChanged(string value){
ApplyFilter();
}
partial void OnIsSearchOpenChanged(bool value){
IsSearchActiveClosed = !string.IsNullOrEmpty(SearchInput) && !IsSearchOpen;
}
partial void OnScaleValueChanged(double value){ partial void OnScaleValueChanged(double value){
double t = (ScaleValue - 0.5) / (1 - 0.5); double t = (ScaleValue - 0.5) / (1 - 0.5);
@ -374,6 +404,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
} }
} }
[RelayCommand]
public void ClearSearchCommand(){
SearchInput = "";
}
[RelayCommand] [RelayCommand]
public void NavToSeries(){ public void NavToSeries(){
@ -419,7 +453,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
.SelectMany(item => item.Seasons) .SelectMany(item => item.Seasons)
.SelectMany(season => season.EpisodesList) .SelectMany(season => season.EpisodesList)
.Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile) .Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile)
.Select(historyEpisode => historyEpisode.DownloadEpisode()) .Select(historyEpisode => historyEpisode.DownloadEpisodeDefault())
); );
} }
@ -515,7 +549,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){ public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){ foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
@ -528,7 +562,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3)); MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{ } else{
foreach (var episode in missingEpisodes){ foreach (var episode in missingEpisodes){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
} }
@ -536,16 +570,16 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand] [RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){ public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){ foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisode(); await episode.DownloadEpisodeDefault();
} }
} }
[RelayCommand] [RelayCommand]
public async Task DownloadEpisodeOnlyOptions(HistoryEpisode episode){ public async Task DownloadEpisodeOnlyOptions(HistoryEpisode episode){
var downloadMode = SelectedDownloadMode; var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
@ -555,7 +589,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){ foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
} }
@ -567,9 +601,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){ foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){ if (historyEpisode.WasDownloaded == allDownloaded){
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId); historyEpisode.ToggleWasDownloaded();
} }
} }
seriesArgs.Season.UpdateDownloaded();
} }
seriesArgs.Series?.UpdateNewEpisodes(); seriesArgs.Series?.UpdateNewEpisodes();
@ -606,7 +641,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
public void ToggleInactive(){ public void ToggleInactive(){
CfgManager.UpdateHistoryFile(); CfgManager.UpdateHistoryFile();
} }
partial void OnSelectedDownloadModeChanged(EpisodeDownloadMode value){ partial void OnSelectedDownloadModeChanged(EpisodeDownloadMode value){
SelectedDownloadIcon = SelectedDownloadMode switch{ SelectedDownloadIcon = SelectedDownloadMode switch{
EpisodeDownloadMode.OnlyVideo => Symbol.Video, EpisodeDownloadMode.OnlyVideo => Symbol.Video,

View file

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

View file

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

View file

@ -0,0 +1,55 @@
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,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel; using System.Collections.ObjectModel;
using System.ComponentModel; using System.ComponentModel;
using System.Globalization;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Net.Http; using System.Net.Http;
@ -19,6 +20,7 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils; using CRD.Utils;
using CRD.Utils.Files; using CRD.Utils.Files;
using CRD.Utils.Sonarr; using CRD.Utils.Sonarr;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History; using CRD.Utils.Structs.History;
using FluentAvalonia.Styling; using FluentAvalonia.Styling;
@ -50,6 +52,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private bool _historyCountSonarr; 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] [ObservableProperty]
private double? _simultaneousDownloads; private double? _simultaneousDownloads;
@ -74,6 +94,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private double? _retryDelay; private double? _retryDelay;
[ObservableProperty]
private bool _trayIconEnabled;
[ObservableProperty]
private bool _startMinimizedToTray;
[ObservableProperty]
private bool _minimizeToTray;
[ObservableProperty]
private bool _minimizeToTrayOnClose;
[ObservableProperty] [ObservableProperty]
private ComboBoxItem _selectedHistoryLang; private ComboBoxItem _selectedHistoryLang;
@ -231,6 +263,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty] [ObservableProperty]
private string _downloadFinishedSoundPath; private string _downloadFinishedSoundPath;
[ObservableProperty]
private bool _downloadFinishedExecute;
[ObservableProperty]
private string _downloadFinishedExecutePath;
[ObservableProperty] [ObservableProperty]
private string _currentIp = ""; private string _currentIp = "";
@ -263,6 +301,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty; DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound; DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
DownloadFinishedExecute = options.DownloadFinishedExecute;
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath; DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath; TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -300,6 +341,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
HistoryAddSpecials = options.HistoryAddSpecials; HistoryAddSpecials = options.HistoryAddSpecials;
HistorySkipUnmonitored = options.HistorySkipUnmonitored; HistorySkipUnmonitored = options.HistorySkipUnmonitored;
HistoryCountSonarr = options.HistoryCountSonarr; 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; DownloadSpeed = options.DownloadSpeedLimit;
DownloadSpeedInBits = options.DownloadSpeedInBits; DownloadSpeedInBits = options.DownloadSpeedInBits;
DownloadMethodeNew = options.DownloadMethodeNew; DownloadMethodeNew = options.DownloadMethodeNew;
@ -310,6 +354,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
SimultaneousDownloads = options.SimultaneousDownloads; SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs; SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
LogMode = options.LogMode; 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; ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
CurrentAppTheme = theme ?? AppThemes[0]; CurrentAppTheme = theme ?? AppThemes[0];
@ -331,6 +380,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
var settings = CrunchyrollManager.Instance.CrunOptions; var settings = CrunchyrollManager.Instance.CrunOptions;
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound; settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
settings.DownloadFinishedExecute = DownloadFinishedExecute;
settings.DownloadMethodeNew = DownloadMethodeNew; settings.DownloadMethodeNew = DownloadMethodeNew;
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart; settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
@ -347,6 +398,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists; settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistorySkipUnmonitored = HistorySkipUnmonitored; settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
settings.HistoryCountSonarr = HistoryCountSonarr; 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.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.DownloadSpeedInBits = DownloadSpeedInBits; settings.DownloadSpeedInBits = DownloadSpeedInBits;
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10); settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
@ -404,6 +457,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
settings.FlareSolverrProperties = propsFlareSolverr; settings.FlareSolverrProperties = propsFlareSolverr;
settings.TrayIconEnabled = TrayIconEnabled;
settings.StartMinimizedToTray = StartMinimizedToTray;
settings.MinimizeToTray = MinimizeToTray;
settings.MinimizeToTrayOnClose = MinimizeToTrayOnClose;
settings.LogMode = LogMode; settings.LogMode = LogMode;
@ -429,7 +487,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path; CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path;
DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path; DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath ?? string.Empty,
defaultPath: CfgManager.PathVIDEOS_DIR defaultPath: CfgManager.PathVIDEOS_DIR
); );
} }
@ -441,7 +499,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path; CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path;
TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path; TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath ?? string.Empty,
defaultPath: CfgManager.PathTEMP_DIR defaultPath: CfgManager.PathTEMP_DIR
); );
} }
@ -490,7 +548,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
BackgroundImagePath = path; BackgroundImagePath = path;
Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius); Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius);
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty,
defaultPath: string.Empty defaultPath: string.Empty
); );
} }
@ -519,11 +577,39 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path; CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
DownloadFinishedSoundPath = path; DownloadFinishedSoundPath = path;
}, },
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath, pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
defaultPath: string.Empty 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 #endregion
private async Task OpenFileDialogAsyncInternal( private async Task OpenFileDialogAsyncInternal(
@ -559,10 +645,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
_faTheme.PreferSystemTheme = true; _faTheme.PreferSystemTheme = true;
} else if (value?.Content?.ToString() == "Dark"){ } else if (value?.Content?.ToString() == "Dark"){
_faTheme.PreferSystemTheme = false; _faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark; Application.Current?.RequestedThemeVariant = ThemeVariant.Dark;
} else{ } else{
_faTheme.PreferSystemTheme = false; _faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light; Application.Current?.RequestedThemeVariant = ThemeVariant.Light;
} }
UpdateSettings(); UpdateSettings();
@ -604,6 +690,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
_faTheme.CustomAccentColor = color; _faTheme.CustomAccentColor = color;
UpdateSettings(); UpdateSettings();
} }
partial void OnTrayIconEnabledChanged(bool value){
((App)Application.Current!).SetTrayIconVisible(value);
UpdateSettings();
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e){ protected override void OnPropertyChanged(PropertyChangedEventArgs e){
base.OnPropertyChanged(e); base.OnPropertyChanged(e);
@ -613,11 +704,22 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(ListBoxColor) or nameof(ListBoxColor)
or nameof(CurrentAppTheme) or nameof(CurrentAppTheme)
or nameof(UseCustomAccent) or nameof(UseCustomAccent)
or nameof(TrayIconEnabled)
or nameof(LogMode)){ or nameof(LogMode)){
return; return;
} }
UpdateSettings(); 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 (e.PropertyName is nameof(History)){
if (CrunchyrollManager.Instance.CrunOptions.History){ if (CrunchyrollManager.Instance.CrunOptions.History){
@ -658,7 +760,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
} }
[RelayCommand] [RelayCommand]
public async void CheckIp(){ public async Task CheckIp(){
var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false)); var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false));
Console.Error.WriteLine("Your IP: " + result.ResponseContent); Console.Error.WriteLine("Your IP: " + result.ResponseContent);
if (result.IsOk){ if (result.IsOk){

View file

@ -3,6 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:CRD.ViewModels" xmlns:vm="clr-namespace:CRD.ViewModels"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
x:DataType="vm:AccountPageViewModel" x:DataType="vm:AccountPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.AccountPageView"> x:Class="CRD.Views.AccountPageView">
@ -13,13 +14,33 @@
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center"> <StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
<!-- Profile Image --> <Grid Width="170" Height="170" Margin="20"
<Image Width="170" Height="170" Margin="20" HorizontalAlignment="Center"
Source="{Binding ProfileImage}"> VerticalAlignment="Top">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170" /> <!-- Profile Image -->
</Image.Clip> <Image Source="{Binding ProfileImage}">
</Image> <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 Name --> <!-- Profile Name -->
<TextBlock Text="{Binding ProfileName}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" /> <TextBlock Text="{Binding ProfileName}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" />

View file

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

View file

@ -13,7 +13,7 @@
Unloaded="OnUnloaded" Unloaded="OnUnloaded"
Loaded="Control_OnLoaded"> Loaded="Control_OnLoaded">
<UserControl.Resources> <UserControl.Resources>
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" /> <ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
<ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" /> <ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" />
<ui:UiListToStringConverter x:Key="UiListToStringConverter" /> <ui:UiListToStringConverter x:Key="UiListToStringConverter" />
@ -70,6 +70,48 @@
</StackPanel> </StackPanel>
</ToggleButton> </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" /> <Rectangle Width="1" Height="50" Fill="Gray" Margin="10,0" />
<StackPanel Margin="10,0"> <StackPanel Margin="10,0">
@ -115,6 +157,7 @@
<!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> --> <!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> -->
</StackPanel> </StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal"> <StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
<Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100" <Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100"

View file

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

View file

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

View file

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

View file

@ -0,0 +1,78 @@
<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

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

View file

@ -62,6 +62,43 @@
<CheckBox IsChecked="{Binding HistorySkipUnmonitored}"> </CheckBox> <CheckBox IsChecked="{Binding HistorySkipUnmonitored}"> </CheckBox>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem> </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> </controls:SettingsExpander>
@ -241,51 +278,50 @@
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox> <CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel> </StackPanel>
</StackPanel>
<!-- <Grid HorizontalAlignment="Right" Margin="0 5 0 0"> --> </controls:SettingsExpanderItem.Footer>
<!-- <Grid.ColumnDefinitions> --> </controls:SettingsExpanderItem>
<!-- <ColumnDefinition Width="Auto" /> -->
<!-- <ColumnDefinition Width="150" /> --> <controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
<!-- </Grid.ColumnDefinitions> --> <controls:SettingsExpanderItem.Footer>
<!-- --> <StackPanel Spacing="10">
<!-- <Grid.RowDefinitions> --> <StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<!-- <RowDefinition Height="Auto" /> --> <TextBlock IsVisible="{Binding DownloadFinishedExecute}"
<!-- <RowDefinition Height="Auto" /> --> Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
<!-- </Grid.RowDefinitions> --> FontSize="15"
<!-- --> Opacity="0.8"
<!-- <TextBlock Text="Opacity" --> TextWrapping="NoWrap"
<!-- FontSize="15" --> TextAlignment="Center"
<!-- Opacity="0.8" --> VerticalAlignment="Center" />
<!-- VerticalAlignment="Center" -->
<!-- HorizontalAlignment="Right" --> <Button IsVisible="{Binding DownloadFinishedExecute}"
<!-- Margin="0 0 5 10" --> Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
<!-- Grid.Row="0" Grid.Column="0" /> --> VerticalAlignment="Center"
<!-- <controls:NumberBox Minimum="0" Maximum="1" --> FontStyle="Italic">
<!-- SmallChange="0.05" --> <ToolTip.Tip>
<!-- LargeChange="0.1" --> <TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
<!-- SimpleNumberFormat="F2" --> </ToolTip.Tip>
<!-- Value="{Binding BackgroundImageOpacity}" --> <StackPanel Orientation="Horizontal" Spacing="5">
<!-- SpinButtonPlacementMode="Inline" --> <controls:SymbolIcon Symbol="Folder" FontSize="18" />
<!-- HorizontalAlignment="Stretch" --> </StackPanel>
<!-- Margin="0 0 0 10" --> </Button>
<!-- Grid.Row="0" Grid.Column="1" /> -->
<!-- --> <Button IsVisible="{Binding DownloadFinishedExecute}"
<!-- <TextBlock Text="Blur Radius" --> Command="{Binding ClearFinishedExectuePath}"
<!-- FontSize="15" --> VerticalAlignment="Center"
<!-- Opacity="0.8" --> FontStyle="Italic">
<!-- VerticalAlignment="Center" --> <ToolTip.Tip>
<!-- HorizontalAlignment="Right" --> <TextBlock Text="Clear selected file" FontSize="15" />
<!-- Margin="0 0 5 0" --> </ToolTip.Tip>
<!-- Grid.Row="1" Grid.Column="0" /> --> <StackPanel Orientation="Horizontal" Spacing="5">
<!-- <controls:NumberBox Minimum="0" Maximum="40" --> <controls:SymbolIcon Symbol="Clear" FontSize="18" />
<!-- SmallChange="1" --> </StackPanel>
<!-- LargeChange="5" --> </Button>
<!-- SimpleNumberFormat="F0" -->
<!-- Value="{Binding BackgroundImageBlurRadius}" --> <CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
<!-- SpinButtonPlacementMode="Inline" --> </StackPanel>
<!-- HorizontalAlignment="Stretch" -->
<!-- Grid.Row="1" Grid.Column="1" /> -->
<!-- </Grid> -->
</StackPanel> </StackPanel>
</controls:SettingsExpanderItem.Footer> </controls:SettingsExpanderItem.Footer>
@ -423,6 +459,38 @@
</controls:SettingsExpanderItem> </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>
<controls:SettingsExpander Header="App Appearance" <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)

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

Binary file not shown.

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

Binary file not shown.