diff --git a/CRD/App.axaml.cs b/CRD/App.axaml.cs index a58b713..bb3e404 100644 --- a/CRD/App.axaml.cs +++ b/CRD/App.axaml.cs @@ -4,12 +4,18 @@ using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Markup.Xaml; using CRD.ViewModels; using MainWindow = CRD.Views.MainWindow; -using System.Linq; +using Avalonia.Controls; +using Avalonia.Platform; using CRD.Downloader; +using CRD.Downloader.Crunchyroll; namespace CRD; -public partial class App : Application{ +public class App : Application{ + + private TrayIcon? trayIcon; + private bool exitRequested; + public override void Initialize(){ AvaloniaXamlLoader.Load(this); } @@ -21,11 +27,21 @@ public partial class App : Application{ var manager = ProgramManager.Instance; if (!isHeadless){ - desktop.MainWindow = new MainWindow{ + desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown; + + var mainWindow = new MainWindow{ 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(); } + 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); + } + } + } \ No newline at end of file diff --git a/CRD/Downloader/CalendarManager.cs b/CRD/Downloader/CalendarManager.cs index 88ed49e..19654bf 100644 --- a/CRD/Downloader/CalendarManager.cs +++ b/CRD/Downloader/CalendarManager.cs @@ -10,6 +10,7 @@ using System.Threading.Tasks; using CRD.Downloader.Crunchyroll; using CRD.Downloader.Crunchyroll.Utils; using CRD.Utils; +using CRD.Utils.Files; using CRD.Utils.Structs; using CRD.Utils.Structs.History; using CRD.Views; @@ -239,6 +240,7 @@ public class CalendarManager{ if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){ try{ await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes); + CfgManager.UpdateHistoryFile(); } catch (Exception e){ Console.Error.WriteLine("Failed to update History from calendar"); } diff --git a/CRD/Downloader/Crunchyroll/CRAuth.cs b/CRD/Downloader/Crunchyroll/CRAuth.cs index 288ce40..689ad66 100644 --- a/CRD/Downloader/Crunchyroll/CRAuth.cs +++ b/CRD/Downloader/Crunchyroll/CRAuth.cs @@ -1,9 +1,15 @@ using System; using System.Collections.Generic; +using System.Collections.Specialized; using System.Linq; using System.Net; using System.Net.Http; +using System.Net.Http.Headers; +using System.Security.Cryptography; +using System.Text; +using System.Text.RegularExpressions; using System.Threading.Tasks; +using System.Web; using CRD.Utils; using CRD.Utils.Files; using CRD.Utils.Structs; @@ -15,9 +21,12 @@ using ReactiveUI; namespace CRD.Downloader.Crunchyroll; public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){ - public CrToken? Token; public CrProfile Profile = new(); + public Subscription? Subscription{ get; set; } + public CrMultiProfile MultiProfile = new(); + + public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown; public CrAuthSettings AuthSettings = authSettings; @@ -32,7 +41,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings PreferredContentSubtitleLanguage = crunInstance.DefaultLocale, HasPremium = false, }; - } private string GetTokenFilePath(){ @@ -49,9 +57,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings case "console/ps5": case "console/xbox_one": return CfgManager.PathCrToken.Replace(".json", "_console.json"); + case "---": + return CfgManager.PathCrToken.Replace(".json", "_guest.json"); default: return CfgManager.PathCrToken; - } } @@ -65,12 +74,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings } public void SetETPCookie(string refreshToken){ - HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken),cookieStore); - HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"),cookieStore); + HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken), cookieStore); + HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"), cookieStore); } public async Task AuthAnonymous(){ - string uuid = Guid.NewGuid().ToString(); + string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id; + + Subscription = new Subscription(); var formData = new Dictionary{ { "grant_type", "client_id" }, @@ -163,10 +174,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings } else{ if (response.ResponseContent.Contains("invalid_credentials")){ MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", ToastType.Error, 5)); - } else if (response.ResponseContent.Contains("Just a moment...") || - response.ResponseContent.Contains("Access denied") || - response.ResponseContent.Contains("Attention Required! | Cloudflare") || - response.ResponseContent.Trim().Equals("error code: 1020") || + } else if (response.ResponseContent.Contains("Just a moment...") || + response.ResponseContent.Contains("Access denied") || + response.ResponseContent.Contains("Attention Required! | Cloudflare") || + response.ResponseContent.Trim().Equals("error code: 1020") || response.ResponseContent.IndexOf("DDOS-GUARD", StringComparison.OrdinalIgnoreCase) > -1){ MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5)); } else{ @@ -179,7 +190,64 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings if (Token?.refresh_token != null){ 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{ + { "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{ + { "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){ 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 subsc = Helpers.Deserialize(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings); - Profile.Subscription = subsc; - if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){ - var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First(); - var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate; - var remaining = expiration - DateTime.Now; - Profile.HasPremium = true; - if (Profile.Subscription != null){ - Profile.Subscription.IsActive = remaining > TimeSpan.Zero; - Profile.Subscription.NextRenewalDate = expiration; - } - } else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){ - var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First(); - var remaining = nonRecurringSub.EndDate - DateTime.Now; - Profile.HasPremium = true; - if (Profile.Subscription != null){ - Profile.Subscription.IsActive = remaining > TimeSpan.Zero; - Profile.Subscription.NextRenewalDate = nonRecurringSub.EndDate; - } - } else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){ - Profile.HasPremium = true; - } else if (subsc is{ SubscriptionProducts.Count: > 0 }){ - Profile.HasPremium = true; - } else{ - Profile.HasPremium = false; - Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} "); - } - } else{ - Profile.HasPremium = false; - Console.Error.WriteLine("Failed to check premium subscription status"); + var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs); + + if (responseSubs.IsOk){ + var subsc = Helpers.Deserialize(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + Subscription = subsc; + if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){ + var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First(); + var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate; + var remaining = expiration - DateTime.Now; + Profile.HasPremium = true; + if (Subscription != null){ + Subscription.IsActive = remaining > TimeSpan.Zero; + Subscription.NextRenewalDate = expiration; } + } 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(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); - if (response.ResponseContent.Contains("Just a moment...") || - response.ResponseContent.Contains("Access denied") || - response.ResponseContent.Contains("Attention Required! | Cloudflare") || - response.ResponseContent.Trim().Equals("error code: 1020") || + if (response.ResponseContent.Contains("Just a moment...") || + response.ResponseContent.Contains("Access denied") || + response.ResponseContent.Contains("Attention Required! | Cloudflare") || + response.ResponseContent.Trim().Equals("error code: 1020") || response.ResponseContent.IndexOf("DDOS-GUARD", StringComparison.OrdinalIgnoreCase) > -1){ MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 5)); Console.Error.WriteLine($"Failed to login - Cloudflare error try to change to BetaAPI in settings"); } - + if (response.IsOk){ JsonTokenToFileAndVariable(response.ResponseContent, uuid); if (Token?.refresh_token != null){ SetETPCookie(Token.refresh_token); - await GetProfile(); + await GetMultiProfile(); } } else{ Console.Error.WriteLine("Token Auth Failed"); await AuthAnonymous(); - + MainWindow.Instance.ShowError("Login failed. Please check the log for more details."); - } } public async Task RefreshToken(bool needsToken){ + if (EndpointEnum == CrunchyrollEndpoints.Guest){ + if (Token != null && !(DateTime.Now > Token.expires)){ + return; + } + + await AuthAnonymousFoxy(); + return; + } + if (Token?.access_token == null && Token?.refresh_token == null || Token.access_token != null && Token.refresh_token == null){ await AuthAnonymous(); diff --git a/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs b/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs index 42a1979..b97c4df 100644 --- a/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs +++ b/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs @@ -151,6 +151,9 @@ public class CrunchyrollManager{ }; options.History = true; + + options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases; + options.HistoryAutoRefreshIntervalMinutes = 0; CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions); @@ -581,23 +584,36 @@ public class CrunchyrollManager{ if (options.MarkAsWatched && data.Data is{ Count: > 0 }){ _ = CrEpisode.MarkAsWatched(data.Data.First().MediaId); } - - if (QueueManager.Instance.Queue.Count == 0 || QueueManager.Instance.Queue.All(e => e.DownloadProgress.Done)){ - QueueManager.Instance.ResetDownloads(); - try{ - var audioPath = CrunOptions.DownloadFinishedSoundPath; - if (!string.IsNullOrEmpty(audioPath)){ - var player = new AudioPlayer(); - player.Play(audioPath); + + if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){ + if (CrunOptions.DownloadFinishedPlaySound){ + try{ + var audioPath = CrunOptions.DownloadFinishedSoundPath; + if (!string.IsNullOrEmpty(audioPath)){ + var player = new AudioPlayer(); + player.Play(audioPath); + } + } catch (Exception exception){ + Console.Error.WriteLine("Failed to play sound: " + exception); } - } catch (Exception exception){ - Console.Error.WriteLine("Failed to play sound: " + exception); } - + if (CrunOptions.DownloadFinishedExecute){ + try{ + var filePath = CrunOptions.DownloadFinishedExecutePath; + if (!string.IsNullOrEmpty(filePath)){ + Helpers.ExecuteFile(filePath); + } + } catch (Exception exception){ + Console.Error.WriteLine("Failed to execute file: " + exception); + } + } + if (CrunOptions.ShutdownWhenQueueEmpty){ Helpers.ShutdownComputer(); } } + + return true; } diff --git a/CRD/Downloader/ProgramManager.cs b/CRD/Downloader/ProgramManager.cs index 2d598f4..460482d 100644 --- a/CRD/Downloader/ProgramManager.cs +++ b/CRD/Downloader/ProgramManager.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.IO; using System.Linq; using System.Runtime.InteropServices; +using System.Threading; using System.Threading.Tasks; using Avalonia; using Avalonia.Controls.ApplicationLifetimes; @@ -12,10 +13,12 @@ using Avalonia.Styling; using CommunityToolkit.Mvvm.ComponentModel; using CRD.Downloader.Crunchyroll; using CRD.Utils; +using CRD.Utils.Files; using CRD.Utils.Structs; using CRD.Utils.Structs.History; using CRD.Utils.Updater; using FluentAvalonia.Styling; +using ProtoBuf.Meta; namespace CRD.Downloader; @@ -75,10 +78,12 @@ public partial class ProgramManager : ObservableObject{ #endregion + private readonly PeriodicWorkRunner checkForNewEpisodesRunner; public IStorageProvider StorageProvider; public ProgramManager(){ + checkForNewEpisodesRunner = new PeriodicWorkRunner(async ct => { await CheckForDownloadsAsync(ct); }); _faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme; foreach (var arg in Environment.GetCommandLineArgs()){ @@ -106,7 +111,7 @@ public partial class ProgramManager : ObservableObject{ } } - Init(); + _ = Init(); CleanUpOldUpdater(); } @@ -172,12 +177,53 @@ public partial class ProgramManager : ObservableObject{ 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..."); - await Task.Delay(2000); + await Task.Delay(2000); } } - + + private async Task CheckForDownloadsAsync(CancellationToken ct){ + var crunchyManager = CrunchyrollManager.Instance; + var crunOptions = crunchyManager.CrunOptions; + + if (!crunOptions.History){ + return; + } + + switch (crunOptions.HistoryAutoRefreshMode){ + case HistoryRefreshMode.DefaultAll: + await RefreshHistory(FilterType.All); + break; + case HistoryRefreshMode.DefaultActive: + await RefreshHistory(FilterType.Active); + break; + case HistoryRefreshMode.FastNewReleases: + var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes( + string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang, + 2000, null, true); + if (newEpisodesBase is{ Data.Count: > 0 }){ + var newEpisodes = newEpisodesBase.Data ?? []; + + try{ + await crunchyManager.History.UpdateWithEpisode(newEpisodes); + CfgManager.UpdateHistoryFile(); + } catch (Exception e){ + Console.Error.WriteLine("Failed to update History"); + } + } + + break; + default: + return; + } + + var tasks = crunchyManager.HistoryList + .Select(item => item.AddNewMissingToDownloads(true)); + + await Task.WhenAll(tasks); + } + public void SetBackgroundImage(){ if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){ Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity, @@ -186,33 +232,41 @@ public partial class ProgramManager : ObservableObject{ } 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 (_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 (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){ + if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor); } + + if (_faTheme != null && Application.Current != null){ + if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){ + _faTheme.PreferSystemTheme = true; + } else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){ + _faTheme.PreferSystemTheme = false; + Application.Current.RequestedThemeVariant = ThemeVariant.Dark; + } else{ + _faTheme.PreferSystemTheme = false; + Application.Current.RequestedThemeVariant = ThemeVariant.Light; + } + } + + await CrunchyrollManager.Instance.Init(); + + FinishedLoading = true; + + await WorkOffArgsTasks(); + + StartRunners(true); + } catch (Exception e){ + Console.Error.WriteLine(e); + } finally{ + NavigationLock = false; } - - await CrunchyrollManager.Instance.Init(); - - FinishedLoading = true; - - await WorkOffArgsTasks(); } @@ -238,8 +292,7 @@ public partial class ProgramManager : ObservableObject{ } } } - - + private void CleanUpOldUpdater(){ 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."); } } + + public DateTime GetLastRefreshTime(){ + return checkForNewEpisodesRunner.LastRunTime; + } + + public void StartRunners(bool runImmediately = false){ + checkForNewEpisodesRunner.StartOrRestartMinutes(CrunchyrollManager.Instance.CrunOptions.HistoryAutoRefreshIntervalMinutes,runImmediately); + } + + public void StopBackgroundTasks(){ + checkForNewEpisodesRunner.Stop(); + } + } \ No newline at end of file diff --git a/CRD/Program.cs b/CRD/Program.cs index 62f2d04..9bc560f 100644 --- a/CRD/Program.cs +++ b/CRD/Program.cs @@ -6,29 +6,19 @@ using ReactiveUI.Avalonia; namespace CRD; sealed class Program{ - // Initialization code. Don't use any Avalonia, third-party APIs or any - // SynchronizationContext-reliant code before AppMain is called: things aren't initialized - // yet and stuff might break. [STAThread] public static void Main(string[] args){ var isHeadless = args.Contains("--headless"); BuildAvaloniaApp(isHeadless).StartWithClassicDesktopLifetime(args); } - - // Avalonia configuration, don't remove; also used by visual designer. - // public static AppBuilder BuildAvaloniaApp() - // => AppBuilder.Configure() - // .UsePlatformDetect() - // .WithInterFont() - // .LogToTrace(); - + public static AppBuilder BuildAvaloniaApp(bool isHeadless){ var builder = AppBuilder.Configure() .UsePlatformDetect() .WithInterFont() .LogToTrace() - .UseReactiveUI() ; + .UseReactiveUI(_ => { }); if (isHeadless){ Console.WriteLine("Running in headless mode..."); diff --git a/CRD/Utils/Enums/EnumCollection.cs b/CRD/Utils/Enums/EnumCollection.cs index f987781..4c69005 100644 --- a/CRD/Utils/Enums/EnumCollection.cs +++ b/CRD/Utils/Enums/EnumCollection.cs @@ -276,6 +276,12 @@ public enum EpisodeDownloadMode{ OnlySubs, } +public enum HistoryRefreshMode{ + DefaultAll = 0, + DefaultActive = 1, + FastNewReleases = 50 +} + public enum SonarrCoverType{ Banner, FanArt, diff --git a/CRD/Utils/Helpers.cs b/CRD/Utils/Helpers.cs index c7d1c1d..de4db22 100644 --- a/CRD/Utils/Helpers.cs +++ b/CRD/Utils/Helpers.cs @@ -7,6 +7,7 @@ using System.Linq; using System.Net.Http; using System.Runtime.InteropServices; using System.Runtime.Serialization; +using System.Text; using System.Text.RegularExpressions; using System.Threading; using System.Threading.Tasks; @@ -360,158 +361,201 @@ public class Helpers{ } } - private static string GetQualityOption(VideoPreset preset){ + private static IEnumerable GetQualityOption(VideoPreset preset){ return preset.Codec switch{ - "h264_nvenc" or "hevc_nvenc" => $"-cq {preset.Crf}", // For NVENC - "h264_qsv" or "hevc_qsv" => $"-global_quality {preset.Crf}", // For Intel QSV - "h264_amf" or "hevc_amf" => $"-qp {preset.Crf}", // For AMD VCE - _ => $"-crf {preset.Crf}", // For software codecs like libx264/libx265 + "h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()], + "h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()], + "h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()], + _ =>["-crf", preset.Crf.ToString()] }; } - public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(string inputFilePath, VideoPreset preset, CrunchyEpMeta? data = null){ + public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync( + string inputFilePath, + VideoPreset preset, + CrunchyEpMeta? data = null){ try{ - string outputExtension = Path.GetExtension(inputFilePath); - string directory = Path.GetDirectoryName(inputFilePath); - string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath); - string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}"); + string ext = Path.GetExtension(inputFilePath); + string dir = Path.GetDirectoryName(inputFilePath)!; + string name = Path.GetFileNameWithoutExtension(inputFilePath); - string additionalParams = string.Join(" ", preset.AdditionalParameters.Select(param => { - var splitIndex = param.IndexOf(' '); - if (splitIndex > 0){ - var prefix = param[..splitIndex]; - var value = param[(splitIndex + 1)..]; - - if (value.Contains(' ') && !(value.StartsWith("\"") && value.EndsWith("\""))){ - value = $"\"{value}\""; - } - - return $"{prefix} {value}"; - } - - return param; - })); - - string qualityOption = GetQualityOption(preset); + string tempOutput = Path.Combine(dir, $"{name}_output{ext}"); TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath); - if (totalDuration == null){ - Console.Error.WriteLine("Unable to retrieve input file duration."); - } else{ - Console.WriteLine($"Total Duration: {totalDuration}"); + + var args = new List{ + "-nostdin", + "-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}\""; - using (var process = new Process()){ - process.StartInfo.FileName = CfgManager.PathFFMPEG; - process.StartInfo.Arguments = ffmpegCommand; - process.StartInfo.RedirectStandardOutput = true; - process.StartInfo.RedirectStandardError = true; - process.StartInfo.UseShellExecute = false; - process.StartInfo.CreateNoWindow = true; - process.EnableRaisingEvents = true; + args.Add("-vf"); + args.Add($"scale={preset.Resolution},fps={preset.FrameRate}"); - process.OutputDataReceived += (sender, e) => { - if (!string.IsNullOrEmpty(e.Data)){ - Console.WriteLine(e.Data); - } - }; + foreach (var param in preset.AdditionalParameters){ + args.AddRange(SplitArguments(param)); + } - process.ErrorDataReceived += (sender, e) => { - if (!string.IsNullOrEmpty(e.Data)){ - Console.Error.WriteLine($"{e.Data}"); - if (data != null && totalDuration != null){ - ParseProgress(e.Data, totalDuration.Value, data); - } - } - }; + args.Add(tempOutput); - process.Start(); - process.BeginOutputReadLine(); - process.BeginErrorReadLine(); - - using var reg = data?.Cts.Token.Register(() => { + string commandString = BuildCommandString(CfgManager.PathFFMPEG, args); + int exitCode; + try{ + exitCode = await RunFFmpegAsync( + CfgManager.PathFFMPEG, + args, + data?.Cts.Token ?? CancellationToken.None, + onStdErr: line => { Console.Error.WriteLine(line); }, + onStdOut: Console.WriteLine + ); + } catch (OperationCanceledException){ + if (File.Exists(tempOutput)){ try{ - if (!process.HasExited) - process.Kill(true); + File.Delete(tempOutput); } catch{ // ignored } - }); - - try{ - await process.WaitForExitAsync(data.Cts.Token); - } catch (OperationCanceledException){ - if (File.Exists(tempOutputFilePath)){ - try{ - File.Delete(tempOutputFilePath); - } catch{ - // ignored - } - } - - return (IsOk: false, ErrorCode: -2); } - bool isSuccess = process.ExitCode == 0; - - 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); + Console.Error.WriteLine("FFMPEG task was canceled"); + return (false, -2); } + + 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){ - Console.Error.WriteLine($"An error occurred: {ex.Message}"); - return (IsOk: false, ErrorCode: -1); + Console.Error.WriteLine(ex); + + return (false, -1); } } - private static void ParseProgress(string progressString, TimeSpan totalDuration, CrunchyEpMeta data){ - try{ - if (progressString.Contains("time=")){ - var timeIndex = progressString.IndexOf("time=") + 5; - var timeString = progressString.Substring(timeIndex, 11); + private static IEnumerable SplitArguments(string commandLine){ + var args = new List(); + var current = new StringBuilder(); + bool inQuotes = false; - - if (TimeSpan.TryParse(timeString, out var currentTime)){ - int progress = (int)(currentTime.TotalSeconds / totalDuration.TotalSeconds * 100); - Console.WriteLine($"Progress: {progress:F2}%"); - - data.DownloadProgress = new DownloadProgress(){ - IsDownloading = true, - Percent = progress, - Time = 0, - DownloadSpeedBytes = 0, - Doing = "Encoding" - }; - - QueueManager.Instance.Queue.Refresh(); - } + foreach (char c in commandLine){ + if (c == '"'){ + inQuotes = !inQuotes; + continue; + } + + if (char.IsWhiteSpace(c) && !inQuotes){ + if (current.Length > 0){ + args.Add(current.ToString()); + current.Clear(); + } + } else{ + current.Append(c); } - } 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 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 RunFFmpegAsync( + string ffmpegPath, + IEnumerable args, + CancellationToken token, + Action? onStdErr = null, + Action? 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? 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 GetMediaDurationAsync(string ffmpegPath, string inputFilePath){ try{ using (var process = new Process()){ @@ -855,7 +899,7 @@ public class Helpers{ bool mergeVideo){ if (target == null) throw new ArgumentNullException(nameof(target)); if (source == null) throw new ArgumentNullException(nameof(source)); - + var serverSet = new HashSet(target.servers); void AddServer(string s){ @@ -866,14 +910,14 @@ public class Helpers{ foreach (var kvp in source){ var key = kvp.Key; var src = kvp.Value; - + if (!src.servers.Contains(key)) src.servers.Add(key); - + AddServer(key); foreach (var s in src.servers) AddServer(s); - + if (mergeAudio && src.audio != null){ target.audio ??= []; target.audio.AddRange(src.audio); @@ -911,6 +955,8 @@ public class Helpers{ if (result == ContentDialogResult.Primary){ timer.Stop(); } + } catch (Exception e){ + Console.Error.WriteLine(e); } finally{ ShutdownLock.Release(); } @@ -967,4 +1013,18 @@ public class Helpers{ Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}"); } } + + public static bool ExecuteFile(string filePath){ + try{ + Process.Start(new ProcessStartInfo{ + FileName = filePath, + UseShellExecute = true + }); + + return true; + } catch (Exception ex){ + Console.Error.WriteLine($"Execution failed: {ex.Message}"); + return false; + } + } } \ No newline at end of file diff --git a/CRD/Utils/Http/HttpClientReq.cs b/CRD/Utils/Http/HttpClientReq.cs index c68e0e2..fcbce51 100644 --- a/CRD/Utils/Http/HttpClientReq.cs +++ b/CRD/Utils/Http/HttpClientReq.cs @@ -296,7 +296,7 @@ public static class ApiUrls{ public static string Auth => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/auth/v1/token"; public static string Profile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/profile"; - public static string 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 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"; diff --git a/CRD/Utils/Muxing/FontsManager.cs b/CRD/Utils/Muxing/FontsManager.cs index 80f57ef..c91902b 100644 --- a/CRD/Utils/Muxing/FontsManager.cs +++ b/CRD/Utils/Muxing/FontsManager.cs @@ -2,37 +2,40 @@ using System.Collections.Generic; using System.IO; using System.Linq; +using System.Security.Cryptography; +using System.Text; using System.Text.RegularExpressions; +using System.Threading; using System.Threading.Tasks; using CRD.Utils.Files; using CRD.Utils.Structs; using CRD.Views; +using SixLabors.Fonts; namespace CRD.Utils.Muxing; public class FontsManager{ #region Singelton - private static FontsManager? instance; - private static readonly object padlock = new object(); + private static readonly Lock Padlock = new Lock(); public static FontsManager Instance{ get{ - if (instance == null){ - lock (padlock){ - if (instance == null){ - instance = new FontsManager(); + if (field == null){ + lock (Padlock){ + if (field == null){ + field = new FontsManager(); } } } - return instance; + return field; } } #endregion - public Dictionary Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){ + public Dictionary Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){ { "Adobe Arabic", "AdobeArabic-Bold.otf" }, { "Andale Mono", "andalemo.ttf" }, { "Arial", "arial.ttf" }, @@ -103,9 +106,14 @@ public class FontsManager{ { "Webdings", "webdings.ttf" } }; + private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/"; - public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/"; + private readonly FontIndex index = new(); + + private void EnsureIndex(string fontsDir){ + index.Rebuild(fontsDir); + } public async Task GetFontsAsync(){ Console.WriteLine("Downloading fonts..."); @@ -115,134 +123,334 @@ public class FontsManager{ var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font); if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){ - // Console.WriteLine($"{font} already downloaded!"); - } else{ - var fontFolder = Path.GetDirectoryName(fontLoc); - if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){ - File.Delete(fontLoc); - } + continue; + } - try{ - if (!Directory.Exists(fontFolder)){ - Directory.CreateDirectory(fontFolder); - } - } catch (Exception e){ - Console.WriteLine($"Failed to create directory: {e.Message}"); - } + var fontFolder = Path.GetDirectoryName(fontLoc); + if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0) + File.Delete(fontLoc); - 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(); - try{ - var response = await httpClient.GetAsync(fontUrl); - if (response.IsSuccessStatusCode){ - var fontData = await response.Content.ReadAsByteArrayAsync(); - await File.WriteAllBytesAsync(fontLoc, fontData); - Console.WriteLine($"Downloaded: {font}"); - } else{ - Console.Error.WriteLine($"Failed to download: {font}"); - } - } catch (Exception e){ - Console.Error.WriteLine($"Error downloading {font}: {e.Message}"); + var fontUrl = root + font; + + var httpClient = HttpClientReq.Instance.GetHttpClient(); + try{ + var response = await httpClient.GetAsync(fontUrl); + if (response.IsSuccessStatusCode){ + var fontData = await response.Content.ReadAsByteArrayAsync(); + await File.WriteAllBytesAsync(fontLoc, fontData); + Console.WriteLine($"Downloaded: {font}"); + } else{ + Console.Error.WriteLine($"Failed to download: {font}"); } + } catch (Exception e){ + Console.Error.WriteLine($"Error downloading {font}: {e.Message}"); } } Console.WriteLine("All required fonts downloaded!"); } - public static List ExtractFontsFromAss(string ass){ - var lines = ass.Replace("\r", "").Split('\n'); - var styles = new List(); + if (string.IsNullOrWhiteSpace(ass)) + return new List(); + + ass = ass.Replace("\r", ""); + var lines = ass.Split('\n'); + + var fonts = new List(); foreach (var line in lines){ - if (line.StartsWith("Style: ")){ - var parts = line.Split(','); - if (parts.Length > 1) - styles.Add(parts[1].Trim()); + if (line.StartsWith("Style: ", StringComparison.OrdinalIgnoreCase)){ + var parts = line.Substring(7).Split(','); + if (parts.Length > 1){ + var fontName = parts[1].Trim(); + fonts.Add(NormalizeFontKey(fontName)); + } } } var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)"); foreach (Match match in fontMatches){ - if (match.Groups.Count > 1) - styles.Add(match.Groups[1].Value); + if (match.Groups.Count > 1){ + 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 GetDictFromKeyList(List keysList){ - Dictionary filteredDictionary = new Dictionary(); + public Dictionary GetDictFromKeyList(List keysList, bool keepUnknown = true){ + Dictionary filteredDictionary = new(StringComparer.OrdinalIgnoreCase); foreach (string key in keysList){ - if (Fonts.TryGetValue(key, out var font)){ - filteredDictionary.Add(key, font); + var k = NormalizeFontKey(key); + + if (Fonts.TryGetValue(k, out var fontFile)){ + filteredDictionary[k] = fontFile; + } else if (keepUnknown){ + filteredDictionary[k] = k; } } return filteredDictionary; } - - public static string GetFontMimeType(string fontFile){ - if (Regex.IsMatch(fontFile, @"\.otf$")) + public static string GetFontMimeType(string fontFileOrPath){ + var ext = Path.GetExtension(fontFileOrPath); + if (ext.Equals(".otf", StringComparison.OrdinalIgnoreCase)) return "application/vnd.ms-opentype"; - else if (Regex.IsMatch(fontFile, @"\.ttf$")) + if (ext.Equals(".ttf", StringComparison.OrdinalIgnoreCase)) return "application/x-truetype-font"; - else - return "application/octet-stream"; + if (ext.Equals(".ttc", StringComparison.OrdinalIgnoreCase) || ext.Equals(".otc", StringComparison.OrdinalIgnoreCase)) + return "application/x-truetype-font"; + if (ext.Equals(".woff", StringComparison.OrdinalIgnoreCase)) + return "font/woff"; + if (ext.Equals(".woff2", StringComparison.OrdinalIgnoreCase)) + return "font/woff2"; + return "application/octet-stream"; } public List MakeFontsList(string fontsDir, List subs){ - Dictionary fontsNameList = new Dictionary(); - List subsList = new List(); - List fontsList = new List(); - bool isNstr = true; + EnsureIndex(fontsDir); + + var required = new HashSet(StringComparer.OrdinalIgnoreCase); + var subsLocales = new List(); + var fontsList = new List(); + var missing = new List(); foreach (var s in subs){ - foreach (var keyValuePair in s.Fonts){ - if (!fontsNameList.ContainsKey(keyValuePair.Key)){ - fontsNameList.Add(keyValuePair.Key, keyValuePair.Value); + subsLocales.Add(s.Language.Locale); + + 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){ - Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count); - isNstr = false; - } + fontsList.Add(new ParsedFont{ + Name = attachName, + Path = resolvedPath, + Mime = GetFontMimeType(resolvedPath) + }); - if (fontsNameList.Count > 0){ - Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count); - } - - List missingFonts = new List(); - - 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 }); - } + if (!exact) Console.WriteLine($"Soft-resolved '{requested}' -> '{Path.GetFileName(resolvedPath)}'"); } else{ - missingFonts.Add(f.Key); + missing.Add(requested); } } - if (missingFonts.Count > 0){ - MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}"); - } + if (missing.Count > 0) + MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}"); 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 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 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 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 BuildAliases(FontDescription d){ + var family = d.FontFamilyInvariantCulture.Trim(); + var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic + var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily" + + if (!string.IsNullOrWhiteSpace(family)) yield return family; + if (!string.IsNullOrWhiteSpace(full)) yield return full; + + if (!string.IsNullOrWhiteSpace(family) && + !string.IsNullOrWhiteSpace(sub) && + !sub.Equals("Regular", StringComparison.OrdinalIgnoreCase)){ + yield return $"{family} {sub}"; + } + } + + private readonly record struct Candidate(string Path, int Score); + } } public class SubtitleFonts{ - public LanguageItem Language{ get; set; } - public Dictionary Fonts{ get; set; } + public LanguageItem Language{ get; set; } = new(); + public Dictionary Fonts{ get; set; } = new(); } \ No newline at end of file diff --git a/CRD/Utils/PeriodicWorkRunner.cs b/CRD/Utils/PeriodicWorkRunner.cs new file mode 100644 index 0000000..8638bfa --- /dev/null +++ b/CRD/Utils/PeriodicWorkRunner.cs @@ -0,0 +1,83 @@ +using System; +using System.Threading; +using System.Threading.Tasks; + +namespace CRD.Utils; + +public class PeriodicWorkRunner(Func 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(); +} \ No newline at end of file diff --git a/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs b/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs index d2ec1d7..2f51e58 100644 --- a/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs +++ b/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs @@ -56,6 +56,11 @@ public class CrDownloadOptions{ [JsonProperty("download_finished_sound_path")] public string? DownloadFinishedSoundPath{ get; set; } + [JsonProperty("download_finished_execute")] + public bool DownloadFinishedExecute{ get; set; } + + [JsonProperty("download_finished_execute_path")] + public string? DownloadFinishedExecutePath{ get; set; } [JsonProperty("background_image_opacity")] public double BackgroundImageOpacity{ get; set; } @@ -92,6 +97,13 @@ public class CrDownloadOptions{ [JsonProperty("history_count_sonarr")] public bool HistoryCountSonarr{ get; set; } + + [JsonProperty("history_auto_refresh_interval_minutes")] + public int HistoryAutoRefreshIntervalMinutes{ get; set; } + + [JsonProperty("history_auto_refresh_mode")] + public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; } + [JsonProperty("sonarr_properties")] public SonarrProperties? SonarrProperties{ get; set; } @@ -141,6 +153,17 @@ public class CrDownloadOptions{ [JsonProperty("flare_solverr_properties")] public FlareSolverrProperties? FlareSolverrProperties{ get; set; } + [JsonProperty("tray_icon_enabled")] + public bool TrayIconEnabled{ get; set; } + + [JsonProperty("tray_start_minimized")] + public bool StartMinimizedToTray{ get; set; } + + [JsonProperty("tray_on_minimize")] + public bool MinimizeToTray{ get; set; } + + [JsonProperty("tray_on_close")] + public bool MinimizeToTrayOnClose{ get; set; } #endregion diff --git a/CRD/Utils/Structs/Crunchyroll/CrProfile.cs b/CRD/Utils/Structs/Crunchyroll/CrProfile.cs index 3df2146..13a9821 100644 --- a/CRD/Utils/Structs/Crunchyroll/CrProfile.cs +++ b/CRD/Utils/Structs/Crunchyroll/CrProfile.cs @@ -4,7 +4,20 @@ using Newtonsoft.Json; namespace CRD.Utils.Structs.Crunchyroll; +public class CrMultiProfile{ + + [JsonProperty("tier_max_profiles")] + public int? TierMaxProfiles{ get; set; } + + [JsonProperty("max_profiles")] + public int? MaxProfiles{ get; set; } + + [JsonProperty("profiles")] + public List Profiles{ get; set; } = []; +} + public class CrProfile{ + public string? Avatar{ get; set; } public string? Email{ get; set; } public string? Username{ get; set; } @@ -20,8 +33,14 @@ public class CrProfile{ [JsonProperty("preferred_content_subtitle_language")] public string? PreferredContentSubtitleLanguage{ get; set; } - [JsonIgnore] - public Subscription? Subscription{ get; set; } + [JsonProperty("can_switch")] + public bool CanSwitch{ get; set; } + + [JsonProperty("is_selected")] + public bool IsSelected{ get; set; } + + [JsonProperty("is_pin_protected")] + public bool IsPinProtected{ get; set; } [JsonIgnore] public bool HasPremium{ get; set; } diff --git a/CRD/Utils/Structs/HelperClasses.cs b/CRD/Utils/Structs/HelperClasses.cs index 0a2c2f3..dde44ad 100644 --- a/CRD/Utils/Structs/HelperClasses.cs +++ b/CRD/Utils/Structs/HelperClasses.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.Linq; +using Avalonia.Media.Imaging; using CommunityToolkit.Mvvm.ComponentModel; using CRD.Utils.Structs.History; using CRD.Views; @@ -13,6 +14,23 @@ public class AuthData{ public string Password{ get; set; } } +public partial class AccountProfile : ObservableObject{ + [ObservableProperty] + private string _profileName = ""; + + [ObservableProperty] + private string _avatarUrl = ""; + + [ObservableProperty] + private Bitmap? _profileImage; + + [ObservableProperty] + private bool _canBeSelected; + + public string? ProfileId{ get; set; } + +} + public class CrAuthSettings{ public string Endpoint{ get; set; } public string Authorization{ get; set; } @@ -141,6 +159,11 @@ public class StringItemWithDisplayName{ public string value{ get; set; } } +public class RefreshModeOption{ + public string DisplayName{ get; set; } + public HistoryRefreshMode value{ get; set; } +} + public class WindowSettings{ public double Width{ get; set; } public double Height{ get; set; } diff --git a/CRD/Utils/Structs/History/HistoryEpisode.cs b/CRD/Utils/Structs/History/HistoryEpisode.cs index c9d0dc9..f4dab4b 100644 --- a/CRD/Utils/Structs/History/HistoryEpisode.cs +++ b/CRD/Utils/Structs/History/HistoryEpisode.cs @@ -1,6 +1,7 @@ using System; using System.Collections.Generic; using System.ComponentModel; +using System.Linq; using System.Threading.Tasks; using Avalonia.Media.Imaging; using CRD.Downloader; @@ -140,10 +141,16 @@ public class HistoryEpisode : INotifyPropertyChanged{ } 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){ case EpisodeType.MusicVideo: await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath); diff --git a/CRD/Utils/Structs/History/HistorySeries.cs b/CRD/Utils/Structs/History/HistorySeries.cs index f62b66b..8875956 100644 --- a/CRD/Utils/Structs/History/HistorySeries.cs +++ b/CRD/Utils/Structs/History/HistorySeries.cs @@ -64,16 +64,16 @@ public class HistorySeries : INotifyPropertyChanged{ public string HistorySeriesVideoQualityOverride{ get; set; } = ""; [JsonProperty("history_series_available_soft_subs")] - public List HistorySeriesAvailableSoftSubs{ get; set; } =[]; + public List HistorySeriesAvailableSoftSubs{ get; set; } = []; [JsonProperty("history_series_available_dub_lang")] - public List HistorySeriesAvailableDubLang{ get; set; } =[]; + public List HistorySeriesAvailableDubLang{ get; set; } = []; [JsonProperty("history_series_soft_subs_override")] - public ObservableCollection HistorySeriesSoftSubsOverride{ get; set; } =[]; + public ObservableCollection HistorySeriesSoftSubsOverride{ get; set; } = []; [JsonProperty("history_series_dub_lang_override")] - public ObservableCollection HistorySeriesDubLangOverride{ get; set; } =[]; + public ObservableCollection HistorySeriesDubLangOverride{ get; set; } = []; public event PropertyChangedEventHandler? PropertyChanged; @@ -329,7 +329,7 @@ public class HistorySeries : INotifyPropertyChanged{ PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData))); } - public async Task AddNewMissingToDownloads(){ + public async Task AddNewMissingToDownloads(bool chekQueueForId = false){ bool foundWatched = false; var options = CrunchyrollManager.Instance.CrunOptions; @@ -355,7 +355,7 @@ public class HistorySeries : INotifyPropertyChanged{ } if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){ - await ep.DownloadEpisode(); + await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId); } } } @@ -372,14 +372,14 @@ public class HistorySeries : INotifyPropertyChanged{ if (ep.SpecialEpisode){ if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){ - await ep.DownloadEpisode(); + await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId); } continue; } if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){ - await ep.DownloadEpisode(); + await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId); } else{ foundWatched = true; if (!historyAddSpecials && !countMissing){ @@ -467,54 +467,61 @@ public class HistorySeries : INotifyPropertyChanged{ } 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)){ SeriesFolderPath = SeriesDownloadPath; SeriesFolderPathExists = true; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists))); + return; } - if (season is{ SeasonDownloadPath: not null }){ + // Season path + if (!string.IsNullOrEmpty(season?.SeasonDownloadPath)){ try{ - var seasonPath = season.SeasonDownloadPath; - var directoryInfo = new DirectoryInfo(seasonPath); + var directoryInfo = new DirectoryInfo(season.SeasonDownloadPath); - if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){ - string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty; + var parentFolder = directoryInfo.Parent?.FullName; - if (Directory.Exists(parentFolderPath)){ - SeriesFolderPath = parentFolderPath; - SeriesFolderPathExists = true; - } + if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){ + SeriesFolderPath = parentFolder; + SeriesFolderPathExists = true; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists))); + return; } } 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)) - return; + // Auto generated path + 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)) - return; + if (string.IsNullOrEmpty(seriesTitle)){ + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists))); + return; + } - // Check Crunchyroll download directory - var downloadDirPath = CrunchyrollManager.Instance.CrunOptions.DownloadDirPath; - if (!string.IsNullOrEmpty(downloadDirPath)){ - customPath = Path.Combine(downloadDirPath, seriesTitle); - } else{ - // Fallback to configured VIDEOS_DIR path - customPath = Path.Combine(CfgManager.PathVIDEOS_DIR, seriesTitle); - } + string basePath = + !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DownloadDirPath) + ? CrunchyrollManager.Instance.CrunOptions.DownloadDirPath + : CfgManager.PathVIDEOS_DIR; - // Check if custom path exists - if (Directory.Exists(customPath)){ - SeriesFolderPath = customPath; - SeriesFolderPathExists = true; - } + var customPath = Path.Combine(basePath, seriesTitle); + + if (Directory.Exists(customPath)){ + SeriesFolderPath = customPath; + SeriesFolderPathExists = true; } PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists))); diff --git a/CRD/Utils/UI/EpisodeHighlightTextBlock.cs b/CRD/Utils/UI/EpisodeHighlightTextBlock.cs index 1a4aedd..3f97d98 100644 --- a/CRD/Utils/UI/EpisodeHighlightTextBlock.cs +++ b/CRD/Utils/UI/EpisodeHighlightTextBlock.cs @@ -127,8 +127,7 @@ public class EpisodeHighlightTextBlock : TextBlock{ streamingService == StreamingService.Crunchyroll ? new HashSet(CrunchyrollManager.Instance.CrunOptions.DlSubs) : new HashSet(); - var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && - subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []); + var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && (subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []) || subSet.Contains("all")); if (higlight){ Foreground = Brushes.Orange; diff --git a/CRD/Utils/UI/HighlightingTextBlock.cs b/CRD/Utils/UI/HighlightingTextBlock.cs index 77ca955..ef49aa0 100644 --- a/CRD/Utils/UI/HighlightingTextBlock.cs +++ b/CRD/Utils/UI/HighlightingTextBlock.cs @@ -155,7 +155,7 @@ public class HighlightingTextBlock : TextBlock{ foreach (var item in Items){ var run = new Run(item); - if (highlightSet.Contains(item)){ + if (highlightSet.Contains(item) || highlightSet.Contains("all")){ run.Foreground = Brushes.Orange; // run.FontWeight = FontWeight.Bold; } diff --git a/CRD/ViewModels/AccountPageViewModel.cs b/CRD/ViewModels/AccountPageViewModel.cs index 34894f2..18c367a 100644 --- a/CRD/ViewModels/AccountPageViewModel.cs +++ b/CRD/ViewModels/AccountPageViewModel.cs @@ -7,6 +7,9 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CRD.Downloader.Crunchyroll; using CRD.Utils; +using CRD.Utils.Structs; +using CRD.Utils.UI; +using CRD.ViewModels.Utils; using CRD.Views.Utils; using FluentAvalonia.UI.Controls; using Newtonsoft.Json; @@ -22,6 +25,9 @@ public partial class AccountPageViewModel : ViewModelBase{ [ObservableProperty] private string _loginLogoutText = ""; + + [ObservableProperty] + private bool _hasMultiProfile; [ObservableProperty] private string _remainingTime = ""; @@ -50,8 +56,8 @@ public partial class AccountPageViewModel : ViewModelBase{ RemainingTime = "Subscription maybe ended"; } - if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){ - Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented)); + if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){ + Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented)); } } else{ 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(){ - 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/" + - (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.SubscriptionProducts is{ Count: >= 1 }){ @@ -84,8 +95,8 @@ public partial class AccountPageViewModel : ViewModelBase{ UnknownEndDate = true; } - if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){ - _targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate; + if (!UnknownEndDate){ + _targetTime = subscriptions.NextRenewalDate; _timer = new DispatcherTimer{ Interval = TimeSpan.FromSeconds(1) }; @@ -101,8 +112,8 @@ public partial class AccountPageViewModel : ViewModelBase{ RaisePropertyChanged(nameof(RemainingTime)); - if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){ - Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented)); + if (subscriptions != null){ + 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){ try{ ProfileImage = await Helpers.LoadImage(imageUrl); diff --git a/CRD/ViewModels/HistoryPageViewModel.cs b/CRD/ViewModels/HistoryPageViewModel.cs index 15ea4d4..30ef7a6 100644 --- a/CRD/ViewModels/HistoryPageViewModel.cs +++ b/CRD/ViewModels/HistoryPageViewModel.cs @@ -453,7 +453,7 @@ public partial class HistoryPageViewModel : ViewModelBase{ .SelectMany(item => item.Seasons) .SelectMany(season => season.EpisodesList) .Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile) - .Select(historyEpisode => historyEpisode.DownloadEpisode()) + .Select(historyEpisode => historyEpisode.DownloadEpisodeDefault()) ); } @@ -549,7 +549,7 @@ public partial class HistoryPageViewModel : ViewModelBase{ [RelayCommand] public async Task DownloadSeasonAll(HistorySeason season){ 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)); } else{ foreach (var episode in missingEpisodes){ - await episode.DownloadEpisode(); + await episode.DownloadEpisodeDefault(); } } } @@ -570,7 +570,7 @@ public partial class HistoryPageViewModel : ViewModelBase{ [RelayCommand] public async Task DownloadSeasonMissingSonarr(HistorySeason season){ 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; 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){ 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){ if (historyEpisode.WasDownloaded == allDownloaded){ - seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId); + historyEpisode.ToggleWasDownloaded(); } } + seriesArgs.Season.UpdateDownloaded(); } seriesArgs.Series?.UpdateNewEpisodes(); diff --git a/CRD/ViewModels/SeriesPageViewModel.cs b/CRD/ViewModels/SeriesPageViewModel.cs index 359f979..d220d71 100644 --- a/CRD/ViewModels/SeriesPageViewModel.cs +++ b/CRD/ViewModels/SeriesPageViewModel.cs @@ -108,6 +108,20 @@ public partial class SeriesPageViewModel : ViewModelBase{ SelectedSeries.UpdateSeriesFolderPath(); } + + [RelayCommand] + public void ClearFolderPathCommand(HistorySeason? season){ + + if (season != null){ + season.SeasonDownloadPath = string.Empty; + } else{ + SelectedSeries.SeriesDownloadPath = string.Empty; + } + + CfgManager.UpdateHistoryFile(); + SelectedSeries.UpdateSeriesFolderPath(); + + } [RelayCommand] public async Task OpenFeaturedMusicDialog(){ @@ -213,7 +227,7 @@ public partial class SeriesPageViewModel : ViewModelBase{ [RelayCommand] public async Task DownloadSeasonAll(HistorySeason season){ 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)); } else{ foreach (var episode in missingEpisodes){ - await episode.DownloadEpisode(); + await episode.DownloadEpisodeDefault(); } } } @@ -236,7 +250,7 @@ public partial class SeriesPageViewModel : ViewModelBase{ var downloadMode = SelectedDownloadMode; 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){ 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] public async Task DownloadSeasonMissingSonarr(HistorySeason season){ 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){ bool allDownloaded = season.EpisodesList.All(ep => ep.WasDownloaded); - foreach (var historyEpisode in season.EpisodesList){ - if (historyEpisode.WasDownloaded == allDownloaded){ - season.UpdateDownloaded(historyEpisode.EpisodeId); - } + foreach (var historyEpisode in season.EpisodesList.Where(historyEpisode => historyEpisode.WasDownloaded == allDownloaded)){ + historyEpisode.ToggleWasDownloaded(); } + + season.UpdateDownloaded(); } [RelayCommand] diff --git a/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs b/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs index 22f7bf5..f809d58 100644 --- a/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs +++ b/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs @@ -81,7 +81,7 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{ [RelayCommand] public void DownloadEpisode(HistoryEpisode episode){ - episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath); + episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath,false); } private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){ diff --git a/CRD/ViewModels/Utils/ContentDialogMultiProfileSelectViewModel.cs b/CRD/ViewModels/Utils/ContentDialogMultiProfileSelectViewModel.cs new file mode 100644 index 0000000..d38d320 --- /dev/null +++ b/CRD/ViewModels/Utils/ContentDialogMultiProfileSelectViewModel.cs @@ -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 _profileList = []; + + public ContentDialogMultiProfileSelectViewModel(CustomContentDialog contentDialog, List 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 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; + } +} \ No newline at end of file diff --git a/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs b/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs index cc3aa04..850af35 100644 --- a/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs +++ b/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs @@ -2,6 +2,7 @@ using System.Collections.Generic; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Linq; using System.Net.Http; @@ -19,6 +20,7 @@ using CRD.Downloader.Crunchyroll; using CRD.Utils; using CRD.Utils.Files; using CRD.Utils.Sonarr; +using CRD.Utils.Structs; using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.History; using FluentAvalonia.Styling; @@ -50,6 +52,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [ObservableProperty] private bool _historyCountSonarr; + + [ObservableProperty] + private double? _historyAutoRefreshIntervalMinutes; + + [ObservableProperty] + private HistoryRefreshMode _historyAutoRefreshMode; + + [ObservableProperty] + private string _historyAutoRefreshModeHint; + + [ObservableProperty] + private string _historyAutoRefreshLastRunTime; + + public ObservableCollection HistoryAutoRefreshModes{ get; } = new(){ + new RefreshModeOption(){ DisplayName = "Default All", value = HistoryRefreshMode.DefaultAll }, + new RefreshModeOption(){ DisplayName = "Default Active", value = HistoryRefreshMode.DefaultActive }, + new RefreshModeOption(){ DisplayName = "Fast New Releases", value = HistoryRefreshMode.FastNewReleases }, + }; [ObservableProperty] private double? _simultaneousDownloads; @@ -74,6 +94,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [ObservableProperty] private double? _retryDelay; + + [ObservableProperty] + private bool _trayIconEnabled; + + [ObservableProperty] + private bool _startMinimizedToTray; + + [ObservableProperty] + private bool _minimizeToTray; + + [ObservableProperty] + private bool _minimizeToTrayOnClose; [ObservableProperty] private ComboBoxItem _selectedHistoryLang; @@ -231,6 +263,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [ObservableProperty] private string _downloadFinishedSoundPath; + + [ObservableProperty] + private bool _downloadFinishedExecute; + + [ObservableProperty] + private string _downloadFinishedExecutePath; [ObservableProperty] private string _currentIp = ""; @@ -263,6 +301,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty; DownloadFinishedPlaySound = options.DownloadFinishedPlaySound; + + DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty; + DownloadFinishedExecute = options.DownloadFinishedExecute; DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath; TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath; @@ -300,6 +341,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ HistoryAddSpecials = options.HistoryAddSpecials; HistorySkipUnmonitored = options.HistorySkipUnmonitored; HistoryCountSonarr = options.HistoryCountSonarr; + HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes; + HistoryAutoRefreshMode = options.HistoryAutoRefreshMode; + HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture); DownloadSpeed = options.DownloadSpeedLimit; DownloadSpeedInBits = options.DownloadSpeedInBits; DownloadMethodeNew = options.DownloadMethodeNew; @@ -310,6 +354,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ SimultaneousDownloads = options.SimultaneousDownloads; SimultaneousProcessingJobs = options.SimultaneousProcessingJobs; LogMode = options.LogMode; + + TrayIconEnabled = options.TrayIconEnabled; + StartMinimizedToTray = options.StartMinimizedToTray; + MinimizeToTray = options.MinimizeToTray; + MinimizeToTrayOnClose = options.MinimizeToTrayOnClose; ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null; CurrentAppTheme = theme ?? AppThemes[0]; @@ -331,6 +380,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ var settings = CrunchyrollManager.Instance.CrunOptions; settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound; + + settings.DownloadFinishedExecute = DownloadFinishedExecute; settings.DownloadMethodeNew = DownloadMethodeNew; settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart; @@ -347,6 +398,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists; settings.HistorySkipUnmonitored = HistorySkipUnmonitored; settings.HistoryCountSonarr = HistoryCountSonarr; + settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ; + settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode; settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000); settings.DownloadSpeedInBits = DownloadSpeedInBits; settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10); @@ -404,6 +457,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ } settings.FlareSolverrProperties = propsFlareSolverr; + + settings.TrayIconEnabled = TrayIconEnabled; + settings.StartMinimizedToTray = StartMinimizedToTray; + settings.MinimizeToTray = MinimizeToTray; + settings.MinimizeToTrayOnClose = MinimizeToTrayOnClose; settings.LogMode = LogMode; @@ -429,7 +487,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = 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 ); } @@ -441,7 +499,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = 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 ); } @@ -490,7 +548,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ BackgroundImagePath = path; Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius); }, - pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, + pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty, defaultPath: string.Empty ); } @@ -519,11 +577,39 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path; DownloadFinishedSoundPath = path; }, - pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath, + pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? 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{ + new("All Files"){ + Patterns = new[]{ "*.*" } + } + }, + pathSetter: (path) => { + CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path; + DownloadFinishedExecutePath = path; + }, + pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty, + defaultPath: string.Empty + ); + } + #endregion private async Task OpenFileDialogAsyncInternal( @@ -559,10 +645,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ _faTheme.PreferSystemTheme = true; } else if (value?.Content?.ToString() == "Dark"){ _faTheme.PreferSystemTheme = false; - Application.Current.RequestedThemeVariant = ThemeVariant.Dark; + Application.Current?.RequestedThemeVariant = ThemeVariant.Dark; } else{ _faTheme.PreferSystemTheme = false; - Application.Current.RequestedThemeVariant = ThemeVariant.Light; + Application.Current?.RequestedThemeVariant = ThemeVariant.Light; } UpdateSettings(); @@ -604,6 +690,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ _faTheme.CustomAccentColor = color; UpdateSettings(); } + partial void OnTrayIconEnabledChanged(bool value){ + ((App)Application.Current!).SetTrayIconVisible(value); + UpdateSettings(); + } + protected override void OnPropertyChanged(PropertyChangedEventArgs e){ base.OnPropertyChanged(e); @@ -613,11 +704,22 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ or nameof(ListBoxColor) or nameof(CurrentAppTheme) or nameof(UseCustomAccent) + or nameof(TrayIconEnabled) or nameof(LogMode)){ return; } UpdateSettings(); + + HistoryAutoRefreshModeHint = HistoryAutoRefreshMode switch{ + HistoryRefreshMode.DefaultAll => + "Refreshes the full history using the default method and includes all entries", + HistoryRefreshMode.DefaultActive => + "Refreshes the history using the default method and includes only active entries", + HistoryRefreshMode.FastNewReleases => + "Uses the faster refresh method, similar to the custom calendar, focusing on newly released items", + _ => "" + }; if (e.PropertyName is nameof(History)){ if (CrunchyrollManager.Instance.CrunOptions.History){ @@ -658,7 +760,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ } [RelayCommand] - public async void CheckIp(){ + public async Task CheckIp(){ var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false)); Console.Error.WriteLine("Your IP: " + result.ResponseContent); if (result.IsOk){ diff --git a/CRD/Views/AccountPageView.axaml b/CRD/Views/AccountPageView.axaml index 7e9320f..106bace 100644 --- a/CRD/Views/AccountPageView.axaml +++ b/CRD/Views/AccountPageView.axaml @@ -3,6 +3,7 @@ xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:vm="clr-namespace:CRD.ViewModels" + xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia" x:DataType="vm:AccountPageViewModel" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" x:Class="CRD.Views.AccountPageView"> @@ -13,13 +14,33 @@ - - - - - - + + + + + + + + + + + + + diff --git a/CRD/Views/MainWindow.axaml.cs b/CRD/Views/MainWindow.axaml.cs index 4c12a07..4001e4d 100644 --- a/CRD/Views/MainWindow.axaml.cs +++ b/CRD/Views/MainWindow.axaml.cs @@ -5,6 +5,7 @@ using System.Linq; using Avalonia; using Avalonia.Controls; using Avalonia.Markup.Xaml; +using Avalonia.Threading; using CRD.Downloader; using CRD.Downloader.Crunchyroll; using CRD.Utils; @@ -46,6 +47,8 @@ public partial class MainWindow : AppWindow{ #endregion private object selectedNavVieItem; + + private ToastNotification? toast; private const int TitleBarHeightAdjustment = 31; @@ -70,6 +73,7 @@ public partial class MainWindow : AppWindow{ PositionChanged += OnPositionChanged; SizeChanged += OnSizeChanged; + toast = this.FindControl("Toast"); //select first element as default var nv = this.FindControl("NavView"); @@ -150,55 +154,48 @@ public partial class MainWindow : AppWindow{ public void ShowToast(string message, ToastType type, int durationInSeconds = 5){ - var toastControl = this.FindControl("Toast"); - toastControl?.Show(message, type, durationInSeconds); + Dispatcher.UIThread.Post(() => toast?.Show(message, type, durationInSeconds)); } private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){ - if (sender is NavigationView navView){ - var selectedItem = navView.SelectedItem as NavigationViewItem; - if (selectedItem != null){ - switch (selectedItem.Tag){ - case "DownloadQueue": - navView.Content = Activator.CreateInstance(typeof(DownloadsPageViewModel)); - selectedNavVieItem = selectedItem; - break; - case "AddDownload": - navView.Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel)); - selectedNavVieItem = selectedItem; - break; - case "Calendar": - navView.Content = Activator.CreateInstance(typeof(CalendarPageViewModel)); - selectedNavVieItem = selectedItem; - break; - case "History": - navView.Content = Activator.CreateInstance(typeof(HistoryPageViewModel)); - navigationStack.Clear(); - navigationStack.Push(navView.Content); - selectedNavVieItem = selectedItem; - break; - case "Seasons": - navView.Content = Activator.CreateInstance(typeof(UpcomingPageViewModel)); - selectedNavVieItem = selectedItem; - break; - case "Account": - navView.Content = Activator.CreateInstance(typeof(AccountPageViewModel)); - selectedNavVieItem = selectedItem; - break; - case "Settings": - var viewModel = (SettingsPageViewModel)Activator.CreateInstance(typeof(SettingsPageViewModel)); - navView.Content = viewModel; - selectedNavVieItem = selectedItem; - break; - case "Update": - navView.Content = Activator.CreateInstance(typeof(UpdateViewModel)); - selectedNavVieItem = selectedItem; - break; - default: - // (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel)); - break; - } + if (sender is NavigationView{ SelectedItem: NavigationViewItem selectedItem } navView){ + switch (selectedItem.Tag){ + case "DownloadQueue": + navView.Content = Activator.CreateInstance(); + selectedNavVieItem = selectedItem; + break; + case "AddDownload": + navView.Content = Activator.CreateInstance(); + selectedNavVieItem = selectedItem; + break; + case "Calendar": + navView.Content = Activator.CreateInstance(); + selectedNavVieItem = selectedItem; + break; + case "History": + navView.Content = Activator.CreateInstance(); + navigationStack.Clear(); + navigationStack.Push(navView.Content); + selectedNavVieItem = selectedItem; + break; + case "Seasons": + navView.Content = Activator.CreateInstance(); + selectedNavVieItem = selectedItem; + break; + case "Account": + navView.Content = Activator.CreateInstance(); + selectedNavVieItem = selectedItem; + break; + case "Settings": + var viewModel = Activator.CreateInstance(); + navView.Content = viewModel; + selectedNavVieItem = selectedItem; + break; + case "Update": + navView.Content = Activator.CreateInstance(); + selectedNavVieItem = selectedItem; + break; } } } @@ -209,7 +206,7 @@ public partial class MainWindow : AppWindow{ if (settings != null){ var screens = Screens.All; if (settings.ScreenIndex >= 0 && settings.ScreenIndex < screens.Count){ - var screen = screens[settings.ScreenIndex]; + // var screen = screens[settings.ScreenIndex]; // Restore the position first Position = new PixelPoint(settings.PosX, settings.PosY); diff --git a/CRD/Views/SeriesPageView.axaml b/CRD/Views/SeriesPageView.axaml index 4174ebd..62292f7 100644 --- a/CRD/Views/SeriesPageView.axaml +++ b/CRD/Views/SeriesPageView.axaml @@ -161,6 +161,13 @@ + + + + + @@ -423,6 +459,38 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +