using System; 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; using Avalonia.Media; using Avalonia.Platform.Storage; using Avalonia.Styling; using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CRD.Downloader.Crunchyroll; using CRD.Utils; using CRD.Utils.Files; using CRD.Utils.Notifications; using CRD.Utils.Structs; using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.History; using CRD.Utils.Updater; using FluentAvalonia.Styling; using ProtoBuf.Meta; namespace CRD.Downloader; public sealed partial class ProgramManager : ObservableObject{ public static ProgramManager Instance{ get; } = new(); #region Observables [ObservableProperty] private bool fetchingData; [ObservableProperty] private bool updateAvailable = true; [ObservableProperty] private bool finishedLoading; [ObservableProperty] private bool navigationLock; #endregion public Dictionary> AnilistSeasons = new(); public Dictionary> AnilistUpcoming = new(); private readonly FluentAvaloniaTheme? _faTheme; #region Startup Param Variables private Queue> taskQueue = new Queue>(); bool historyRefreshAdded; private bool exitOnTaskFinish; #endregion private readonly PeriodicWorkRunner checkForNewEpisodesRunner; private bool historyRefreshNotificationsArmed; private static readonly TimeSpan TrackedSeriesReleaseOverlap = TimeSpan.FromMinutes(10); 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()){ switch (arg){ case "--historyRefreshAll": if (!historyRefreshAdded){ taskQueue.Enqueue(() => RefreshHistory(FilterType.All)); historyRefreshAdded = true; } break; case "--historyRefreshActive": if (!historyRefreshAdded){ taskQueue.Enqueue(() => RefreshHistory(FilterType.Active)); historyRefreshAdded = true; } break; case "--historyAddToQueue": taskQueue.Enqueue(AddMissingToQueue); break; case "--exit": exitOnTaskFinish = true; break; } } _ = Init(); CleanUpOldUpdater(); } internal async Task RefreshHistory(FilterType filterType){ FetchingData = true; List filteredItems; var historyList = CrunchyrollManager.Instance.HistoryList; switch (filterType){ case FilterType.All: filteredItems = historyList.ToList(); break; case FilterType.MissingEpisodes: filteredItems = historyList.Where(item => item.NewEpisodes > 0).ToList(); break; case FilterType.MissingEpisodesSonarr: filteredItems = historyList.Where(historySeries => !string.IsNullOrEmpty(historySeries.SonarrSeriesId) && historySeries.Seasons.Any(season => season.EpisodesList.Any(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile && (!CrunchyrollManager.Instance.CrunOptions.HistorySkipUnmonitored || historyEpisode.SonarrIsMonitored)))) .ToList(); break; case FilterType.ContinuingOnly: filteredItems = historyList.Where(item => !string.IsNullOrEmpty(item.SonarrNextAirDate)).ToList(); break; case FilterType.Active: filteredItems = historyList.Where(item => !item.IsInactive).ToList(); break; case FilterType.Inactive: filteredItems = historyList.Where(item => item.IsInactive).ToList(); break; default: filteredItems = new List(); break; } foreach (var item in filteredItems){ item.SetFetchingData(); } for (int i = 0; i < filteredItems.Count; i++){ await filteredItems[i].FetchData(""); filteredItems[i].UpdateNewEpisodes(); } FetchingData = false; CrunchyrollManager.Instance.History.SortItems(); await PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager.Instance); } private async Task AddMissingToQueue(){ var tasks = CrunchyrollManager.Instance.HistoryList .Select(item => item.AddNewMissingToDownloads()); await Task.WhenAll(tasks); while (QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){ Console.WriteLine("Waiting for downloads to complete..."); 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: await RefreshHistoryWithNewReleases(crunchyManager, crunOptions); break; default: return; } if (crunOptions.HistoryAutoRefreshAddToQueue){ var tasks = crunchyManager.HistoryList .Select(item => item.AddNewMissingToDownloads(true)); await Task.WhenAll(tasks); } if (Application.Current is App app){ Dispatcher.UIThread.Post(app.UpdateTrayTooltip); } historyRefreshNotificationsArmed = true; } internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){ var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes( string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang, 2000, null, true); var newEpisodes = newEpisodesBase?.Data ?? []; if (newEpisodesBase is{ Data.Count: > 0 }){ try{ await crunchyManager.History.UpdateWithEpisode(newEpisodes); CfgManager.UpdateHistoryFile(); } catch (Exception e){ Console.Error.WriteLine("Failed to update History: " + e.Message); } } await PublishTrackedSeriesReleaseNotificationsAsync(crunchyManager, newEpisodes); } private async Task PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager crunchyManager, List? releaseFeedEpisodes = null){ var currentCheckTimeUtc = DateTime.UtcNow; var settings = crunchyManager.CrunOptions; var previousCheckUtc = settings.TrackedSeriesReleaseLastCheckUtc; if (!historyRefreshNotificationsArmed){ settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc; CfgManager.WriteCrSettings(); return; } var trackedSeries = crunchyManager.HistoryList .Where(series => !string.IsNullOrWhiteSpace(series.SeriesId)) .ToDictionary(series => series.SeriesId!, StringComparer.Ordinal); if (trackedSeries.Count == 0){ settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc; CfgManager.WriteCrSettings(); return; } releaseFeedEpisodes ??= (await crunchyManager.CrEpisode.GetNewEpisodes( string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang, 2000, null, true))?.Data ?? []; var notificationSettings = settings.NotificationSettings; var historyUpdated = false; var windowStartUtc = previousCheckUtc?.Subtract(TrackedSeriesReleaseOverlap); foreach (var release in releaseFeedEpisodes){ var seriesId = release.EpisodeMetadata?.SeriesId; if (string.IsNullOrWhiteSpace(seriesId) || !trackedSeries.TryGetValue(seriesId, out var historySeries)){ continue; } var releaseDateUtc = GetTrackedReleaseDateUtc(release); if (windowStartUtc.HasValue && releaseDateUtc < windowStartUtc.Value){ continue; } if (releaseDateUtc > currentCheckTimeUtc){ continue; } var historyEpisode = crunchyManager.History.GetHistoryEpisode(seriesId, release.EpisodeMetadata.SeasonId, release.Id ?? string.Empty); if (historyEpisode == null && !string.IsNullOrWhiteSpace(release.Id)){ historyEpisode = historySeries.Seasons .SelectMany(season => season.EpisodesList) .FirstOrDefault(episode => episode.EpisodeId == release.Id); } if (historyEpisode == null || historyEpisode.TrackedSeriesReleaseNotified){ continue; } var notificationSent = await NotificationPublisher.Instance.PublishTrackedSeriesEpisodeReleasedAsync( notificationSettings, historySeries, historyEpisode, release, string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang ); if (notificationSent){ historyEpisode.TrackedSeriesReleaseNotified = true; historyUpdated = true; } } settings.TrackedSeriesReleaseLastCheckUtc = currentCheckTimeUtc; if (historyUpdated){ CfgManager.UpdateHistoryFile(); } CfgManager.WriteCrSettings(); } private static DateTime GetTrackedReleaseDateUtc(CrBrowseEpisode episode){ DateTime episodeAirDate = episode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc ? episode.EpisodeMetadata.EpisodeAirDate.ToLocalTime() : episode.EpisodeMetadata.EpisodeAirDate; DateTime premiumAvailableStart = episode.EpisodeMetadata.PremiumAvailableDate.Kind == DateTimeKind.Utc ? episode.EpisodeMetadata.PremiumAvailableDate.ToLocalTime() : episode.EpisodeMetadata.PremiumAvailableDate; DateTime now = DateTime.Now; DateTime oneYearFromNow = now.AddYears(1); var targetDate = premiumAvailableStart; if (targetDate >= oneYearFromNow){ DateTime freeAvailableStart = episode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc ? episode.EpisodeMetadata.FreeAvailableDate.ToLocalTime() : episode.EpisodeMetadata.FreeAvailableDate; if (freeAvailableStart <= oneYearFromNow){ targetDate = freeAvailableStart; } else{ targetDate = episodeAirDate; } } return targetDate.Kind == DateTimeKind.Utc ? targetDate : targetDate.ToUniversalTime(); } public void SetBackgroundImage(){ if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){ Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity, CrunchyrollManager.Instance.CrunOptions.BackgroundImageBlurRadius); } } private async Task Init(){ try{ CrunchyrollManager.Instance.InitOptions(); UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync(); await Updater.Instance.CheckGhJsonAsync(); 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; } } private async Task WorkOffArgsTasks(){ if (taskQueue.Count == 0){ return; } while (taskQueue.Count > 0){ var task = taskQueue.Dequeue(); await task(); // Execute the task asynchronously } Console.WriteLine("All tasks are completed."); if (exitOnTaskFinish){ Console.WriteLine("Exiting..."); IClassicDesktopStyleApplicationLifetime? lifetime = (IClassicDesktopStyleApplicationLifetime?)Application.Current?.ApplicationLifetime; if (lifetime != null){ lifetime.Shutdown(); } else{ Environment.Exit(0); } } } private void CleanUpOldUpdater(){ var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty; string backupFilePath = Path.Combine(Directory.GetCurrentDirectory(), $"Updater{executableExtension}.bak"); if (File.Exists(backupFilePath)){ try{ File.Delete(backupFilePath); Console.WriteLine($"Deleted old updater file: {backupFilePath}"); } catch (Exception ex){ Console.Error.WriteLine($"Failed to delete old updater file: {ex.Message}"); } } else{ 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(); } }