mirror of
https://github.com/Crunchy-DL/Crunchy-Downloader.git
synced 2026-07-29 23:39:16 +00:00
- 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:
parent
d9813191ad
commit
ff3e28093e
37 changed files with 1955 additions and 275 deletions
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -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(){
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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 "\"\"";
|
||||
|
|
|
|||
|
|
@ -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: "",[]);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
10
CRD/Utils/Notifications/INotificationProvider.cs
Normal file
10
CRD/Utils/Notifications/INotificationProvider.cs
Normal 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);
|
||||
}
|
||||
51
CRD/Utils/Notifications/NotificationDispatcher.cs
Normal file
51
CRD/Utils/Notifications/NotificationDispatcher.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
16
CRD/Utils/Notifications/NotificationEvent.cs
Normal file
16
CRD/Utils/Notifications/NotificationEvent.cs
Normal 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; } = [];
|
||||
}
|
||||
10
CRD/Utils/Notifications/NotificationEventType.cs
Normal file
10
CRD/Utils/Notifications/NotificationEventType.cs
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
namespace CRD.Utils.Notifications;
|
||||
|
||||
public enum NotificationEventType{
|
||||
QueueFinished,
|
||||
DownloadFinished,
|
||||
DownloadFailed,
|
||||
TrackedSeriesEpisodeReleased,
|
||||
LoginExpired,
|
||||
UpdateAvailable
|
||||
}
|
||||
37
CRD/Utils/Notifications/NotificationProviderConfig.cs
Normal file
37
CRD/Utils/Notifications/NotificationProviderConfig.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
7
CRD/Utils/Notifications/NotificationProviderType.cs
Normal file
7
CRD/Utils/Notifications/NotificationProviderType.cs
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
namespace CRD.Utils.Notifications;
|
||||
|
||||
public enum NotificationProviderType{
|
||||
Sound,
|
||||
Execute,
|
||||
Webhook
|
||||
}
|
||||
178
CRD/Utils/Notifications/NotificationPublisher.cs
Normal file
178
CRD/Utils/Notifications/NotificationPublisher.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
25
CRD/Utils/Notifications/NotificationSettings.cs
Normal file
25
CRD/Utils/Notifications/NotificationSettings.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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();
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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{
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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){
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"){
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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 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
BIN
images/Settings_G_Notifications.png
(Stored with Git LFS)
Normal file
Binary file not shown.
Loading…
Reference in a new issue