- 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
This commit is contained in:
Elwador 2026-05-14 21:49:57 +02:00
parent d9813191ad
commit ff3e28093e
37 changed files with 1955 additions and 275 deletions

View file

@ -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<string,string> Headers) response;
if (!HttpClientReq.Instance.UseFlareSolverr){
response = await HttpClientReq.Instance.SendHttpRequest(request);
} else{

View file

@ -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<string, CookieCollection> 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<string, string>{
@ -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);
}
}
}
}
}

View file

@ -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<string>{ "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<string>(){ "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<string> failedSyncLocales)> MuxStreams(List<DownloadedMedia> data, CrunchyMuxOptions options, string filename, CrunchyEpMeta crunchyEpMeta){
private async Task<(Merger? merger, bool isMuxed, bool syncError, string notSyncedDubs, List<string> failedSyncLocales)> MuxStreams(List<DownloadedMedia> 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<string>(
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<string, string> Headers) fetchPlaybackData = default;
(bool IsOk, PlaybackData pbData, string error, Dictionary<string, string> 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<DownloadedMedia>(),
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<string, string> 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<string, string> 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<string, string> Headers)> HandleStreamErrorsAsync(
(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers) response, string endpoint, CrAuth authEndpoint){
if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response;
var error = StreamError.FromJson(response.ResponseContent);

View file

@ -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();

View file

@ -255,6 +255,17 @@
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Dub Download Delay"
Description="Delay in seconds before starting the next selected dub. 0 disables the delay.">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0"
Value="{Binding DubDownloadDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Stream Endpoint ">
<controls:SettingsExpanderItem.Footer>
<StackPanel>

View file

@ -864,7 +864,7 @@ public class History{
List<HistoryEpisode> 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<SonarrEpisode>(episodes);

View file

@ -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<HistorySeries> 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<CrBrowseEpisode>? 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(){

View file

@ -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<CrunchyEpMeta> GetQueueSnapshot(){
if (Dispatcher.UIThread.CheckAccess()){
return queue.ToList();
}
return Dispatcher.UIThread
.InvokeAsync(() => queue.ToList())
.GetAwaiter()
.GetResult();
}
public void ReplaceQueue(IEnumerable<CrunchyEpMeta> 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<CrunchyEpMeta> toStart = new();
List<CrunchyEpMeta> 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<DateTimeOffset>()
.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);

View file

@ -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;
}
}
}
private void DisposeWindowsPlayback(){
_playbackCompleted = null;
_waveOut?.Dispose();
_waveOut = null;
_audioFileReader?.Dispose();
_audioFileReader = null;
}
}

View file

@ -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<string, string>());
for (var attempt = 0; attempt < 3 + 1; attempt++){
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
response = await HttpClientReq.Instance.SendHttpRequest(request);

View file

@ -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>(T obj){
var settings = new JsonSerializerSettings{
ContractResolver = new DefaultContractResolver{
@ -376,7 +390,7 @@ public class Helpers{
}
}
private static IEnumerable<string> GetQualityOption(VideoPreset preset){
public static IEnumerable<string> 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<string> BuildFFmpegArgsForPreset(string inputFilePath, VideoPreset preset, string outputFilePath){
var args = new List<string>{
"-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<string>{
"-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<string> args){
public static string BuildCommandString(string exe, IEnumerable<string> args){
static string Quote(string s){
if (string.IsNullOrWhiteSpace(s))
return "\"\"";

View file

@ -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<string, CookieCollection>? cookieStore = null,
public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
bool allowChallengeBypass = true){
string content = string.Empty;
var headers = new Dictionary<string, string>(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<string, string> 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: "",[]);
}
}

View file

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

View file

@ -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<NotificationProviderType, INotificationProvider> 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<bool> 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;
}
}

View file

@ -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<string, string> Metadata{ get; set; } = [];
}

View file

@ -0,0 +1,10 @@
namespace CRD.Utils.Notifications;
public enum NotificationEventType{
QueueFinished,
DownloadFinished,
DownloadFailed,
TrackedSeriesEpisodeReleased,
LoginExpired,
UpdateAvailable
}

View file

@ -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<NotificationEventType> 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<string, string> 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);
}
}

View file

@ -0,0 +1,7 @@
namespace CRD.Utils.Notifications;
public enum NotificationProviderType{
Sound,
Execute,
Webhook
}

View file

@ -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<string, string>{
["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<string, string>{
["currentVersion"] = currentVersion,
["latestVersion"] = latestVersion,
["platform"] = platformName,
["downloadUrl"] = downloadUrl
}
});
}
public Task<bool> 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<string, string>{
["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<string, string> BuildMetadata(CrunchyEpMeta data, string? error = null){
var metadata = new Dictionary<string, string>{
["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}";
}
}

View file

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

View file

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

View file

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

View file

@ -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<string, string>(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;
}
}

View file

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

View file

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

View file

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

View file

@ -11,9 +11,17 @@ public class StreamError{
[JsonPropertyName("activeStreams")]
public List<ActiveStream> ActiveStreams{ get; set; } = new ();
[JsonIgnore]
public string? RawJson{ get; set; }
public static StreamError? FromJson(string json){
try{
return Helpers.Deserialize<StreamError>(json,null);
var error = Helpers.Deserialize<StreamError>(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;
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -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<StringItem> 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<VideoPreset> CustomPresetsList{ get; } = new(){ };
public ObservableCollection<ComboBoxItem> 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<StringItemWithDisplayName> 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;
}
}

View file

@ -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<NotificationEventType> BuildEvents(
bool queueFinished,
bool downloadFinished,
bool downloadFailed,
bool trackedSeriesEpisodeReleased,
bool loginExpired,
bool updateAvailable){
var events = new List<NotificationEventType>();
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<NotificationEvent> BuildTestWebhookEvents(IEnumerable<NotificationEventType> 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<string, string>{
["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<string, string>{
["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<string, string>{
["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<string, string> BuildTestDownloadMetadata(string? error = null){
var metadata = new Dictionary<string, string>{
["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<string, string>? 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<string, string> ParseHeaders(string? headerText){
var headers = new Dictionary<string, string>(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"){

View file

@ -51,7 +51,16 @@
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding ResolutionList}"
SelectedItem="{Binding SelectedResolution}">
<ComboBox.ItemTemplate>
<DataTemplate>
<TextBlock Text="{Binding DisplayName}" />
</DataTemplate>
</ComboBox.ItemTemplate>
</ComboBox>
<TextBlock Text="Use exact sizes for fixed output dimensions, or keep AR to preserve the source aspect ratio."
Opacity="0.7"
FontSize="12"
TextWrapping="Wrap" />
</StackPanel>
<!-- Frame Rate NumberBox -->
@ -114,6 +123,25 @@
</ItemsControl>
</StackPanel>
<StackPanel Margin="0,20,0,0">
<TextBlock Text="Generated FFmpeg Command" Margin="0,0,0,5" />
<Border BorderBrush="#4a4a4a"
Background="{DynamicResource ControlAltFillColorQuarternary}"
BorderThickness="1"
CornerRadius="8"
Padding="10"
MaxWidth="700"
HorizontalAlignment="Left">
<SelectableTextBlock Text="{Binding CommandPreview}"
FontFamily="Cascadia Mono, Consolas, Courier New"
TextWrapping="Wrap" />
</Border>
<TextBlock Text="This preview uses sample input and output file names, but the generated options match the preset."
Opacity="0.7"
FontSize="12"
TextWrapping="Wrap" />
</StackPanel>
</StackPanel>

View file

@ -97,6 +97,13 @@
Text="{Binding HistoryAutoRefreshModeHint}" />
</StackPanel>
<StackPanel Spacing="4">
<CheckBox IsChecked="{Binding HistoryAutoRefreshAddToQueue}"
Content="Add newly found missing episodes to the queue" />
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
Text="When disabled, auto refresh only updates history and missing counts." />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
</StackPanel>
@ -158,19 +165,33 @@
<controls:SettingsExpanderItem.Footer>
<StackPanel>
<StackPanel Orientation="Horizontal">
<TextBlock Width="100" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<TextBlock Width="200" Text="Retry Attempts" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="10"
Value="{Binding RetryAttempts}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0 5">
<TextBlock Width="100" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<TextBlock Width="200" Text="Retry Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="30"
Value="{Binding RetryDelay}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0 5">
<TextBlock Width="200" Text="Rate Limit Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="86400"
Value="{Binding PlaybackRateLimitRetryDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
<StackPanel Orientation="Horizontal" Margin="0 5">
<TextBlock Width="200" Text="Rate Limit Max Delay (s)" Margin="5 0" VerticalAlignment="Center" HorizontalAlignment="Center"></TextBlock>
<controls:NumberBox Minimum="1" Maximum="86400"
Value="{Binding RetryMaxDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</StackPanel>
</StackPanel>
@ -258,96 +279,212 @@
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished">
<controls:SettingsExpander.Footer>
</controls:SettingsExpander.Footer>
</controls:SettingsExpander>
<controls:SettingsExpander Header="Notifications"
IconSource="AlertOn"
Description="Configure sound, file execution, and webhook notifications"
IsExpanded="False">
<controls:SettingsExpanderItem Content="Sound notification" Description="Play a sound file when the queue finishes">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedPlaySound}"
<StackPanel Spacing="10" Width="520">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
IsVisible="{Binding DownloadFinishedPlaySound}"
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
VerticalAlignment="Center"
FontStyle="Italic">
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Select Finished Sound" FontSize="15" />
<TextBlock Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
</TextBlock>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding ClearFinishedSoundPath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Remove Finished Sound Path" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select notification sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding TestFinishedSoundAsync}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Test selected notification sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Play" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding IsTestingFinishedSound}"
Command="{Binding StopFinishedSoundAsync}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Stop notification sound test" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Stop" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding ClearFinishedSoundPath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected notification sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}" />
</StackPanel>
</Grid>
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="This notification is sent only when the full queue finishes." />
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
<controls:SettingsExpanderItem Content="Execute file" Description="Run a selected file when the queue finishes">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedExecute}"
<StackPanel Spacing="10" Width="520">
<Grid ColumnDefinitions="*,Auto" ColumnSpacing="10">
<TextBlock Grid.Column="0"
IsVisible="{Binding DownloadFinishedExecute}"
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
TextTrimming="CharacterEllipsis"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
<TextBlock Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
</TextBlock>
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding ClearFinishedExectuePath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected file" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<StackPanel Grid.Column="1" Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select file to execute" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
</StackPanel>
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding ClearFinishedExectuePath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected file" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}" />
</StackPanel>
</Grid>
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="This action runs only when the full queue finishes." />
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Webhook" Description="Send an HTTP request when selected notification events are raised">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10" Width="520">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBox Watermark="https://example.com/webhook"
MinWidth="420"
Text="{Binding WebhookUrl}" />
<CheckBox IsChecked="{Binding WebhookEnabled}" />
</StackPanel>
<controls:SettingsExpander.Footer>
</controls:SettingsExpander.Footer>
<StackPanel Orientation="Horizontal" Spacing="10">
<StackPanel Spacing="4">
<TextBlock Text="Method" />
<TextBox MinWidth="120" Text="{Binding WebhookMethod}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Content type" />
<TextBox MinWidth="220" Text="{Binding WebhookContentType}" />
</StackPanel>
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Headers" />
<TextBox AcceptsReturn="True"
Height="90"
Text="{Binding WebhookHeadersText}"
Watermark="Authorization: Bearer token&#10;X-App: CRD" />
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="Enter one header per line in the format Name: Value." />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Body template" />
<TextBox AcceptsReturn="True"
Height="120"
Text="{Binding WebhookBodyTemplate}"
Watermark="JSON template using placeholders like {{eventType}} and {{message}}" />
<TextBlock Opacity="0.7"
FontSize="12"
TextWrapping="Wrap"
Text="Leave empty to send the default JSON payload. Available placeholders include {{eventType}}, {{title}}, {{message}}, {{timestampUtc}}, and metadata keys." />
</StackPanel>
<WrapPanel ItemWidth="245" Orientation="Horizontal">
<CheckBox IsChecked="{Binding WebhookNotifyQueueFinished}" Content="Queue finished" />
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFinished}" Content="Download finished" />
<CheckBox IsChecked="{Binding WebhookNotifyDownloadFailed}" Content="Download failed" />
<CheckBox IsChecked="{Binding WebhookNotifyTrackedSeriesEpisodeReleased}" Content="Tracked series episode released" />
<CheckBox IsChecked="{Binding WebhookNotifyLoginExpired}" Content="Login expired" />
<CheckBox IsChecked="{Binding WebhookNotifyUpdateAvailable}" Content="Update available" />
</WrapPanel>
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<Button Command="{Binding TestWebhookCommand}"
Content="Send selected test events" />
<ProgressBar Width="120"
IsIndeterminate="True"
IsVisible="{Binding IsTestingWebhook}" />
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
<controls:SettingsExpander Header="Sonarr Settings"
@ -836,4 +973,4 @@
</ScrollViewer>
</UserControl>
</UserControl>

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

Binary file not shown.