From ff3e28093ebfd4ebb4df04aa677757f7a586a7ac Mon Sep 17 00:00:00 2001 From: Elwador <75888166+Elwador@users.noreply.github.com> Date: Thu, 14 May 2026 21:49:57 +0200 Subject: [PATCH] - Added notification service for webhooks - Added retry delay for rate limit handling - Added toggle to control whether auto refresh also adds missing episodes to the queue - Added configurable delay after each dub download - Changed encoding preset dialog to show a preview of the FFmpeg command - Changed play sound on queue empty and execute file on completion to be handled by the notification service - Changed shutdown PC option to disable once triggered - Fixed crash with queue persistence - Fixed crash with audio player - Fixed subscription countdown on the account page --- CRD/Downloader/CalendarManager.cs | 2 +- CRD/Downloader/Crunchyroll/CRAuth.cs | 21 +- .../Crunchyroll/CrunchyrollManager.cs | 137 +++--- .../CrunchyrollSettingsViewModel.cs | 5 + .../Views/CrunchyrollSettingsView.axaml | 11 + CRD/Downloader/History.cs | 2 +- CRD/Downloader/ProgramManager.cs | 127 +++++- CRD/Downloader/QueueManager.cs | 128 ++++++ CRD/Utils/AudioPlayer.cs | 167 ++++++- CRD/Utils/DRM/Widevine.cs | 2 +- CRD/Utils/Helpers.cs | 77 ++-- CRD/Utils/Http/HttpClientReq.cs | 27 +- .../Notifications/INotificationProvider.cs | 10 + .../Notifications/NotificationDispatcher.cs | 51 +++ CRD/Utils/Notifications/NotificationEvent.cs | 16 + .../Notifications/NotificationEventType.cs | 10 + .../NotificationProviderConfig.cs | 37 ++ .../Notifications/NotificationProviderType.cs | 7 + .../Notifications/NotificationPublisher.cs | 178 ++++++++ .../Notifications/NotificationSettings.cs | 25 + .../Providers/ExecuteNotificationProvider.cs | 17 + .../Providers/SoundNotificationProvider.cs | 17 + .../Providers/WebhookNotificationProvider.cs | 71 +++ .../QueuePersistenceManager.cs | 11 +- .../Structs/Crunchyroll/CrDownloadOptions.cs | 62 +++ .../Crunchyroll/Episode/EpisodeStructs.cs | 21 + CRD/Utils/Structs/Crunchyroll/StreamLimits.cs | 16 +- CRD/Utils/Structs/HelperClasses.cs | 2 + CRD/Utils/Structs/History/HistoryEpisode.cs | 3 + CRD/Utils/Updater/Updater.cs | 10 +- CRD/ViewModels/AccountPageViewModel.cs | 76 ++-- CRD/ViewModels/DownloadsPageViewModel.cs | 53 ++- .../ContentDialogEncodingPresetViewModel.cs | 107 +++-- .../Utils/GeneralSettingsViewModel.cs | 426 +++++++++++++++++- .../ContentDialogEncodingPresetView.axaml | 28 ++ CRD/Views/Utils/GeneralSettingsView.axaml | 267 ++++++++--- images/Settings_G_Notifications.png | 3 + 37 files changed, 1955 insertions(+), 275 deletions(-) create mode 100644 CRD/Utils/Notifications/INotificationProvider.cs create mode 100644 CRD/Utils/Notifications/NotificationDispatcher.cs create mode 100644 CRD/Utils/Notifications/NotificationEvent.cs create mode 100644 CRD/Utils/Notifications/NotificationEventType.cs create mode 100644 CRD/Utils/Notifications/NotificationProviderConfig.cs create mode 100644 CRD/Utils/Notifications/NotificationProviderType.cs create mode 100644 CRD/Utils/Notifications/NotificationPublisher.cs create mode 100644 CRD/Utils/Notifications/NotificationSettings.cs create mode 100644 CRD/Utils/Notifications/Providers/ExecuteNotificationProvider.cs create mode 100644 CRD/Utils/Notifications/Providers/SoundNotificationProvider.cs create mode 100644 CRD/Utils/Notifications/Providers/WebhookNotificationProvider.cs create mode 100644 images/Settings_G_Notifications.png diff --git a/CRD/Downloader/CalendarManager.cs b/CRD/Downloader/CalendarManager.cs index cb9f025..38dc121 100644 --- a/CRD/Downloader/CalendarManager.cs +++ b/CRD/Downloader/CalendarManager.cs @@ -82,7 +82,7 @@ public class CalendarManager{ request.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8"); request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br"); - (bool IsOk, string ResponseContent, string error) response; + (bool IsOk, string ResponseContent, string error, Dictionary Headers) response; if (!HttpClientReq.Instance.UseFlareSolverr){ response = await HttpClientReq.Instance.SendHttpRequest(request); } else{ diff --git a/CRD/Downloader/Crunchyroll/CRAuth.cs b/CRD/Downloader/Crunchyroll/CRAuth.cs index bc829e2..514a55d 100644 --- a/CRD/Downloader/Crunchyroll/CRAuth.cs +++ b/CRD/Downloader/Crunchyroll/CRAuth.cs @@ -13,6 +13,7 @@ using System.Web; using CRD.Utils; using CRD.Utils.Files; using CRD.Utils.Http; +using CRD.Utils.Notifications; using CRD.Utils.Structs; using CRD.Utils.Structs.Crunchyroll; using CRD.Views; @@ -22,6 +23,8 @@ using ReactiveUI; namespace CRD.Downloader.Crunchyroll; public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){ + private static readonly TimeSpan TokenRefreshBuffer = TimeSpan.FromSeconds(60); + public CrToken? Token; public CrProfile Profile = new(); public Subscription? Subscription{ get; set; } @@ -30,11 +33,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown; public CrAuthSettings AuthSettings = authSettings; - + public Dictionary cookieStore = new(); + private bool IsTokenExpiredOrNearExpiry(){ + return Token == null || DateTime.Now >= Token.expires - TokenRefreshBuffer; + } + public void Init(){ - Profile = new CrProfile{ Username = "???", Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png", @@ -406,7 +412,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings public async Task RefreshToken(bool needsToken){ if (EndpointEnum == CrunchyrollEndpoints.Guest){ - if (Token != null && !(DateTime.Now > Token.expires)){ + if (!IsTokenExpiredOrNearExpiry()){ return; } @@ -418,7 +424,7 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings Token.access_token != null && Token.refresh_token == null){ await AuthAnonymous(); } else{ - if (!(DateTime.Now > Token.expires) && needsToken){ + if (!IsTokenExpiredOrNearExpiry() && needsToken){ return; } } @@ -427,6 +433,8 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings return; } + var hadUserSession = !string.IsNullOrWhiteSpace(Token?.refresh_token) && !string.IsNullOrWhiteSpace(Profile.Username) && Profile.Username != "???"; + string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id; var formData = new Dictionary{ @@ -464,6 +472,9 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings JsonTokenToFileAndVariable(response.ResponseContent, uuid); } else{ Console.Error.WriteLine("Refresh Token Auth Failed"); + if (hadUserSession){ + await NotificationPublisher.Instance.PublishLoginExpiredAsync(crunInstance.CrunOptions.NotificationSettings, Profile.Username, AuthSettings.Endpoint); + } } } -} \ No newline at end of file +} diff --git a/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs b/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs index 1683e19..0c24de1 100644 --- a/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs +++ b/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs @@ -22,6 +22,7 @@ using CRD.Utils.Muxing; using CRD.Utils.Muxing.Fonts; using CRD.Utils.Muxing.Structs; using CRD.Utils.Muxing.Syncing; +using CRD.Utils.Notifications; using CRD.Utils.Parser; using CRD.Utils.Sonarr; using CRD.Utils.Sonarr.Models; @@ -78,7 +79,7 @@ public class CrunchyrollManager{ public CrMovies CrMovies; public CrMusic CrMusic; public CrQueue CrQueue; - + public History History; #region Singelton @@ -119,6 +120,7 @@ public class CrunchyrollManager{ options.Force = "Y"; options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]"; options.Partsize = 10; + options.DubDownloadDelaySeconds = 0; options.DlSubs = new List{ "en-US" }; options.SkipMuxing = false; options.MkvmergeOptions = []; @@ -131,6 +133,8 @@ public class CrunchyrollManager{ options.CcSubsFont = "Trebuchet MS"; options.RetryDelay = 5; options.RetryAttempts = 5; + options.PlaybackRateLimitRetryDelaySeconds = 30; + options.RetryMaxDelaySeconds = 3600; options.Numbers = 2; options.Timeout = 15000; options.DubLang = new List(){ "ja-JP" }; @@ -168,6 +172,7 @@ public class CrunchyrollManager{ options.HistoryAutoRefreshIntervalMinutes = 0; CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions); + options.NormalizeNotificationSettings(); return options; } @@ -254,8 +259,8 @@ public class CrunchyrollManager{ Device_type = DefaultAndroidTvAuthSettings.Device_type, UseDefault = true }; - - + + if (CrunOptions.StreamEndpoint.UseDefault){ var streamEndpointAuthorization = DefaultAndroidTvAuthSettings.Authorization; var streamEndpointUserAgent = DefaultAndroidTvAuthSettings.UserAgent; @@ -282,14 +287,14 @@ public class CrunchyrollManager{ CrunOptions.StreamEndpoint.Device_name = DefaultAndroidTvAuthSettings.Device_name; CrunOptions.StreamEndpoint.Device_type = DefaultAndroidTvAuthSettings.Device_type; } - + CrunOptions.StreamEndpoint.Endpoint = "tv/android_tv"; CrAuthEndpoint1.AuthSettings = CrunOptions.StreamEndpoint; //---------- TV ---------- - - + + //---------- Mobile ---------- CrunOptions.StreamEndpointSecondSettings ??= new CrAuthSettings{ Authorization = DefaultAndroidAuthSettings.Authorization, @@ -298,7 +303,7 @@ public class CrunchyrollManager{ Device_type = DefaultAndroidAuthSettings.Device_type, UseDefault = true }; - + if (CrunOptions.StreamEndpointSecondSettings.UseDefault){ var streamEndpointSecondAuthorization = DefaultAndroidAuthSettings.Authorization; var streamEndpointSecondUserAgent = DefaultAndroidAuthSettings.UserAgent; @@ -329,7 +334,7 @@ public class CrunchyrollManager{ CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings; //---------- Mobile ---------- - + await CrAuthEndpoint1.Auth(); if (!string.IsNullOrEmpty(CrAuthEndpoint2.AuthSettings.Endpoint)){ await CrAuthEndpoint2.Auth(); @@ -408,12 +413,14 @@ public class CrunchyrollManager{ QueueManager.Instance.ReleaseDownloadSlot(data); } + int retryAttemptCount = data.DownloadProgress.RetryAttemptCount; data.DownloadProgress = new DownloadProgress(){ State = DownloadState.Downloading, Percent = 0, Time = 0, DownloadSpeedBytes = 0, - Doing = "Starting" + Doing = "Starting", + RetryAttemptCount = retryAttemptCount }; QueueManager.Instance.RefreshQueue(); var res = new DownloadResponse(); @@ -424,6 +431,19 @@ public class CrunchyrollManager{ res.Error = true; } + if (res.RetrySuggested){ + ReleaseDownloadSlotIfHeld(); + var retryDelay = TimeSpan.FromSeconds(Math.Max(1, res.RetryDelaySeconds)); + QueueManager.Instance.BlockAutoDownloadUntil(retryDelay, data.Cts.Token); + QueueManager.Instance.ScheduleRetry( + data, + retryDelay, + $"Rate limited by playback API. Retrying in {(int)retryDelay.TotalSeconds}s", + data.Cts.Token); + return false; + } + + data.DownloadProgress.ClearRetryState(); if (res.Error){ ReleaseDownloadSlotIfHeld(); @@ -435,6 +455,7 @@ public class CrunchyrollManager{ Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""), }; QueueManager.Instance.RefreshQueue(); + await NotificationPublisher.Instance.PublishDownloadFailedAsync(CrunOptions.NotificationSettings, data, res.ErrorText); return false; } @@ -671,13 +692,13 @@ public class CrunchyrollManager{ } - data.DownloadProgress = new DownloadProgress(){ - State = DownloadState.Done, - Percent = 100, - Time = 0, - DownloadSpeedBytes = 0, - Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") + (fallbackUsed ? " - Used full-quality fallback" : "") - }; + data.DownloadProgress = new DownloadProgress(){ + State = DownloadState.Done, + Percent = 100, + Time = 0, + DownloadSpeedBytes = 0, + Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "") + (fallbackUsed ? " - Used full-quality fallback" : "") + }; QueueManager.Instance.MarkDownloadFinished(data, CrunOptions.RemoveFinishedDownload && !syncError); } else{ @@ -714,7 +735,7 @@ public class CrunchyrollManager{ } } - + QueueManager.Instance.RefreshQueue(); if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){ @@ -726,31 +747,14 @@ public class CrunchyrollManager{ _ = CrEpisode.MarkAsWatched(data.Data.First().MediaId); } - if (!QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){ - 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); - } - } + await NotificationPublisher.Instance.PublishDownloadFinishedAsync(CrunOptions.NotificationSettings, data); - 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 (!QueueManager.Instance.Queue.Any(e => !e.DownloadProgress.IsFinished)){ + await NotificationPublisher.Instance.PublishQueueFinishedAsync(CrunOptions.NotificationSettings, data); if (CrunOptions.ShutdownWhenQueueEmpty){ + CrunOptions.ShutdownWhenQueueEmpty = false; + CfgManager.WriteCrSettings(); Helpers.ShutdownComputer(); } } @@ -835,7 +839,8 @@ public class CrunchyrollManager{ #endregion - private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs, List failedSyncLocales)> MuxStreams(List data, CrunchyMuxOptions options, string filename, CrunchyEpMeta crunchyEpMeta){ + private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs, List failedSyncLocales)> MuxStreams(List data, CrunchyMuxOptions options, string filename, + CrunchyEpMeta crunchyEpMeta){ var muxToMp3 = false; if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){ @@ -1235,16 +1240,16 @@ public class CrunchyrollManager{ if (data.Data is{ Count: > 0 }){ options.Partsize = options.Partsize > 0 ? options.Partsize : 1; - var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty ,data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty); + var historyEpisode = History.GetHistoryEpisode(data.SeriesId ?? string.Empty, data.SeasonId ?? string.Empty, data.EpisodeId ?? string.Empty); SonarrEpisode? sonarrEpisode; if (historyEpisode != null && CrunOptions.SonarrProperties?.SonarrEnabled == true){ sonarrEpisode = await SonarrClient.Instance.GetEpisode(Convert.ToInt32(historyEpisode.SonarrEpisodeId)); - if(sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId); + if (sonarrEpisode is{ Series: null }) sonarrEpisode.Series = await SonarrClient.Instance.GetSeries(sonarrEpisode.SeriesId); variables.Add(new Variable("sonarrSeriesTitle", sonarrEpisode?.Series?.Title ?? string.Empty, true)); variables.Add(new Variable("sonarrSeriesReleaseYear", sonarrEpisode?.Series?.Year ?? 0, true)); variables.Add(new Variable("sonarrEpisodeTitle", sonarrEpisode?.Title ?? string.Empty, true)); } - + if (options.DownloadDescriptionAudio){ var alreadyAdr = new HashSet( data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err") @@ -1288,7 +1293,16 @@ public class CrunchyrollManager{ data.Data = sortedMetaData; + var epMetaIndex = 0; foreach (CrunchyEpMetaData epMeta in data.Data){ + if (epMetaIndex > 0 && options.DubDownloadDelaySeconds > 0){ + var delay = TimeSpan.FromSeconds(options.DubDownloadDelaySeconds); + data.DownloadProgress.Doing = $"Waiting {options.DubDownloadDelaySeconds}s before next dub"; + QueueManager.Instance.RefreshQueue(); + await Task.Delay(delay, data.Cts.Token); + } + + epMetaIndex++; Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}"); string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId); @@ -1365,8 +1379,8 @@ public class CrunchyrollManager{ #endregion - (bool IsOk, PlaybackData pbData, string error) fetchPlaybackData = default; - (bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default; + (bool IsOk, PlaybackData pbData, string error, Dictionary Headers) fetchPlaybackData = default; + (bool IsOk, PlaybackData pbData, string error, Dictionary Headers) fetchPlaybackData2 = default; if (CrAuthEndpoint1.Profile.Username != "???" && options.StreamEndpoint != null && (options.StreamEndpoint.Video || options.StreamEndpoint.Audio)){ fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription, options.StreamEndpoint); @@ -1377,7 +1391,9 @@ public class CrunchyrollManager{ } if (!fetchPlaybackData.IsOk && !fetchPlaybackData2.IsOk){ - var errorJson = fetchPlaybackData.error; + var errorJson = !string.IsNullOrEmpty(fetchPlaybackData.error) + ? fetchPlaybackData.error + : fetchPlaybackData2.error; if (!string.IsNullOrEmpty(errorJson)){ var error = StreamError.FromJson(errorJson); @@ -1402,12 +1418,21 @@ public class CrunchyrollManager{ }; } - if (error?.IsRateLimitError() == true || errorJson.Contains("4294")){ - CrunOptions.AutoDownload = false; - MainWindow.Instance.ShowError("Couldn't get Playback Data\nRate limit reached. Auto Download has been stopped."); + if (error?.IsPlaybackRateLimitError() == true){ + int retryDelaySeconds = Helpers.GetRetryDelaySeconds(options, data.DownloadProgress.RetryAttemptCount); + + if (fetchPlaybackData.Headers != null && + fetchPlaybackData.Headers.TryGetValue("retry-after", out var retryAfter) && + int.TryParse(retryAfter, out var parsedRetryAfter)){ + Console.WriteLine($"Retry after: {parsedRetryAfter} seconds"); + retryDelaySeconds = parsedRetryAfter; + } + + Console.Error.WriteLine($"Playback API rate limited with 4294. Requeueing download in {retryDelaySeconds}s."); return new DownloadResponse{ Data = new List(), - Error = true, + RetrySuggested = true, + RetryDelaySeconds = retryDelaySeconds, FileName = "./unknown", ErrorText = "Rate limit error" }; @@ -1907,7 +1932,8 @@ public class CrunchyrollManager{ string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : ""); string tempFile = Path.Combine(FileNameManager - .ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}{data.TempFileSuffix ?? string.Empty}", variables, options.Numbers, options.FileNameWhitespaceSubstitute, + .ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}{data.TempFileSuffix ?? string.Empty}", variables, options.Numbers, + options.FileNameWhitespaceSubstitute, options.Override) .ToArray()); string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(fileDir, tempFile); @@ -2715,7 +2741,7 @@ public class CrunchyrollManager{ #region Fetch Playback Data - private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc, + private async Task<(bool IsOk, PlaybackData pbData, string error, Dictionary Headers)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc, CrAuthSettings optionsStreamEndpointSettings){ var temppbData = new PlaybackData{ Total = 0, @@ -2749,16 +2775,17 @@ public class CrunchyrollManager{ } } - return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent); + return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent, playbackRequestResponse.Headers); } - private async Task<(bool IsOk, string ResponseContent, string error)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){ + private async Task<(bool IsOk, string ResponseContent, string error, Dictionary Headers)> SendPlaybackRequestAsync(string endpoint, CrAuth authEndpoint){ var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, authEndpoint.Token?.access_token, null); request.Headers.UserAgent.ParseAdd(authEndpoint.AuthSettings.UserAgent); return await HttpClientReq.Instance.SendHttpRequest(request, false, authEndpoint.cookieStore); } - private async Task<(bool IsOk, string ResponseContent, string error)> HandleStreamErrorsAsync((bool IsOk, string ResponseContent, string error) response, string endpoint, CrAuth authEndpoint){ + private async Task<(bool IsOk, string ResponseContent, string error, Dictionary Headers)> HandleStreamErrorsAsync( + (bool IsOk, string ResponseContent, string error, Dictionary Headers) response, string endpoint, CrAuth authEndpoint){ if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response; var error = StreamError.FromJson(response.ResponseContent); diff --git a/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs b/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs index 81fc4f1..a203653 100644 --- a/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs +++ b/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs @@ -119,6 +119,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{ [ObservableProperty] private double? _partSize; + [ObservableProperty] + private double? _dubDownloadDelaySeconds; + [ObservableProperty] private string _fileName = ""; @@ -474,6 +477,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{ DefaultSubForcedDisplay = options.DefaultSubForcedDisplay; DefaultSubSigns = options.DefaultSubSigns; PartSize = options.Partsize; + DubDownloadDelaySeconds = options.DubDownloadDelaySeconds; IncludeEpisodeDescription = options.IncludeVideoDescription; FileTitle = options.VideoTitle ?? ""; IncludeSignSubs = options.IncludeSignsSubs; @@ -575,6 +579,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{ CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs; CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs; CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000); + CrunchyrollManager.Instance.CrunOptions.DubDownloadDelaySeconds = Math.Max((int)(DubDownloadDelaySeconds ?? 0), 0); CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic; CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection(); diff --git a/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml b/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml index d80b06a..f3e5f26 100644 --- a/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml +++ b/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml @@ -255,6 +255,17 @@ + + + + + + + diff --git a/CRD/Downloader/History.cs b/CRD/Downloader/History.cs index 338c20e..da578e5 100644 --- a/CRD/Downloader/History.cs +++ b/CRD/Downloader/History.cs @@ -864,7 +864,7 @@ public class History{ List failedEpisodes = []; Parallel.ForEach(allHistoryEpisodes, historyEpisode => { - if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){ + if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) || rematchAll){ // Create a copy of the episodes list for each thread var episodesCopy = new List(episodes); diff --git a/CRD/Downloader/ProgramManager.cs b/CRD/Downloader/ProgramManager.cs index 75de26e..40cb482 100644 --- a/CRD/Downloader/ProgramManager.cs +++ b/CRD/Downloader/ProgramManager.cs @@ -15,6 +15,7 @@ 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; @@ -60,6 +61,8 @@ public sealed partial class ProgramManager : ObservableObject{ #endregion private readonly PeriodicWorkRunner checkForNewEpisodesRunner; + private bool historyRefreshNotificationsArmed; + private static readonly TimeSpan TrackedSeriesReleaseOverlap = TimeSpan.FromMinutes(10); public IStorageProvider? StorageProvider; @@ -100,7 +103,6 @@ public sealed partial class ProgramManager : ObservableObject{ internal async Task RefreshHistory(FilterType filterType){ FetchingData = true; - List filteredItems; var historyList = CrunchyrollManager.Instance.HistoryList; @@ -149,6 +151,7 @@ public sealed partial class ProgramManager : ObservableObject{ FetchingData = false; CrunchyrollManager.Instance.History.SortItems(); + await PublishTrackedSeriesReleaseNotificationsAsync(CrunchyrollManager.Instance); } private async Task AddMissingToQueue(){ @@ -186,23 +189,27 @@ public sealed partial class ProgramManager : ObservableObject{ return; } - var tasks = crunchyManager.HistoryList - .Select(item => item.AddNewMissingToDownloads(true)); + if (crunOptions.HistoryAutoRefreshAddToQueue){ + var tasks = crunchyManager.HistoryList + .Select(item => item.AddNewMissingToDownloads(true)); - await Task.WhenAll(tasks); + 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); - if (newEpisodesBase is{ Data.Count: > 0 }){ - var newEpisodes = newEpisodesBase.Data ?? []; + var newEpisodes = newEpisodesBase?.Data ?? []; + if (newEpisodesBase is{ Data.Count: > 0 }){ try{ await crunchyManager.History.UpdateWithEpisode(newEpisodes); CfgManager.UpdateHistoryFile(); @@ -210,6 +217,114 @@ public sealed partial class ProgramManager : ObservableObject{ 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(){ diff --git a/CRD/Downloader/QueueManager.cs b/CRD/Downloader/QueueManager.cs index 3c6d634..4eaa652 100644 --- a/CRD/Downloader/QueueManager.cs +++ b/CRD/Downloader/QueueManager.cs @@ -5,6 +5,7 @@ using System.Collections.Specialized; using System.Linq; using System.Threading; using System.Threading.Tasks; +using Avalonia.Threading; using CommunityToolkit.Mvvm.ComponentModel; using CRD.Downloader.Crunchyroll; using CRD.Utils; @@ -37,6 +38,8 @@ public sealed partial class QueueManager : ObservableObject{ private int pumpScheduled; private int pumpDirty; + private DateTimeOffset? autoDownloadBlockedUntilUtc; + private readonly object autoDownloadBlockLock = new(); #endregion @@ -182,6 +185,17 @@ public sealed partial class QueueManager : ObservableObject{ queuePersistenceManager.SaveNow(); } + internal List GetQueueSnapshot(){ + if (Dispatcher.UIThread.CheckAccess()){ + return queue.ToList(); + } + + return Dispatcher.UIThread + .InvokeAsync(() => queue.ToList()) + .GetAwaiter() + .GetResult(); + } + public void ReplaceQueue(IEnumerable items){ uiMutationQueue.Enqueue(() => { queue.Clear(); @@ -190,6 +204,7 @@ public sealed partial class QueueManager : ObservableObject{ queue.Add(item); } + RestoreRetryStateFromQueue(); UpdateDownloadListItems(); }); } @@ -263,6 +278,20 @@ public sealed partial class QueueManager : ObservableObject{ private void PumpQueue(){ if (!crunchyrollManager.CrunOptions.AutoDownload) return; + + lock (autoDownloadBlockLock){ + if (autoDownloadBlockedUntilUtc.HasValue && !HasPendingRetryItems()){ + autoDownloadBlockedUntilUtc = null; + } + + if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value > DateTimeOffset.UtcNow){ + return; + } + + if (autoDownloadBlockedUntilUtc.HasValue){ + autoDownloadBlockedUntilUtc = null; + } + } List toStart = new(); List toResume = new(); @@ -282,6 +311,9 @@ public sealed partial class QueueManager : ObservableObject{ if (item.DownloadProgress.IsError) continue; + if (item.DownloadProgress.IsWaitingForRetry) + continue; + if (item.DownloadProgress.IsDone) continue; @@ -326,6 +358,102 @@ public sealed partial class QueueManager : ObservableObject{ OnQueueStateChanged(); } + public void BlockAutoDownloadUntil(TimeSpan delay, CancellationToken cancellationToken = default){ + DateTimeOffset unblockAt = DateTimeOffset.UtcNow.Add(delay); + + lock (autoDownloadBlockLock){ + if (!autoDownloadBlockedUntilUtc.HasValue || unblockAt > autoDownloadBlockedUntilUtc.Value){ + autoDownloadBlockedUntilUtc = unblockAt; + } else{ + unblockAt = autoDownloadBlockedUntilUtc.Value; + } + } + + _ = Task.Run(async () => { + try{ + var remaining = unblockAt - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.Zero){ + await Task.Delay(remaining, cancellationToken); + } + + lock (autoDownloadBlockLock){ + if (autoDownloadBlockedUntilUtc.HasValue && autoDownloadBlockedUntilUtc.Value <= DateTimeOffset.UtcNow){ + autoDownloadBlockedUntilUtc = null; + } + } + + RefreshQueue(); + UpdateDownloadListItems(); + } catch (OperationCanceledException){ + // ignored + } + }, cancellationToken); + } + + public void ScheduleRetry(CrunchyEpMeta item, TimeSpan delay, string statusText, CancellationToken cancellationToken = default){ + item.DownloadProgress.ScheduleRetry(delay, statusText); + RefreshQueue(); + OnQueueStateChanged(); + + ScheduleRetryWake(item, item.DownloadProgress.RetryAtUtc, cancellationToken); + } + + private void RestoreRetryStateFromQueue(){ + var retryItems = queue + .Where(item => item.DownloadProgress.IsWaitingForRetry) + .ToList(); + + if (retryItems.Count == 0){ + lock (autoDownloadBlockLock){ + autoDownloadBlockedUntilUtc = null; + } + + return; + } + + var maxRetryAt = retryItems + .Select(item => item.DownloadProgress.RetryAtUtc) + .OfType() + .Max(); + + lock (autoDownloadBlockLock){ + autoDownloadBlockedUntilUtc = maxRetryAt; + } + + foreach (var retryItem in retryItems){ + ScheduleRetryWake(retryItem, retryItem.DownloadProgress.RetryAtUtc); + } + } + + private bool HasPendingRetryItems(){ + return queue.Any(item => item.DownloadProgress.IsWaitingForRetry); + } + + private void ScheduleRetryWake(CrunchyEpMeta item, DateTimeOffset? retryAtUtc, CancellationToken cancellationToken = default){ + if (!retryAtUtc.HasValue){ + return; + } + + _ = Task.Run(async () => { + try{ + var remaining = retryAtUtc.Value - DateTimeOffset.UtcNow; + if (remaining > TimeSpan.Zero){ + await Task.Delay(remaining, cancellationToken); + } + + if (cancellationToken.IsCancellationRequested){ + return; + } + + item.DownloadProgress.RetryAtUtc = null; + RefreshQueue(); + UpdateDownloadListItems(); + } catch (OperationCanceledException){ + // ignored + } + }, cancellationToken); + } + private void OnQueueStateChanged(){ QueueStateChanged?.Invoke(this, EventArgs.Empty); diff --git a/CRD/Utils/AudioPlayer.cs b/CRD/Utils/AudioPlayer.cs index 06e0309..9c90627 100644 --- a/CRD/Utils/AudioPlayer.cs +++ b/CRD/Utils/AudioPlayer.cs @@ -1,29 +1,178 @@ -using System; +using System; +using System.IO; +using System.Threading.Tasks; using NetCoreAudio; +using NAudio.Wave; namespace CRD.Utils; public class AudioPlayer{ private readonly Player _player; private bool _isPlaying; + private WaveOutEvent? _waveOut; + private AudioFileReader? _audioFileReader; + private TaskCompletionSource? _playbackCompleted; public AudioPlayer(){ _player = new Player(); } - public async void Play(string path){ + public static (bool IsValid, string ErrorMessage) ValidateSoundFile(string path){ + if (string.IsNullOrWhiteSpace(path)){ + return (false, "The selected sound file path is empty."); + } + + if (!File.Exists(path)){ + return (false, "The selected sound file does not exist."); + } + + try{ + using var stream = File.Open(path, FileMode.Open, FileAccess.Read, FileShare.Read); + if (stream.Length == 0){ + return (false, "The selected sound file is empty."); + } + } catch (Exception exception){ + return (false, $"The selected sound file could not be opened: {exception.Message}"); + } + + if (string.IsNullOrWhiteSpace(Path.GetExtension(path))){ + return (false, "The selected sound file has no file extension."); + } + + return (true, string.Empty); + } + + public async Task<(bool IsSuccess, string ErrorMessage)> ValidatePlaybackAsync(string path){ + var fileValidation = ValidateSoundFile(path); + if (!fileValidation.IsValid){ + return (false, fileValidation.ErrorMessage); + } + + if (_isPlaying){ + return (false, "Audio playback is already in progress."); + } + + if (OperatingSystem.IsWindows()){ + try{ + _isPlaying = true; + DisposeWindowsPlayback(); + + _audioFileReader = new AudioFileReader(path); + _waveOut = new WaveOutEvent(); + _playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _waveOut.PlaybackStopped += (_, args) => { + if (args.Exception != null){ + _playbackCompleted?.TrySetException(args.Exception); + } else{ + _playbackCompleted?.TrySetResult(); + } + }; + + _waveOut.Init(_audioFileReader); + _waveOut.Play(); + await _playbackCompleted.Task; + return (true, string.Empty); + } catch (Exception exception){ + return (false, exception.Message); + } finally{ + DisposeWindowsPlayback(); + _isPlaying = false; + } + } + + try{ + _isPlaying = true; + await _player.Play(path); + return (true, string.Empty); + } catch (Exception exception){ + return (false, exception.Message); + } finally{ + _isPlaying = false; + } + } + + public async Task PlayAsync(string path){ + var fileValidation = ValidateSoundFile(path); + if (!fileValidation.IsValid){ + Console.Error.WriteLine($"Failed to play audio '{path}': {fileValidation.ErrorMessage}"); + return; + } + if (_isPlaying){ Console.WriteLine("Audio is already playing, ignoring duplicate request."); return; } - _isPlaying = true; - await _player.Play(path); - _isPlaying = false; + if (OperatingSystem.IsWindows()){ + try{ + _isPlaying = true; + DisposeWindowsPlayback(); + + _audioFileReader = new AudioFileReader(path); + _waveOut = new WaveOutEvent(); + _playbackCompleted = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + + _waveOut.PlaybackStopped += (_, args) => { + if (args.Exception != null){ + _playbackCompleted?.TrySetException(args.Exception); + } else{ + _playbackCompleted?.TrySetResult(); + } + }; + + _waveOut.Init(_audioFileReader); + _waveOut.Play(); + + await _playbackCompleted.Task; + } catch (Exception exception){ + Console.Error.WriteLine($"Failed to play audio '{path}': {exception}"); + } finally{ + DisposeWindowsPlayback(); + _isPlaying = false; + } + return; + } + + try{ + _isPlaying = true; + await _player.Play(path); + } catch (Exception exception){ + Console.Error.WriteLine($"Failed to play audio '{path}': {exception.Message}"); + } finally{ + _isPlaying = false; + } } - public async void Stop(){ - await _player.Stop(); - _isPlaying = false; + public async Task StopAsync(){ + if (OperatingSystem.IsWindows()){ + try{ + _waveOut?.Stop(); + } catch (Exception exception){ + Console.Error.WriteLine($"Failed to stop audio playback: {exception}"); + } finally{ + DisposeWindowsPlayback(); + _isPlaying = false; + } + return; + } + + try{ + await _player.Stop(); + } catch (Exception exception){ + Console.Error.WriteLine($"Failed to stop audio playback: {exception}"); + } finally{ + _isPlaying = false; + } } -} \ No newline at end of file + + private void DisposeWindowsPlayback(){ + _playbackCompleted = null; + + _waveOut?.Dispose(); + _waveOut = null; + + _audioFileReader?.Dispose(); + _audioFileReader = null; + } +} diff --git a/CRD/Utils/DRM/Widevine.cs b/CRD/Utils/DRM/Widevine.cs index a683669..0a16c2e 100644 --- a/CRD/Utils/DRM/Widevine.cs +++ b/CRD/Utils/DRM/Widevine.cs @@ -114,7 +114,7 @@ public class Widevine{ // var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2); - var response = (IsOk: false, ResponseContent: "", error: ""); + var response = (IsOk: false, ResponseContent: "", error: "",Headers: new Dictionary()); for (var attempt = 0; attempt < 3 + 1; attempt++){ using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){ response = await HttpClientReq.Instance.SendHttpRequest(request); diff --git a/CRD/Utils/Helpers.cs b/CRD/Utils/Helpers.cs index f0e2f94..96a120f 100644 --- a/CRD/Utils/Helpers.cs +++ b/CRD/Utils/Helpers.cs @@ -24,6 +24,7 @@ using CRD.Utils.Http; using CRD.Utils.JsonConv; using CRD.Utils.Parser; using CRD.Utils.Structs; +using CRD.Utils.Structs.Crunchyroll; using FluentAvalonia.UI.Controls; using Microsoft.Win32; using Newtonsoft.Json; @@ -87,6 +88,19 @@ public class Helpers{ return clone; } + public static int GetRetryDelaySeconds(CrDownloadOptions options, int retryAttemptCount){ + return GetRetryDelaySeconds(options.PlaybackRateLimitRetryDelaySeconds, options.RetryMaxDelaySeconds, retryAttemptCount); + } + + public static int GetRetryDelaySeconds(int baseDelaySeconds, int maxDelaySeconds, int retryAttemptCount){ + int baseDelay = Math.Max(1, baseDelaySeconds); + int maxDelay = Math.Max(baseDelay, maxDelaySeconds); + + int attempt = Math.Max(0, retryAttemptCount); + double delay = baseDelay * Math.Pow(2, attempt); + return (int)Math.Min(maxDelay, delay); + } + public static T? DeepCopy(T obj){ var settings = new JsonSerializerSettings{ ContractResolver = new DefaultContractResolver{ @@ -376,7 +390,7 @@ public class Helpers{ } } - private static IEnumerable GetQualityOption(VideoPreset preset){ + public static IEnumerable GetQualityOption(VideoPreset preset){ if (preset.Crf is -1) return []; @@ -389,14 +403,44 @@ public class Helpers{ "h264_qsv" or "hevc_qsv" => preset.Crf is >= 1 and <= 51 ? ["-global_quality", q] : [], - "h264_amf" or "hevc_amf" - => preset.Crf is >= 0 and <= 51 ? ["-qp", q] : [], + "h264_amf" + => preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q, "-qp_b", q] : [], + + "hevc_amf" + => preset.Crf is >= 0 and <= 51 ? ["-rc", "cqp", "-qp_i", q, "-qp_p", q] : [], _ // libx264/libx265/etc. => preset.Crf >= 0 ? ["-crf", q] : [] }; } + public static List BuildFFmpegArgsForPreset(string inputFilePath, VideoPreset preset, string outputFilePath){ + 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)); + + args.Add("-vf"); + args.Add($"scale={preset.Resolution},fps={preset.FrameRate}"); + } + + foreach (var param in preset.AdditionalParameters){ + args.AddRange(SplitArguments(param)); + } + + args.Add(outputFilePath); + + return args; + } + public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync( string inputFilePath, VideoPreset preset, @@ -409,30 +453,7 @@ public class Helpers{ string tempOutput = Path.Combine(dir, $"{name}_output{ext}"); TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath); - - 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)); - - args.Add("-vf"); - args.Add($"scale={preset.Resolution},fps={preset.FrameRate}"); - } - - - foreach (var param in preset.AdditionalParameters){ - args.AddRange(SplitArguments(param)); - } - - args.Add(tempOutput); + var args = BuildFFmpegArgsForPreset(inputFilePath, preset, tempOutput); string commandString = BuildCommandString(CfgManager.PathFFMPEG, args); int exitCode; @@ -508,7 +529,7 @@ public class Helpers{ return args; } - private static string BuildCommandString(string exe, IEnumerable args){ + public static string BuildCommandString(string exe, IEnumerable args){ static string Quote(string s){ if (string.IsNullOrWhiteSpace(s)) return "\"\""; diff --git a/CRD/Utils/Http/HttpClientReq.cs b/CRD/Utils/Http/HttpClientReq.cs index 80851e7..af41c5f 100644 --- a/CRD/Utils/Http/HttpClientReq.cs +++ b/CRD/Utils/Http/HttpClientReq.cs @@ -112,9 +112,10 @@ public class HttpClientReq{ return handler; } - public async Task<(bool IsOk, string ResponseContent, string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary? cookieStore = null, + public async Task<(bool IsOk, string ResponseContent, string error, Dictionary Headers)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary? cookieStore = null, bool allowChallengeBypass = true){ string content = string.Empty; + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); try{ if (request.RequestUri?.ToString() != ApiUrls.WidevineLicenceUrl){ AttachCookies(request, cookieStore); @@ -131,7 +132,7 @@ public class HttpClientReq{ retryRequest, GetCookiesForRequest(cookieStore)); if (!solverResult.IsOk){ - return (false, solverResult.ResponseContent, "Challenge bypass failed"); + return (false, solverResult.ResponseContent, "Challenge bypass failed",headers); } // foreach (var cookie in solverResult.Cookies){ @@ -139,30 +140,36 @@ public class HttpClientReq{ // AddCookie(cookie.Domain, cookie, cookieStore); // } - return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), ""); + return (true, ExtractJsonFromBrowserHtml(solverResult.ResponseContent), "",headers); } - return (false, content, "Cloudflare challenge detected"); + return (false, content, "Cloudflare challenge detected",headers); } content = await response.Content.ReadAsStringAsync(); + + foreach (var header in response.Headers) + headers[header.Key] = string.Join(", ", header.Value); + + foreach (var header in response.Content.Headers) + headers[header.Key] = string.Join(", ", header.Value); response.EnsureSuccessStatusCode(); CaptureResponseCookies(response, request.RequestUri!, cookieStore); - return (IsOk: true, ResponseContent: content, error: ""); + return (IsOk: true, ResponseContent: content, error: "",headers); } catch (Exception e){ if (!suppressError){ Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}"); } - return (IsOk: false, ResponseContent: content, error: ""); + return (IsOk: false, ResponseContent: content, error: "",headers); } } - public async Task<(bool IsOk, string ResponseContent, string error)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){ - if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured"); + public async Task<(bool IsOk, string ResponseContent, string error, Dictionary Headers)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){ + if (flareSolverrClient == null) return (IsOk: false, ResponseContent: "", error: "No Flare Solverr client has been configured",[]); string content = string.Empty; try{ var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []); @@ -170,13 +177,13 @@ public class HttpClientReq{ content = flareSolverrResponses.ResponseContent; - return (flareSolverrResponses.IsOk, ResponseContent: content, error: ""); + return (flareSolverrResponses.IsOk, ResponseContent: content, error: "",[]); } catch (Exception e){ if (!suppressError){ Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}"); } - return (IsOk: false, ResponseContent: content, error: ""); + return (IsOk: false, ResponseContent: content, error: "",[]); } } diff --git a/CRD/Utils/Notifications/INotificationProvider.cs b/CRD/Utils/Notifications/INotificationProvider.cs new file mode 100644 index 0000000..deb7f4d --- /dev/null +++ b/CRD/Utils/Notifications/INotificationProvider.cs @@ -0,0 +1,10 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace CRD.Utils.Notifications; + +public interface INotificationProvider{ + NotificationProviderType Type{ get; } + + Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default); +} diff --git a/CRD/Utils/Notifications/NotificationDispatcher.cs b/CRD/Utils/Notifications/NotificationDispatcher.cs new file mode 100644 index 0000000..b9908c2 --- /dev/null +++ b/CRD/Utils/Notifications/NotificationDispatcher.cs @@ -0,0 +1,51 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using CRD.Utils.Notifications.Providers; + +namespace CRD.Utils.Notifications; + +public class NotificationDispatcher{ + public static NotificationDispatcher Instance{ get; } = new(); + + private readonly IReadOnlyDictionary providers; + + private NotificationDispatcher(){ + var providerInstances = new INotificationProvider[]{ + new SoundNotificationProvider(), + new ExecuteNotificationProvider(), + new WebhookNotificationProvider() + }; + + providers = providerInstances.ToDictionary(provider => provider.Type); + } + + public async Task PublishAsync(NotificationSettings? settings, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){ + await PublishWithResultAsync(settings, notificationEvent, cancellationToken); + } + + public async Task PublishWithResultAsync(NotificationSettings? settings, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){ + if (settings?.Providers == null || settings.Providers.Count == 0){ + return false; + } + + var sentSuccessfully = false; + + foreach (var config in settings.Providers.Where(provider => provider.Enabled && provider.Handles(notificationEvent.Type))){ + if (!providers.TryGetValue(config.Type, out var provider)){ + continue; + } + + try{ + await provider.SendAsync(config, notificationEvent, cancellationToken); + sentSuccessfully = true; + } catch (Exception exception){ + Console.Error.WriteLine($"Failed to send {config.Type} notification: {exception}"); + } + } + + return sentSuccessfully; + } +} diff --git a/CRD/Utils/Notifications/NotificationEvent.cs b/CRD/Utils/Notifications/NotificationEvent.cs new file mode 100644 index 0000000..3b75e0e --- /dev/null +++ b/CRD/Utils/Notifications/NotificationEvent.cs @@ -0,0 +1,16 @@ +using System; +using System.Collections.Generic; + +namespace CRD.Utils.Notifications; + +public class NotificationEvent{ + public NotificationEventType Type{ get; set; } + + public string Title{ get; set; } = string.Empty; + + public string Message{ get; set; } = string.Empty; + + public DateTimeOffset TimestampUtc{ get; set; } = DateTimeOffset.UtcNow; + + public Dictionary Metadata{ get; set; } = []; +} diff --git a/CRD/Utils/Notifications/NotificationEventType.cs b/CRD/Utils/Notifications/NotificationEventType.cs new file mode 100644 index 0000000..1d9c1ee --- /dev/null +++ b/CRD/Utils/Notifications/NotificationEventType.cs @@ -0,0 +1,10 @@ +namespace CRD.Utils.Notifications; + +public enum NotificationEventType{ + QueueFinished, + DownloadFinished, + DownloadFailed, + TrackedSeriesEpisodeReleased, + LoginExpired, + UpdateAvailable +} diff --git a/CRD/Utils/Notifications/NotificationProviderConfig.cs b/CRD/Utils/Notifications/NotificationProviderConfig.cs new file mode 100644 index 0000000..c2b0bc5 --- /dev/null +++ b/CRD/Utils/Notifications/NotificationProviderConfig.cs @@ -0,0 +1,37 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Notifications; + +public class NotificationProviderConfig{ + [JsonProperty("type")] + public NotificationProviderType Type{ get; set; } + + [JsonProperty("enabled")] + public bool Enabled{ get; set; } + + [JsonProperty("events")] + public List Events{ get; set; } = []; + + [JsonProperty("path")] + public string Path{ get; set; } = string.Empty; + + [JsonProperty("url")] + public string Url{ get; set; } = string.Empty; + + [JsonProperty("method")] + public string Method{ get; set; } = "POST"; + + [JsonProperty("headers")] + public Dictionary Headers{ get; set; } = []; + + [JsonProperty("content_type")] + public string ContentType{ get; set; } = "application/json"; + + [JsonProperty("body_template")] + public string BodyTemplate{ get; set; } = string.Empty; + + public bool Handles(NotificationEventType eventType){ + return Events.Count == 0 || Events.Contains(eventType); + } +} diff --git a/CRD/Utils/Notifications/NotificationProviderType.cs b/CRD/Utils/Notifications/NotificationProviderType.cs new file mode 100644 index 0000000..7a34706 --- /dev/null +++ b/CRD/Utils/Notifications/NotificationProviderType.cs @@ -0,0 +1,7 @@ +namespace CRD.Utils.Notifications; + +public enum NotificationProviderType{ + Sound, + Execute, + Webhook +} diff --git a/CRD/Utils/Notifications/NotificationPublisher.cs b/CRD/Utils/Notifications/NotificationPublisher.cs new file mode 100644 index 0000000..91ae978 --- /dev/null +++ b/CRD/Utils/Notifications/NotificationPublisher.cs @@ -0,0 +1,178 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading.Tasks; +using CRD.Utils.Structs; +using CRD.Utils.Structs.History; +using CRD.Utils.Structs.Crunchyroll; + +namespace CRD.Utils.Notifications; + +public class NotificationPublisher{ + public static NotificationPublisher Instance{ get; } = new(); + + private bool loginExpiredNotificationSent; + private string notifiedUpdateVersion = string.Empty; + + public Task PublishDownloadFailedAsync(NotificationSettings? settings, CrunchyEpMeta data, string? error = null){ + return NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{ + Type = NotificationEventType.DownloadFailed, + Title = "Download failed", + Message = string.IsNullOrWhiteSpace(error) + ? $"Failed to download {data.SeriesTitle ?? data.EpisodeTitle ?? "item"}." + : $"Failed to download {data.SeriesTitle ?? data.EpisodeTitle ?? "item"}: {error}", + Metadata = BuildMetadata(data, error) + }); + } + + public Task PublishDownloadFinishedAsync(NotificationSettings? settings, CrunchyEpMeta data){ + return NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{ + Type = NotificationEventType.DownloadFinished, + Title = "Download finished", + Message = $"Finished processing {data.SeriesTitle ?? data.EpisodeTitle ?? "item"}.", + Metadata = BuildMetadata(data) + }); + } + + public Task PublishQueueFinishedAsync(NotificationSettings? settings, CrunchyEpMeta data){ + return NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{ + Type = NotificationEventType.QueueFinished, + Title = "Downloads finished", + Message = "All queued downloads have finished processing.", + Metadata = [] + }); + } + + public async Task PublishLoginExpiredAsync(NotificationSettings? settings, string? username, string? endpoint){ + if (loginExpiredNotificationSent){ + return; + } + + loginExpiredNotificationSent = true; + + await NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{ + Type = NotificationEventType.LoginExpired, + Title = "Crunchyroll login expired", + Message = "The saved Crunchyroll session could not be refreshed. Please log in again.", + Metadata = new Dictionary{ + ["username"] = username ?? string.Empty, + ["endpoint"] = endpoint ?? string.Empty + } + }); + } + + public void ResetLoginExpiredNotification(){ + loginExpiredNotificationSent = false; + } + + public async Task PublishUpdateAvailableAsync(NotificationSettings? settings, string currentVersion, string latestVersion, string platformName, string downloadUrl){ + if (string.Equals(notifiedUpdateVersion, latestVersion, StringComparison.OrdinalIgnoreCase)){ + return; + } + + notifiedUpdateVersion = latestVersion; + + await NotificationDispatcher.Instance.PublishAsync(settings, new NotificationEvent{ + Type = NotificationEventType.UpdateAvailable, + Title = "Update available", + Message = $"Version {latestVersion} is available. Current version: {currentVersion}.", + Metadata = new Dictionary{ + ["currentVersion"] = currentVersion, + ["latestVersion"] = latestVersion, + ["platform"] = platformName, + ["downloadUrl"] = downloadUrl + } + }); + } + + public Task PublishTrackedSeriesEpisodeReleasedAsync(NotificationSettings? settings, HistorySeries series, HistoryEpisode episode, CrBrowseEpisode? release = null, string? locale = null){ + var episodeUrl = BuildEpisodeUrl(release, episode, locale); + var imageUrl = release?.Images?.Thumbnail?.FirstOrDefault()?.FirstOrDefault()?.Source + ?? episode.ThumbnailImageUrl + ?? string.Empty; + var description = release?.Description + ?? episode.EpisodeDescription + ?? string.Empty; + var premiumAvailableDate = release?.EpisodeMetadata?.PremiumAvailableDate; + var durationMs = release?.EpisodeMetadata?.DurationMs ?? 0; + + return NotificationDispatcher.Instance.PublishWithResultAsync(settings, new NotificationEvent{ + Type = NotificationEventType.TrackedSeriesEpisodeReleased, + Title = "Tracked series episode released", + Message = string.IsNullOrWhiteSpace(series.SeriesTitle) + ? $"A tracked episode is available: {episode.EpisodeTitle ?? episode.EpisodeId ?? "Unknown episode"}." + : $"A tracked episode is available for {series.SeriesTitle}: {episode.EpisodeTitle ?? episode.EpisodeId ?? "Unknown episode"}.", + Metadata = new Dictionary{ + ["seriesTitle"] = series.SeriesTitle ?? string.Empty, + ["seriesId"] = series.SeriesId ?? string.Empty, + ["seasonId"] = release?.EpisodeMetadata?.SeasonId ?? string.Empty, + ["episodeTitle"] = episode.EpisodeTitle ?? string.Empty, + ["episodeId"] = episode.EpisodeId ?? string.Empty, + ["episodeNumber"] = episode.Episode ?? string.Empty, + ["seasonNumber"] = episode.EpisodeSeasonNum ?? string.Empty, + ["releaseDate"] = episode.EpisodeCrPremiumAirDate?.ToString("O") ?? string.Empty, + ["premiumAvailableDate"] = premiumAvailableDate?.ToString("O") ?? episode.EpisodeCrPremiumAirDate?.ToString("O") ?? string.Empty, + ["episodeUrl"] = episodeUrl, + ["imageUrl"] = imageUrl, + ["description"] = description, + ["durationMs"] = durationMs > 0 ? durationMs.ToString() : string.Empty, + ["availableDubs"] = string.Join(", ", episode.HistoryEpisodeAvailableDubLang ?? []), + ["availableSubs"] = string.Join(", ", episode.HistoryEpisodeAvailableSoftSubs ?? []) + } + }); + } + + public void ResetUpdateAvailableNotification(){ + notifiedUpdateVersion = string.Empty; + } + + private static Dictionary BuildMetadata(CrunchyEpMeta data, string? error = null){ + var metadata = new Dictionary{ + ["seriesTitle"] = data.SeriesTitle ?? string.Empty, + ["seasonTitle"] = data.SeasonTitle ?? string.Empty, + ["episodeTitle"] = data.EpisodeTitle ?? string.Empty, + ["episodeNumber"] = data.EpisodeNumber ?? string.Empty, + ["episodeId"] = data.EpisodeId ?? string.Empty, + ["downloadPath"] = data.DownloadPath ?? string.Empty, + ["seasonNumber"] = data.Season ?? string.Empty, + ["description"] = data.Description ?? string.Empty, + ["imageUrl"] = data.Image ?? string.Empty, + ["imageUrlLarge"] = data.ImageBig ?? string.Empty, + ["downloadSubs"] = string.Join(", ", data.DownloadSubs ?? []), + ["downloadDubs"] = string.Join(", ", data.SelectedDubs ?? []), + ["hardsub"] = data.Hslang ?? string.Empty, + }; + + if (!string.IsNullOrWhiteSpace(data.SeriesId)){ + metadata["seriesId"] = data.SeriesId; + } + + if (!string.IsNullOrWhiteSpace(data.SeasonId)){ + metadata["seasonId"] = data.SeasonId; + } + + if (!string.IsNullOrWhiteSpace(data.EpisodeId)){ + metadata["episodeUrl"] = $"https://www.crunchyroll.com/watch/{data.EpisodeId}"; + } + + if (!string.IsNullOrWhiteSpace(error)){ + metadata["error"] = error; + } + + return metadata; + } + + private static string BuildEpisodeUrl(CrBrowseEpisode? release, HistoryEpisode episode, string? locale){ + var episodeId = release?.Id ?? episode.EpisodeId; + if (string.IsNullOrWhiteSpace(episodeId)){ + return string.Empty; + } + + var normalizedLocale = string.IsNullOrWhiteSpace(locale) ? "en-US" : locale; + var slugTitle = release?.SlugTitle; + + return string.IsNullOrWhiteSpace(slugTitle) + ? $"https://www.crunchyroll.com/{normalizedLocale}/watch/{episodeId}" + : $"https://www.crunchyroll.com/{normalizedLocale}/watch/{episodeId}/{slugTitle}"; + } +} diff --git a/CRD/Utils/Notifications/NotificationSettings.cs b/CRD/Utils/Notifications/NotificationSettings.cs new file mode 100644 index 0000000..5f30745 --- /dev/null +++ b/CRD/Utils/Notifications/NotificationSettings.cs @@ -0,0 +1,25 @@ +using System.Collections.Generic; +using System.Linq; +using Newtonsoft.Json; + +namespace CRD.Utils.Notifications; + +public class NotificationSettings{ + [JsonProperty("providers")] + public List Providers{ get; set; } = []; + + public NotificationProviderConfig GetOrCreateProvider(NotificationProviderType type){ + var provider = Providers.FirstOrDefault(p => p.Type == type); + if (provider != null){ + provider.Events ??= []; + provider.Headers ??= []; + return provider; + } + + provider = new NotificationProviderConfig{ + Type = type + }; + Providers.Add(provider); + return provider; + } +} diff --git a/CRD/Utils/Notifications/Providers/ExecuteNotificationProvider.cs b/CRD/Utils/Notifications/Providers/ExecuteNotificationProvider.cs new file mode 100644 index 0000000..9bb1453 --- /dev/null +++ b/CRD/Utils/Notifications/Providers/ExecuteNotificationProvider.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace CRD.Utils.Notifications.Providers; + +public class ExecuteNotificationProvider : INotificationProvider{ + public NotificationProviderType Type => NotificationProviderType.Execute; + + public Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){ + if (string.IsNullOrWhiteSpace(config.Path)){ + return Task.CompletedTask; + } + + Helpers.ExecuteFile(config.Path); + return Task.CompletedTask; + } +} diff --git a/CRD/Utils/Notifications/Providers/SoundNotificationProvider.cs b/CRD/Utils/Notifications/Providers/SoundNotificationProvider.cs new file mode 100644 index 0000000..9254e64 --- /dev/null +++ b/CRD/Utils/Notifications/Providers/SoundNotificationProvider.cs @@ -0,0 +1,17 @@ +using System.Threading; +using System.Threading.Tasks; + +namespace CRD.Utils.Notifications.Providers; + +public class SoundNotificationProvider : INotificationProvider{ + public NotificationProviderType Type => NotificationProviderType.Sound; + + public Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){ + if (string.IsNullOrWhiteSpace(config.Path)){ + return Task.CompletedTask; + } + + var player = new AudioPlayer(); + return player.PlayAsync(config.Path); + } +} diff --git a/CRD/Utils/Notifications/Providers/WebhookNotificationProvider.cs b/CRD/Utils/Notifications/Providers/WebhookNotificationProvider.cs new file mode 100644 index 0000000..0ec9e76 --- /dev/null +++ b/CRD/Utils/Notifications/Providers/WebhookNotificationProvider.cs @@ -0,0 +1,71 @@ +using System; +using System.Collections.Generic; +using System.Net.Http; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using CRD.Utils.Http; +using Newtonsoft.Json; + +namespace CRD.Utils.Notifications.Providers; + +public class WebhookNotificationProvider : INotificationProvider{ + public NotificationProviderType Type => NotificationProviderType.Webhook; + + public async Task SendAsync(NotificationProviderConfig config, NotificationEvent notificationEvent, CancellationToken cancellationToken = default){ + if (string.IsNullOrWhiteSpace(config.Url)){ + return; + } + + using var request = new HttpRequestMessage(new HttpMethod(string.IsNullOrWhiteSpace(config.Method) ? "POST" : config.Method), config.Url); + + foreach (var header in config.Headers){ + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + var body = BuildBody(config, notificationEvent); + if (!string.IsNullOrEmpty(body)){ + request.Content = new StringContent(body, Encoding.UTF8, string.IsNullOrWhiteSpace(config.ContentType) ? "application/json" : config.ContentType); + } + + var response = await HttpClientReq.Instance.SendHttpRequest(request, suppressError: false); + if (!response.IsOk){ + throw new InvalidOperationException($"Webhook request failed for '{config.Url}'."); + } + } + + private static string BuildBody(NotificationProviderConfig config, NotificationEvent notificationEvent){ + if (!string.IsNullOrWhiteSpace(config.BodyTemplate)){ + return ApplyTemplate(config.BodyTemplate, notificationEvent); + } + + var payload = new{ + eventType = notificationEvent.Type.ToString(), + title = notificationEvent.Title, + message = notificationEvent.Message, + timestampUtc = notificationEvent.TimestampUtc, + metadata = notificationEvent.Metadata + }; + + return JsonConvert.SerializeObject(payload); + } + + private static string ApplyTemplate(string template, NotificationEvent notificationEvent){ + var values = new Dictionary(StringComparer.OrdinalIgnoreCase){ + ["eventType"] = notificationEvent.Type.ToString(), + ["title"] = notificationEvent.Title, + ["message"] = notificationEvent.Message, + ["timestampUtc"] = notificationEvent.TimestampUtc.ToString("O") + }; + + foreach (var pair in notificationEvent.Metadata){ + values[pair.Key] = pair.Value; + } + + foreach (var pair in values){ + template = template.Replace("{{" + pair.Key + "}}", pair.Value, StringComparison.OrdinalIgnoreCase); + } + + return template; + } +} diff --git a/CRD/Utils/QueueManagement/QueuePersistenceManager.cs b/CRD/Utils/QueueManagement/QueuePersistenceManager.cs index 5a0db0b..d834d00 100644 --- a/CRD/Utils/QueueManagement/QueuePersistenceManager.cs +++ b/CRD/Utils/QueueManagement/QueuePersistenceManager.cs @@ -69,7 +69,7 @@ public sealed class QueuePersistenceManager : IDisposable{ return; } - var queue = queueManager.Queue; + var queue = queueManager.GetQueueSnapshot(); if (queue.Count == 0){ CfgManager.DeleteFileIfExists(CfgManager.PathCrQueue); return; @@ -94,7 +94,14 @@ public sealed class QueuePersistenceManager : IDisposable{ item.downloadedFiles ??= []; item.DownloadProgress ??= new DownloadProgress(); - if (!item.DownloadProgress.IsFinished){ + if (item.DownloadProgress.RetryAtUtc.HasValue){ + if (item.DownloadProgress.RetryAtUtc.Value <= DateTimeOffset.UtcNow){ + item.DownloadProgress.ResetForRetry(); + } else{ + item.DownloadProgress.State = DownloadState.Queued; + item.DownloadProgress.ResumeState = DownloadState.Downloading; + } + } else if (!item.DownloadProgress.IsFinished){ item.DownloadProgress.ResetForRetry(); } diff --git a/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs b/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs index a17c7d5..7ff271b 100644 --- a/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs +++ b/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs @@ -1,8 +1,10 @@ using System.Collections.Generic; using CRD.Utils.Http; +using CRD.Utils.Notifications; using CRD.Utils.Sonarr; using CRD.ViewModels; using Newtonsoft.Json; +using System; namespace CRD.Utils.Structs.Crunchyroll; @@ -32,6 +34,12 @@ public class CrDownloadOptions{ [JsonProperty("retry_attempts")] public int RetryAttempts{ get; set; } + + [JsonProperty("playback_rate_limit_retry_delay_seconds")] + public int PlaybackRateLimitRetryDelaySeconds{ get; set; } + + [JsonProperty("retry_max_delay_seconds")] + public int RetryMaxDelaySeconds{ get; set; } [JsonIgnore] public string Force{ get; set; } = ""; @@ -68,6 +76,9 @@ public class CrDownloadOptions{ [JsonProperty("download_finished_execute_path")] public string? DownloadFinishedExecutePath{ get; set; } + + [JsonProperty("notifications")] + public NotificationSettings? NotificationSettings{ get; set; } [JsonProperty("download_only_with_all_selected_dubsub")] public bool DownloadOnlyWithAllSelectedDubSub{ get; set; } @@ -117,6 +128,12 @@ public class CrDownloadOptions{ [JsonProperty("history_auto_refresh_mode")] public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; } + [JsonProperty("history_auto_refresh_add_to_queue")] + public bool HistoryAutoRefreshAddToQueue{ get; set; } = true; + + [JsonProperty("tracked_series_release_last_check_utc")] + public DateTime? TrackedSeriesReleaseLastCheckUtc{ get; set; } + [JsonProperty("sonarr_properties")] public SonarrProperties? SonarrProperties{ get; set; } @@ -230,6 +247,9 @@ public class CrDownloadOptions{ [JsonProperty("download_part_size")] public int Partsize{ get; set; } + [JsonProperty("dub_download_delay_seconds")] + public int DubDownloadDelaySeconds{ get; set; } + [JsonProperty("soft_subs")] public List DlSubs{ get; set; } =[]; @@ -369,4 +389,46 @@ public class CrDownloadOptions{ public bool SearchFetchFeaturedMusic{ get; set; } #endregion + + public void NormalizeNotificationSettings(){ + NotificationSettings ??= new NotificationSettings(); + + var hasProviders = NotificationSettings.Providers.Count > 0; + + var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound); + if (hasProviders){ + if (soundProvider.Events.Count == 0){ + soundProvider.Events.Add(NotificationEventType.QueueFinished); + } + } else{ + soundProvider.Enabled = DownloadFinishedPlaySound; + soundProvider.Path = DownloadFinishedSoundPath ?? string.Empty; + soundProvider.Events.Add(NotificationEventType.QueueFinished); + } + + var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute); + if (hasProviders){ + if (executeProvider.Events.Count == 0){ + executeProvider.Events.Add(NotificationEventType.QueueFinished); + } + } else{ + executeProvider.Enabled = DownloadFinishedExecute; + executeProvider.Path = DownloadFinishedExecutePath ?? string.Empty; + executeProvider.Events.Add(NotificationEventType.QueueFinished); + } + + SyncLegacyNotificationFields(); + } + + public void SyncLegacyNotificationFields(){ + NotificationSettings ??= new NotificationSettings(); + + var soundProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound); + DownloadFinishedPlaySound = soundProvider.Enabled; + DownloadFinishedSoundPath = soundProvider.Path; + + var executeProvider = NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute); + DownloadFinishedExecute = executeProvider.Enabled; + DownloadFinishedExecutePath = executeProvider.Path; + } } diff --git a/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs b/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs index 6a46507..1cbcc04 100644 --- a/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs +++ b/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs @@ -443,6 +443,8 @@ public class DownloadProgress{ public DownloadState State{ get; set; } = DownloadState.Queued; public DownloadState ResumeState{ get; set; } = DownloadState.Downloading; public string Doing = string.Empty; + public DateTimeOffset? RetryAtUtc{ get; set; } + public int RetryAttemptCount{ get; set; } public int Percent{ get; set; } public double Time{ get; set; } @@ -456,6 +458,7 @@ public class DownloadProgress{ public bool IsError => State == DownloadState.Error; public bool IsFinished => State is DownloadState.Done or DownloadState.Error; public bool IsRunnable => State is DownloadState.Queued or DownloadState.Error; + public bool IsWaitingForRetry => RetryAtUtc.HasValue && RetryAtUtc.Value > DateTimeOffset.UtcNow; public void ResetForRetry(){ State = DownloadState.Queued; @@ -464,6 +467,24 @@ public class DownloadProgress{ Time = 0; DownloadSpeedBytes = 0; Doing = string.Empty; + RetryAtUtc = null; + RetryAttemptCount = 0; + } + + public void ScheduleRetry(TimeSpan delay, string doing){ + State = DownloadState.Queued; + ResumeState = DownloadState.Downloading; + Percent = 0; + Time = 0; + DownloadSpeedBytes = 0; + Doing = doing; + RetryAtUtc = DateTimeOffset.UtcNow.Add(delay); + RetryAttemptCount++; + } + + public void ClearRetryState(){ + RetryAtUtc = null; + RetryAttemptCount = 0; } } diff --git a/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs b/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs index 50d440b..acdb599 100644 --- a/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs +++ b/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs @@ -11,9 +11,17 @@ public class StreamError{ [JsonPropertyName("activeStreams")] public List ActiveStreams{ get; set; } = new (); + [JsonIgnore] + public string? RawJson{ get; set; } + public static StreamError? FromJson(string json){ try{ - return Helpers.Deserialize(json,null); + var error = Helpers.Deserialize(json,null); + if (error != null){ + error.RawJson = json; + } + + return error; } catch (Exception e){ Console.Error.WriteLine(e); return null; @@ -25,7 +33,11 @@ public class StreamError{ } public bool IsRateLimitError(){ - return Error?.Contains("4294") == true; + return IsPlaybackRateLimitError(); + } + + public bool IsPlaybackRateLimitError(){ + return Error?.Contains("4294") == true || RawJson?.Contains("4294") == true; } } diff --git a/CRD/Utils/Structs/HelperClasses.cs b/CRD/Utils/Structs/HelperClasses.cs index de8e4fe..6db5a88 100644 --- a/CRD/Utils/Structs/HelperClasses.cs +++ b/CRD/Utils/Structs/HelperClasses.cs @@ -125,6 +125,8 @@ public class DownloadResponse{ public string VideoTitle{ get; set; } public bool Error{ get; set; } public string ErrorText{ get; set; } + public bool RetrySuggested{ get; set; } + public int RetryDelaySeconds{ get; set; } } public class DownloadedMedia : SxItem{ diff --git a/CRD/Utils/Structs/History/HistoryEpisode.cs b/CRD/Utils/Structs/History/HistoryEpisode.cs index 4985ba1..732d5cf 100644 --- a/CRD/Utils/Structs/History/HistoryEpisode.cs +++ b/CRD/Utils/Structs/History/HistoryEpisode.cs @@ -34,6 +34,9 @@ public class HistoryEpisode : INotifyPropertyChanged{ [JsonProperty("episode_was_downloaded")] public bool WasDownloaded{ get; set; } + [JsonProperty("episode_tracked_series_release_notified")] + public bool TrackedSeriesReleaseNotified{ get; set; } + [JsonProperty("episode_special_episode")] public bool SpecialEpisode{ get; set; } diff --git a/CRD/Utils/Updater/Updater.cs b/CRD/Utils/Updater/Updater.cs index 37ee540..2b14f34 100644 --- a/CRD/Utils/Updater/Updater.cs +++ b/CRD/Utils/Updater/Updater.cs @@ -15,6 +15,7 @@ using CRD.Downloader; using CRD.Downloader.Crunchyroll; using CRD.Utils.Files; using CRD.Utils.Http; +using CRD.Utils.Notifications; using Newtonsoft.Json; using NuGet.Versioning; @@ -110,12 +111,19 @@ public class Updater : ObservableObject{ } downloadUrl = asset.BrowserDownloadUrl; + await NotificationPublisher.Instance.PublishUpdateAvailableAsync( + CrunchyrollManager.Instance.CrunOptions.NotificationSettings, + currentVersion.ToString(), + selectedRelease.TagName, + platformName, + downloadUrl); _ = UpdateChangelogAsync(); return true; } Console.WriteLine("No updates available."); + NotificationPublisher.Instance.ResetUpdateAvailableNotification(); _ = UpdateChangelogAsync(); return false; } @@ -417,4 +425,4 @@ public class Updater : ObservableObject{ return Name.Contains(platform, StringComparison.OrdinalIgnoreCase); } } -} \ No newline at end of file +} diff --git a/CRD/ViewModels/AccountPageViewModel.cs b/CRD/ViewModels/AccountPageViewModel.cs index 18c367a..2f0c5e9 100644 --- a/CRD/ViewModels/AccountPageViewModel.cs +++ b/CRD/ViewModels/AccountPageViewModel.cs @@ -12,11 +12,10 @@ using CRD.Utils.UI; using CRD.ViewModels.Utils; using CRD.Views.Utils; using FluentAvalonia.UI.Controls; -using Newtonsoft.Json; namespace CRD.ViewModels; -public partial class AccountPageViewModel : ViewModelBase{ +public partial class AccountPageViewModel : ViewModelBase, IDisposable{ [ObservableProperty] private Bitmap? _profileImage; @@ -32,7 +31,9 @@ public partial class AccountPageViewModel : ViewModelBase{ [ObservableProperty] private string _remainingTime = ""; - private static DispatcherTimer? _timer; + private static AccountPageViewModel? _activeInstance; + + private readonly DispatcherTimer _timer; private DateTime _targetTime; private bool IsCancelled; @@ -40,6 +41,14 @@ public partial class AccountPageViewModel : ViewModelBase{ private bool EndedButMaybeActive; public AccountPageViewModel(){ + _activeInstance?.StopSubscriptionTimer(); + _activeInstance = this; + + _timer = new DispatcherTimer{ + Interval = TimeSpan.FromSeconds(1) + }; + _timer.Tick += Timer_Tick; + UpdatetProfile(); } @@ -47,7 +56,7 @@ public partial class AccountPageViewModel : ViewModelBase{ var remaining = _targetTime - DateTime.Now; if (remaining <= TimeSpan.Zero){ RemainingTime = "No active Subscription"; - _timer?.Stop(); + _timer.Stop(); if (UnknownEndDate){ RemainingTime = "Unknown Subscription end date"; } @@ -55,30 +64,32 @@ public partial class AccountPageViewModel : ViewModelBase{ if (EndedButMaybeActive){ RemainingTime = "Subscription maybe ended"; } - - 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}"; } } public void UpdatetProfile(){ - + StopSubscriptionTimer(); + IsCancelled = false; + UnknownEndDate = false; + EndedButMaybeActive = false; + RemainingTime = "No active Subscription"; + var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1; var firstEndpointProfile = firstEndpoint.Profile; - - HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1; + + var isLoggedIn = firstEndpointProfile.Username != "???"; + HasMultiProfile = isLoggedIn && firstEndpoint.MultiProfile.Profiles.Count > 1; ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name - LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state + LoginLogoutText = isLoggedIn ? "Logout" : "Login"; // Default state LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" + (string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar)); var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription; - if (subscriptions != null){ + if (subscriptions != null && HasSubscriptionData(subscriptions)){ if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){ var sub = subscriptions.SubscriptionProducts.First(); IsCancelled = sub.IsCancelled; @@ -97,23 +108,8 @@ public partial class AccountPageViewModel : ViewModelBase{ if (!UnknownEndDate){ _targetTime = subscriptions.NextRenewalDate; - _timer = new DispatcherTimer{ - Interval = TimeSpan.FromSeconds(1) - }; - _timer.Tick += Timer_Tick; _timer.Start(); - } - } else{ - RemainingTime = "No active Subscription"; - if (_timer != null){ - _timer.Stop(); - _timer.Tick -= Timer_Tick; - } - - RaisePropertyChanged(nameof(RemainingTime)); - - if (subscriptions != null){ - Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented)); + Timer_Tick(null, EventArgs.Empty); } } @@ -126,6 +122,26 @@ public partial class AccountPageViewModel : ViewModelBase{ } } + private static bool HasSubscriptionData(CRD.Utils.Structs.Crunchyroll.Subscription subscriptions){ + return subscriptions.SubscriptionProducts is{ Count: > 0 } || + subscriptions.ThirdPartySubscriptionProducts is{ Count: > 0 } || + subscriptions.NonrecurringSubscriptionProducts is{ Count: > 0 } || + subscriptions.FunimationSubscriptions is{ Count: > 0 }; + } + + private void StopSubscriptionTimer(){ + _timer.Stop(); + } + + public void Dispose(){ + _timer.Tick -= Timer_Tick; + StopSubscriptionTimer(); + + if (ReferenceEquals(_activeInstance, this)){ + _activeInstance = null; + } + } + [RelayCommand] public async Task Button_Press(){ if (LoginLogoutText == "Login"){ @@ -191,4 +207,4 @@ public partial class AccountPageViewModel : ViewModelBase{ Console.Error.WriteLine("Failed to load image: " + ex.Message); } } -} \ No newline at end of file +} diff --git a/CRD/ViewModels/DownloadsPageViewModel.cs b/CRD/ViewModels/DownloadsPageViewModel.cs index fb3b04c..e7befa2 100644 --- a/CRD/ViewModels/DownloadsPageViewModel.cs +++ b/CRD/ViewModels/DownloadsPageViewModel.cs @@ -1,6 +1,7 @@ using System; using System.Collections.ObjectModel; using System.ComponentModel; +using System.Globalization; using System.IO; using System.Linq; using System.Threading.Tasks; @@ -139,16 +140,10 @@ public partial class DownloadItemModel : INotifyPropertyChanged{ isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing; ShowPauseIcon = isDownloading; Percent = epMeta.DownloadProgress.Percent; - Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss"); - DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits - ? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s" - : $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s"; - - ; + Time = GetTimeText(); + DownloadSpeed = GetDownloadSpeedText(); Paused = epMeta.DownloadProgress.IsPaused; - DoingWhat = Paused ? "Paused" : - Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") : - epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting"; + DoingWhat = GetDoingWhatText(); InfoText = JoinWithSeparator( GetDubString(), @@ -209,15 +204,11 @@ public partial class DownloadItemModel : INotifyPropertyChanged{ isDownloading = epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing; ShowPauseIcon = isDownloading; Percent = epMeta.DownloadProgress.Percent; - Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss"); - DownloadSpeed = CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits - ? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s" - : $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s"; + Time = GetTimeText(); + DownloadSpeed = GetDownloadSpeedText(); Paused = epMeta.DownloadProgress.IsPaused; - DoingWhat = Paused ? "Paused" : - Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") : - epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting"; + DoingWhat = GetDoingWhatText(); InfoText = JoinWithSeparator( GetDubString(), @@ -244,6 +235,36 @@ public partial class DownloadItemModel : INotifyPropertyChanged{ public event PropertyChangedEventHandler? PropertyChanged; + private string GetDoingWhatText(){ + if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){ + return "Rate limited, retrying at " + epMeta.DownloadProgress.RetryAtUtc.Value + .ToLocalTime() + .ToString("T", CultureInfo.CurrentCulture); + } + + return Paused ? "Paused" : + Done ? (epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Done") : + epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting"; + } + + private string GetTimeText(){ + if (epMeta.DownloadProgress.IsWaitingForRetry && epMeta.DownloadProgress.RetryAtUtc.HasValue){ + return string.Empty; + } + + return "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time).ToString(@"hh\:mm\:ss"); + } + + private string GetDownloadSpeedText(){ + if (epMeta.DownloadProgress.IsWaitingForRetry){ + return string.Empty; + } + + return CrunchyrollManager.Instance.CrunOptions.DownloadSpeedInBits + ? $"{epMeta.DownloadProgress.DownloadSpeedBytes * 8 / 1000000.0:F2} Mb/s" + : $"{epMeta.DownloadProgress.DownloadSpeedBytes / 1000000.0:F2} MB/s"; + } + [RelayCommand] public void ToggleIsDownloading(){ if (epMeta.DownloadProgress.State is DownloadState.Downloading or DownloadState.Processing){ diff --git a/CRD/ViewModels/Utils/ContentDialogEncodingPresetViewModel.cs b/CRD/ViewModels/Utils/ContentDialogEncodingPresetViewModel.cs index 5539a48..1b3aecf 100644 --- a/CRD/ViewModels/Utils/ContentDialogEncodingPresetViewModel.cs +++ b/CRD/ViewModels/Utils/ContentDialogEncodingPresetViewModel.cs @@ -1,5 +1,6 @@ using System; using System.Collections.ObjectModel; +using System.Collections.Specialized; using System.IO; using System.Linq; using Avalonia.Controls; @@ -7,6 +8,7 @@ using CommunityToolkit.Mvvm.ComponentModel; using CommunityToolkit.Mvvm.Input; using CRD.Utils.Ffmpeg_Encoding; using CRD.Utils.Files; +using CRD.Utils; using CRD.Utils.Structs; using CRD.Views; using DynamicData; @@ -22,25 +24,32 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ private bool editMode; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CommandPreview))] private string presetName; [ObservableProperty] [NotifyPropertyChangedFor(nameof(HasCodec))] + [NotifyPropertyChangedFor(nameof(CommandPreview))] private string codec; [ObservableProperty] - private ComboBoxItem selectedResolution = new(); + [NotifyPropertyChangedFor(nameof(CommandPreview))] + private StringItemWithDisplayName selectedResolution = new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" }; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CommandPreview))] private double? crf = 23; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CommandPreview))] private string frameRate = ""; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CommandPreview))] private string additionalParametersString = ""; [ObservableProperty] + [NotifyPropertyChangedFor(nameof(CommandPreview))] private ObservableCollection additionalParameters = new(); [ObservableProperty] @@ -50,33 +59,46 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ private bool fileExists; public bool HasCodec => !string.IsNullOrWhiteSpace(Codec); + public string CommandPreview => BuildCommandPreview(); public ObservableCollection CustomPresetsList{ get; } = new(){ }; - public ObservableCollection ResolutionList{ get; } = new(){ - new ComboBoxItem(){ Content = "3840:2160" }, // 4K UHD - new ComboBoxItem(){ Content = "3440:1440" }, // Ultra-Wide Quad HD - new ComboBoxItem(){ Content = "2560:1440" }, // 1440p - new ComboBoxItem(){ Content = "2560:1080" }, // Ultra-Wide Full HD - new ComboBoxItem(){ Content = "2160:1080" }, // 2:1 Aspect Ratio - new ComboBoxItem(){ Content = "1920:1080" }, // 1080p Full HD - new ComboBoxItem(){ Content = "1920:800" }, // Cinematic 2.40:1 - new ComboBoxItem(){ Content = "1600:900" }, // 900p - new ComboBoxItem(){ Content = "1366:768" }, // 768p - new ComboBoxItem(){ Content = "1280:960" }, // SXGA 4:3 - new ComboBoxItem(){ Content = "1280:720" }, // 720p HD - new ComboBoxItem(){ Content = "1024:576" }, // 576p - new ComboBoxItem(){ Content = "960:540" }, // 540p qHD - new ComboBoxItem(){ Content = "854:480" }, // 480p - new ComboBoxItem(){ Content = "800:600" }, // SVGA - new ComboBoxItem(){ Content = "768:432" }, // 432p - new ComboBoxItem(){ Content = "720:480" }, // NTSC SD - new ComboBoxItem(){ Content = "704:576" }, // PAL SD - new ComboBoxItem(){ Content = "640:360" }, // 360p - new ComboBoxItem(){ Content = "426:240" }, // 240p - new ComboBoxItem(){ Content = "320:240" }, // QVGA - new ComboBoxItem(){ Content = "320:180" }, // 180p - new ComboBoxItem(){ Content = "256:144" }, // 144p + public ObservableCollection ResolutionList{ get; } = new(){ + new(){ value = "3840:2160", DisplayName = "4K exact (3840:2160)" }, + new(){ value = "-2:2160", DisplayName = "4K keep AR (-2:2160)" }, + new(){ value = "3440:1440", DisplayName = "UWQHD exact (3440:1440)" }, + new(){ value = "2560:1440", DisplayName = "1440p exact (2560:1440)" }, + new(){ value = "-2:1440", DisplayName = "1440p keep AR (-2:1440)" }, + new(){ value = "2560:1080", DisplayName = "UW FHD exact (2560:1080)" }, + new(){ value = "2160:1080", DisplayName = "2:1 exact (2160:1080)" }, + new(){ value = "1920:1080", DisplayName = "1080p exact (1920:1080)" }, + new(){ value = "-2:1080", DisplayName = "1080p keep AR (-2:1080)" }, + new(){ value = "1920:800", DisplayName = "Cinema exact (1920:800)" }, + new(){ value = "1600:900", DisplayName = "900p exact (1600:900)" }, + new(){ value = "1366:768", DisplayName = "768p exact (1366:768)" }, + new(){ value = "1280:960", DisplayName = "SXGA exact (1280:960)" }, + new(){ value = "1280:720", DisplayName = "720p exact (1280:720)" }, + new(){ value = "-2:720", DisplayName = "720p keep AR (-2:720)" }, + new(){ value = "1024:576", DisplayName = "576p exact (1024:576)" }, + new(){ value = "-2:576", DisplayName = "576p keep AR (-2:576)" }, + new(){ value = "960:540", DisplayName = "540p exact (960:540)" }, + new(){ value = "-2:540", DisplayName = "540p keep AR (-2:540)" }, + new(){ value = "854:480", DisplayName = "480p exact (854:480)" }, + new(){ value = "-2:480", DisplayName = "480p keep AR (-2:480)" }, + new(){ value = "800:600", DisplayName = "SVGA exact (800:600)" }, + new(){ value = "768:432", DisplayName = "432p exact (768:432)" }, + new(){ value = "-2:432", DisplayName = "432p keep AR (-2:432)" }, + new(){ value = "720:480", DisplayName = "NTSC exact (720:480)" }, + new(){ value = "704:576", DisplayName = "PAL exact (704:576)" }, + new(){ value = "640:360", DisplayName = "360p exact (640:360)" }, + new(){ value = "-2:360", DisplayName = "360p keep AR (-2:360)" }, + new(){ value = "426:240", DisplayName = "240p exact (426:240)" }, + new(){ value = "-2:240", DisplayName = "240p keep AR (-2:240)" }, + new(){ value = "320:240", DisplayName = "QVGA exact (320:240)" }, + new(){ value = "320:180", DisplayName = "180p exact (320:180)" }, + new(){ value = "-2:180", DisplayName = "180p keep AR (-2:180)" }, + new(){ value = "256:144", DisplayName = "144p exact (256:144)" }, + new(){ value = "-2:144", DisplayName = "144p keep AR (-2:144)" }, }; public ContentDialogEncodingPresetViewModel(ContentDialog dialog, bool editMode){ @@ -87,6 +109,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ } AdditionalParameters.Add(new StringItem(){ stringValue = "-map 0" }); + AdditionalParameters.CollectionChanged += AdditionalParametersOnCollectionChanged; if (editMode){ EditMode = true; @@ -113,7 +136,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ Crf = value.Crf; FrameRate = value.FrameRate ?? "24000/1001"; - SelectedResolution = ResolutionList.FirstOrDefault(e => e.Content?.ToString() == value.Resolution) ?? ResolutionList.First(); + SelectedResolution = ResolutionList.FirstOrDefault(e => e.value == value.Resolution) ?? ResolutionList.First(); AdditionalParameters.Clear(); foreach (var valueAdditionalParameter in value.AdditionalParameters){ @@ -136,13 +159,36 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ public void AddAdditionalParam(){ AdditionalParameters.Add(new StringItem(){ stringValue = AdditionalParametersString }); AdditionalParametersString = ""; - RaisePropertyChanged(nameof(AdditionalParametersString)); } [RelayCommand] public void RemoveAdditionalParam(StringItem param){ AdditionalParameters.Remove(param); - RaisePropertyChanged(nameof(AdditionalParameters)); + } + + private void AdditionalParametersOnCollectionChanged(object? sender, NotifyCollectionChangedEventArgs e){ + OnPropertyChanged(nameof(CommandPreview)); + } + + private string BuildCommandPreview(){ + var previewPreset = new VideoPreset{ + PresetName = PresetName, + Codec = Codec, + FrameRate = string.IsNullOrWhiteSpace(FrameRate) ? "24000/1001" : FrameRate, + Crf = Math.Clamp((int)(Crf ?? 0), 0, 51), + Resolution = SelectedResolution.value, + AdditionalParameters = AdditionalParameters + .Select(additionalParameter => additionalParameter.stringValue) + .Where(static value => !string.IsNullOrWhiteSpace(value)) + .ToList() + }; + + var args = Helpers.BuildFFmpegArgsForPreset( + "S01E01.mkv", + previewPreset, + "S01E01_output.mkv"); + + return Helpers.BuildCommandString("ffmpeg", args); } private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){ @@ -156,7 +202,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ SelectedCustomPreset.Codec = Codec; SelectedCustomPreset.FrameRate = FrameRate; SelectedCustomPreset.Crf = Math.Clamp((int)(Crf ?? 0), 0, 51); - SelectedCustomPreset.Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080"; + SelectedCustomPreset.Resolution = SelectedResolution.value; SelectedCustomPreset.AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList(); try{ @@ -178,7 +224,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ Codec = Codec, FrameRate = FrameRate, Crf = Math.Clamp((int)(Crf ?? 0), 0, 51), - Resolution = SelectedResolution.Content?.ToString() ?? "1920:1080", + Resolution = SelectedResolution.value, AdditionalParameters = AdditionalParameters.Select(additionalParameter => additionalParameter.stringValue).ToList() }; @@ -189,6 +235,7 @@ public partial class ContentDialogEncodingPresetViewModel : ViewModelBase{ } private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){ + AdditionalParameters.CollectionChanged -= AdditionalParametersOnCollectionChanged; dialog.Closed -= DialogOnClosed; } } diff --git a/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs b/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs index 2947383..8519a56 100644 --- a/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs +++ b/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs @@ -20,15 +20,20 @@ using CRD.Downloader.Crunchyroll; using CRD.Utils; using CRD.Utils.Files; using CRD.Utils.Http; +using CRD.Utils.Notifications; using CRD.Utils.Sonarr; using CRD.Utils.Structs; using CRD.Utils.Structs.Crunchyroll; using CRD.Utils.Structs.History; +using CRD.Views; using FluentAvalonia.Styling; +using ReactiveUI; namespace CRD.ViewModels.Utils; public partial class GeneralSettingsViewModel : ViewModelBase{ + private readonly AudioPlayer notificationTestPlayer = new(); + [ObservableProperty] private string currentVersion; @@ -62,6 +67,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [ObservableProperty] private HistoryRefreshMode historyAutoRefreshMode; + [ObservableProperty] + private bool historyAutoRefreshAddToQueue; + [ObservableProperty] private string historyAutoRefreshModeHint; @@ -103,6 +111,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [ObservableProperty] private double? retryDelay; + + [ObservableProperty] + private double? playbackRateLimitRetryDelaySeconds; + + [ObservableProperty] + private double? retryMaxDelaySeconds; [ObservableProperty] private bool trayIconEnabled; @@ -291,9 +305,51 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [ObservableProperty] private string downloadFinishedExecutePath; + [ObservableProperty] + private bool webhookEnabled; + + [ObservableProperty] + private string webhookUrl = string.Empty; + + [ObservableProperty] + private string webhookMethod = "POST"; + + [ObservableProperty] + private string webhookContentType = "application/json"; + + [ObservableProperty] + private string webhookHeadersText = string.Empty; + + [ObservableProperty] + private string webhookBodyTemplate = string.Empty; + + [ObservableProperty] + private bool webhookNotifyQueueFinished; + + [ObservableProperty] + private bool webhookNotifyDownloadFinished; + + [ObservableProperty] + private bool webhookNotifyDownloadFailed; + + [ObservableProperty] + private bool webhookNotifyTrackedSeriesEpisodeReleased; + + [ObservableProperty] + private bool webhookNotifyLoginExpired; + + [ObservableProperty] + private bool webhookNotifyUpdateAvailable; + [ObservableProperty] private string currentIp = ""; + [ObservableProperty] + private bool isTestingFinishedSound; + + [ObservableProperty] + private bool isTestingWebhook; + private readonly FluentAvaloniaTheme faTheme; private bool settingsLoaded; @@ -318,16 +374,29 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ } CrDownloadOptions options = CrunchyrollManager.Instance.CrunOptions; + options.NormalizeNotificationSettings(); BackgroundImageBlurRadius = options.BackgroundImageBlurRadius; BackgroundImageOpacity = options.BackgroundImageOpacity; BackgroundImagePath = options.BackgroundImagePath ?? string.Empty; - DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty; - DownloadFinishedPlaySound = options.DownloadFinishedPlaySound; + var soundProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound); + DownloadFinishedSoundPath = soundProvider?.Path ?? string.Empty; + DownloadFinishedPlaySound = soundProvider?.Enabled ?? false; - DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty; - DownloadFinishedExecute = options.DownloadFinishedExecute; + var executeProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute); + DownloadFinishedExecutePath = executeProvider?.Path ?? string.Empty; + DownloadFinishedExecute = executeProvider?.Enabled ?? false; + + var webhookProvider = options.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Webhook); + WebhookEnabled = webhookProvider?.Enabled ?? false; + WebhookUrl = webhookProvider?.Url ?? string.Empty; + WebhookMethod = string.IsNullOrWhiteSpace(webhookProvider?.Method) ? "POST" : webhookProvider.Method; + WebhookContentType = string.IsNullOrWhiteSpace(webhookProvider?.ContentType) ? "application/json" : webhookProvider.ContentType; + WebhookHeadersText = SerializeHeaders(webhookProvider?.Headers); + WebhookBodyTemplate = webhookProvider?.BodyTemplate ?? string.Empty; + + LoadProviderEvents(webhookProvider, NotificationProviderType.Webhook); DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath; TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath; @@ -377,6 +446,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ HistoryCountSonarr = options.HistoryCountSonarr; HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes; HistoryAutoRefreshMode = options.HistoryAutoRefreshMode; + HistoryAutoRefreshAddToQueue = options.HistoryAutoRefreshAddToQueue; HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture); DownloadSpeed = options.DownloadSpeedLimit; DownloadSpeedInBits = options.DownloadSpeedInBits; @@ -386,6 +456,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ PersistQueue = options.PersistQueue; RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10); RetryDelay = Math.Clamp((options.RetryDelay), 1, 30); + PlaybackRateLimitRetryDelaySeconds = Math.Clamp(options.PlaybackRateLimitRetryDelaySeconds, 1, 86400); + RetryMaxDelaySeconds = Math.Clamp(options.RetryMaxDelaySeconds, 1, 86400); DownloadToTempFolder = options.DownloadToTempFolder; SimultaneousDownloads = options.SimultaneousDownloads; SimultaneousProcessingJobs = options.SimultaneousProcessingJobs; @@ -425,9 +497,35 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ var settings = CrunchyrollManager.Instance.CrunOptions; - settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound; - - settings.DownloadFinishedExecute = DownloadFinishedExecute; + settings.NotificationSettings ??= new NotificationSettings(); + + var soundProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound); + soundProvider.Enabled = DownloadFinishedPlaySound; + soundProvider.Path = DownloadFinishedSoundPath; + soundProvider.Events = [NotificationEventType.QueueFinished]; + + var executeProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute); + executeProvider.Enabled = DownloadFinishedExecute; + executeProvider.Path = DownloadFinishedExecutePath; + executeProvider.Events = [NotificationEventType.QueueFinished]; + + var webhookProvider = settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Webhook); + webhookProvider.Enabled = WebhookEnabled; + webhookProvider.Url = WebhookUrl?.Trim() ?? string.Empty; + webhookProvider.Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant(); + webhookProvider.ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim(); + webhookProvider.Headers = ParseHeaders(WebhookHeadersText); + webhookProvider.BodyTemplate = WebhookBodyTemplate ?? string.Empty; + webhookProvider.Events = BuildEvents( + WebhookNotifyQueueFinished, + WebhookNotifyDownloadFinished, + WebhookNotifyDownloadFailed, + WebhookNotifyTrackedSeriesEpisodeReleased, + WebhookNotifyLoginExpired, + WebhookNotifyUpdateAvailable + ); + + settings.SyncLegacyNotificationFields(); settings.DownloadMethodeNew = DownloadMethodeNew; settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart; @@ -439,6 +537,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ settings.RetryAttempts = Math.Clamp((int)(RetryAttempts ?? 0), 1, 10); settings.RetryDelay = Math.Clamp((int)(RetryDelay ?? 0), 1, 30); + settings.PlaybackRateLimitRetryDelaySeconds = Math.Clamp((int)(PlaybackRateLimitRetryDelaySeconds ?? 0), 1, 86400); + settings.RetryMaxDelaySeconds = Math.Clamp((int)(RetryMaxDelaySeconds ?? 0), 1, 86400); settings.DownloadToTempFolder = DownloadToTempFolder; settings.HistoryCountMissing = HistoryCountMissing; @@ -449,6 +549,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ settings.HistoryCountSonarr = HistoryCountSonarr; settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ; settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode; + settings.HistoryAutoRefreshAddToQueue = HistoryAutoRefreshAddToQueue; settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000); settings.DownloadSpeedInBits = DownloadSpeedInBits; settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10); @@ -627,7 +728,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ [RelayCommand] public void ClearFinishedSoundPath(){ - CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = string.Empty; + var settings = CrunchyrollManager.Instance.CrunOptions; + settings.NotificationSettings ??= new NotificationSettings(); + settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = string.Empty; + settings.SyncLegacyNotificationFields(); DownloadFinishedSoundPath = string.Empty; } @@ -641,21 +745,131 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ } }, pathSetter: (path) => { - CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path; + var validationResult = AudioPlayer.ValidateSoundFile(path); + if (!validationResult.IsValid){ + MessageBus.Current.SendMessage(new ToastMessage(validationResult.ErrorMessage, ToastType.Error, 5)); + return; + } + + var settings = CrunchyrollManager.Instance.CrunOptions; + settings.NotificationSettings ??= new NotificationSettings(); + settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Sound).Path = path; + settings.SyncLegacyNotificationFields(); DownloadFinishedSoundPath = path; + MessageBus.Current.SendMessage(new ToastMessage("Notification sound updated", ToastType.Information, 2)); }, - pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty, + pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty, defaultPath: string.Empty ); } + [RelayCommand] + public async Task TestFinishedSoundAsync(){ + if (IsTestingFinishedSound){ + return; + } + + var path = CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Sound).Path ?? string.Empty; + IsTestingFinishedSound = true; + + try{ + var result = await notificationTestPlayer.ValidatePlaybackAsync(path); + + if (result.IsSuccess){ + MessageBus.Current.SendMessage(new ToastMessage("Notification sound test succeeded", ToastType.Information, 2)); + return; + } + + MessageBus.Current.SendMessage(new ToastMessage($"Notification sound test failed: {result.ErrorMessage}", ToastType.Error, 5)); + } finally{ + IsTestingFinishedSound = false; + } + } + + [RelayCommand] + public async Task StopFinishedSoundAsync(){ + await notificationTestPlayer.StopAsync(); + IsTestingFinishedSound = false; + } + + [RelayCommand] + public async Task TestWebhookAsync(){ + if (IsTestingWebhook){ + return; + } + + var selectedEvents = BuildEvents( + WebhookNotifyQueueFinished, + WebhookNotifyDownloadFinished, + WebhookNotifyDownloadFailed, + WebhookNotifyTrackedSeriesEpisodeReleased, + WebhookNotifyLoginExpired, + WebhookNotifyUpdateAvailable + ); + + if (!WebhookEnabled){ + MessageBus.Current.SendMessage(new ToastMessage("Enable the webhook first", ToastType.Error, 4)); + return; + } + + if (string.IsNullOrWhiteSpace(WebhookUrl)){ + MessageBus.Current.SendMessage(new ToastMessage("Set a webhook URL first", ToastType.Error, 4)); + return; + } + + if (selectedEvents.Count == 0){ + MessageBus.Current.SendMessage(new ToastMessage("Select at least one webhook event to test", ToastType.Error, 4)); + return; + } + + IsTestingWebhook = true; + + try{ + var settings = new NotificationSettings{ + Providers = [ + new NotificationProviderConfig{ + Type = NotificationProviderType.Webhook, + Enabled = true, + Url = WebhookUrl.Trim(), + Method = string.IsNullOrWhiteSpace(WebhookMethod) ? "POST" : WebhookMethod.Trim().ToUpperInvariant(), + ContentType = string.IsNullOrWhiteSpace(WebhookContentType) ? "application/json" : WebhookContentType.Trim(), + Headers = ParseHeaders(WebhookHeadersText), + BodyTemplate = WebhookBodyTemplate ?? string.Empty, + Events = selectedEvents + } + ] + }; + + var sentCount = 0; + + foreach (var notificationEvent in BuildTestWebhookEvents(selectedEvents)){ + if (await NotificationDispatcher.Instance.PublishWithResultAsync(settings, notificationEvent)){ + sentCount++; + } + } + + if (sentCount == selectedEvents.Count){ + MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} test webhook event(s)", ToastType.Information, 3)); + } else if (sentCount > 0){ + MessageBus.Current.SendMessage(new ToastMessage($"Sent {sentCount} of {selectedEvents.Count} test webhook event(s)", ToastType.Error, 5)); + } else{ + MessageBus.Current.SendMessage(new ToastMessage("Webhook test failed for all selected events", ToastType.Error, 5)); + } + } finally{ + IsTestingWebhook = false; + } + } + #endregion #region Download Finished Execute File [RelayCommand] public void ClearFinishedExectuePath(){ - CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty; + var settings = CrunchyrollManager.Instance.CrunOptions; + settings.NotificationSettings ??= new NotificationSettings(); + settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = string.Empty; + settings.SyncLegacyNotificationFields(); DownloadFinishedExecutePath = string.Empty; } @@ -669,10 +883,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ } }, pathSetter: (path) => { - CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path; + var settings = CrunchyrollManager.Instance.CrunOptions; + settings.NotificationSettings ??= new NotificationSettings(); + settings.NotificationSettings.GetOrCreateProvider(NotificationProviderType.Execute).Path = path; + settings.SyncLegacyNotificationFields(); DownloadFinishedExecutePath = path; }, - pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty, + pathGetter: () => CrunchyrollManager.Instance.CrunOptions.NotificationSettings?.GetOrCreateProvider(NotificationProviderType.Execute).Path ?? string.Empty, defaultPath: string.Empty ); } @@ -706,6 +923,189 @@ public partial class GeneralSettingsViewModel : ViewModelBase{ } } + private void LoadProviderEvents(NotificationProviderConfig? provider, NotificationProviderType type){ + var events = provider?.Events ?? []; + + switch (type){ + case NotificationProviderType.Webhook: + WebhookNotifyQueueFinished = events.Contains(NotificationEventType.QueueFinished); + WebhookNotifyDownloadFinished = events.Contains(NotificationEventType.DownloadFinished); + WebhookNotifyDownloadFailed = events.Contains(NotificationEventType.DownloadFailed); + WebhookNotifyTrackedSeriesEpisodeReleased = events.Contains(NotificationEventType.TrackedSeriesEpisodeReleased); + WebhookNotifyLoginExpired = events.Contains(NotificationEventType.LoginExpired); + WebhookNotifyUpdateAvailable = events.Contains(NotificationEventType.UpdateAvailable); + break; + default: + throw new ArgumentOutOfRangeException(nameof(type), type, null); + } + } + + private static List BuildEvents( + bool queueFinished, + bool downloadFinished, + bool downloadFailed, + bool trackedSeriesEpisodeReleased, + bool loginExpired, + bool updateAvailable){ + var events = new List(); + + if (queueFinished){ + events.Add(NotificationEventType.QueueFinished); + } + + if (downloadFinished){ + events.Add(NotificationEventType.DownloadFinished); + } + + if (downloadFailed){ + events.Add(NotificationEventType.DownloadFailed); + } + + if (trackedSeriesEpisodeReleased){ + events.Add(NotificationEventType.TrackedSeriesEpisodeReleased); + } + + if (loginExpired){ + events.Add(NotificationEventType.LoginExpired); + } + + if (updateAvailable){ + events.Add(NotificationEventType.UpdateAvailable); + } + + return events; + } + + private static IEnumerable BuildTestWebhookEvents(IEnumerable selectedEvents){ + foreach (var eventType in selectedEvents.Distinct()){ + yield return BuildTestWebhookEvent(eventType); + } + } + + private static NotificationEvent BuildTestWebhookEvent(NotificationEventType eventType){ + return eventType switch{ + NotificationEventType.QueueFinished => new NotificationEvent{ + Type = NotificationEventType.QueueFinished, + Title = "Downloads finished", + Message = "All queued downloads have finished processing.", + Metadata = [] + }, + NotificationEventType.DownloadFinished => new NotificationEvent{ + Type = NotificationEventType.DownloadFinished, + Title = "Download finished", + Message = "Finished processing Example Series.", + Metadata = BuildTestDownloadMetadata() + }, + NotificationEventType.DownloadFailed => new NotificationEvent{ + Type = NotificationEventType.DownloadFailed, + Title = "Download failed", + Message = "Failed to download Example Series: Example failure message", + Metadata = BuildTestDownloadMetadata("Example failure message") + }, + NotificationEventType.TrackedSeriesEpisodeReleased => new NotificationEvent{ + Type = NotificationEventType.TrackedSeriesEpisodeReleased, + Title = "Tracked series episode released", + Message = "A tracked episode is available for Example Series: Episode Title.", + Metadata = new Dictionary{ + ["seriesTitle"] = "Example Series", + ["seriesId"] = "G6ABC1234", + ["seasonId"] = "G6SEASON01", + ["episodeTitle"] = "Episode Title", + ["episodeId"] = "G6EP0001", + ["episodeNumber"] = "1", + ["seasonNumber"] = "1", + ["releaseDate"] = DateTimeOffset.UtcNow.AddMinutes(-30).ToString("O"), + ["premiumAvailableDate"] = DateTimeOffset.UtcNow.ToString("O"), + ["episodeUrl"] = "https://www.crunchyroll.com/en-US/watch/G6EP0001/episode-title", + ["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg", + ["description"] = "Example tracked-release description.", + ["durationMs"] = "1440000", + ["availableDubs"] = "en-US, ja-JP", + ["availableSubs"] = "en-US, de-DE" + } + }, + NotificationEventType.LoginExpired => new NotificationEvent{ + Type = NotificationEventType.LoginExpired, + Title = "Crunchyroll login expired", + Message = "The saved Crunchyroll session could not be refreshed. Please log in again.", + Metadata = new Dictionary{ + ["username"] = "example-user", + ["endpoint"] = "/auth/v1/token" + } + }, + NotificationEventType.UpdateAvailable => new NotificationEvent{ + Type = NotificationEventType.UpdateAvailable, + Title = "Update available", + Message = "Version v9.9.9 is available. Current version: v1.0.0.", + Metadata = new Dictionary{ + ["currentVersion"] = "v1.0.0", + ["latestVersion"] = "v9.9.9", + ["platform"] = "win-x64", + ["downloadUrl"] = "https://github.com/Crunchy-DL/Crunchy-Downloader/releases/latest" + } + }, + _ => throw new ArgumentOutOfRangeException(nameof(eventType), eventType, null) + }; + } + + private static Dictionary BuildTestDownloadMetadata(string? error = null){ + var metadata = new Dictionary{ + ["seriesTitle"] = "Example Series", + ["seasonTitle"] = "Season 1", + ["episodeTitle"] = "Episode Title", + ["episodeNumber"] = "1", + ["episodeId"] = "G6EP0001", + ["downloadPath"] = @"C:\Downloads\Example Series\Season 1", + ["seasonNumber"] = "1", + ["description"] = "Example download description.", + ["imageUrl"] = "https://static.crunchyroll.com/example-thumbnail.jpg", + ["imageUrlLarge"] = "https://static.crunchyroll.com/example-poster.jpg", + ["downloadSubs"] = "en-US, de-DE", + ["downloadDubs"] = "ja-JP", + ["hardsub"] = string.Empty, + ["seriesId"] = "G6ABC1234", + ["seasonId"] = "G6SEASON01", + ["episodeUrl"] = "https://www.crunchyroll.com/watch/G6EP0001" + }; + + if (!string.IsNullOrWhiteSpace(error)){ + metadata["error"] = error; + } + + return metadata; + } + + private static string SerializeHeaders(IReadOnlyDictionary? headers){ + if (headers == null || headers.Count == 0){ + return string.Empty; + } + + return string.Join(Environment.NewLine, headers.Select(pair => $"{pair.Key}: {pair.Value}")); + } + + private static Dictionary ParseHeaders(string? headerText){ + var headers = new Dictionary(StringComparer.OrdinalIgnoreCase); + if (string.IsNullOrWhiteSpace(headerText)){ + return headers; + } + + foreach (var rawLine in headerText.Split(["\r\n", "\n"], StringSplitOptions.RemoveEmptyEntries)){ + var separatorIndex = rawLine.IndexOf(':'); + if (separatorIndex <= 0){ + continue; + } + + var key = rawLine[..separatorIndex].Trim(); + var value = rawLine[(separatorIndex + 1)..].Trim(); + + if (!string.IsNullOrWhiteSpace(key)){ + headers[key] = value; + } + } + + return headers; + } + partial void OnCurrentAppThemeChanged(ComboBoxItem? value){ if (value?.Content?.ToString() == "System"){ diff --git a/CRD/Views/Utils/ContentDialogEncodingPresetView.axaml b/CRD/Views/Utils/ContentDialogEncodingPresetView.axaml index 68a0040..1c8a9e2 100644 --- a/CRD/Views/Utils/ContentDialogEncodingPresetView.axaml +++ b/CRD/Views/Utils/ContentDialogEncodingPresetView.axaml @@ -51,7 +51,16 @@ + + + + + + @@ -114,6 +123,25 @@ + + + + + + + + diff --git a/CRD/Views/Utils/GeneralSettingsView.axaml b/CRD/Views/Utils/GeneralSettingsView.axaml index c356a3d..50e617f 100644 --- a/CRD/Views/Utils/GeneralSettingsView.axaml +++ b/CRD/Views/Utils/GeneralSettingsView.axaml @@ -97,6 +97,13 @@ Text="{Binding HistoryAutoRefreshModeHint}" /> + + + + + @@ -158,19 +165,33 @@ - + - + + + + + + + + + @@ -258,96 +279,212 @@ - + + + + + + + - - - + + - - + - + + - - + + + + + + + + + + - - + - - - + + - - + - + + - - + + + + + + + - + + + + + + + - - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +