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
This commit is contained in:
Elwador 2026-03-04 14:43:59 +01:00
parent 973c45ce5c
commit 985fd9c00f
31 changed files with 1609 additions and 499 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;
@ -239,6 +240,7 @@ public class CalendarManager{
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){ if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
try{ try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes); await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){ } catch (Exception e){
Console.Error.WriteLine("Failed to update History from calendar"); Console.Error.WriteLine("Failed to update History from calendar");
} }

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

@ -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;
} }

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

@ -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

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

@ -1,6 +1,7 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; 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;
@ -13,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; }
@ -141,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

@ -453,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())
); );
} }
@ -549,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();
} }
} }
@ -562,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();
} }
} }
} }
@ -570,7 +570,7 @@ 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();
} }
} }
@ -579,7 +579,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode; var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){ if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode); await episode.DownloadEpisode(downloadMode,"",false);
} }
} }
@ -589,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);
} }
} }
} }
@ -601,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();

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

@ -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"