Add - Added **retry** for license key requests

Add - Added **Sonarr season/episode numbers** to the history view
Add - Added option to **match episodes to Sonarr episodes** to correct mismatches
Add - Added **button to rematch all Sonarr episodes**
Add - Added an **optional secondary endpoint**
Chg - Changed **changelog heading sizes**
Chg - Changed Sonarr matching to only process **new or unmatched episodes**
Chg - Changed logic to **request audio license keys only when needed**
Fix - Fixed **Sonarr series manual matching dialog**
This commit is contained in:
Elwador 2025-06-17 18:56:24 +02:00
parent ae0f936ff5
commit 5b33d2336c
34 changed files with 1281 additions and 748 deletions

View file

@ -20,6 +20,7 @@
<Application.Styles>
<sty:FluentAvaloniaTheme PreferSystemTheme="True" PreferUserAccentColor="True"/>
<StyleInclude Source="avares://CRD/Styling/ControlsGalleryStyles.axaml" />
<StyleInclude Source="avares://CRD/Styling/ContentDialogCustomStyle.axaml" />
<StyleInclude Source="avares://CRD/Assets/Icons.axaml"></StyleInclude>
</Application.Styles>
</Application>

View file

@ -65,8 +65,8 @@ public class CalendarManager{
return forDate;
}
var request = calendarLanguage.ContainsKey(CrunchyrollManager.Instance.CrunOptions.SelectedCalendarLanguage ?? "de")
? HttpClientReq.CreateRequestMessage($"{calendarLanguage[CrunchyrollManager.Instance.CrunOptions.SelectedCalendarLanguage ?? "de"]}?filter=premium&date={weeksMondayDate}", HttpMethod.Get, false, false, null)
var request = calendarLanguage.ContainsKey(CrunchyrollManager.Instance.CrunOptions.SelectedCalendarLanguage ?? "en-us")
? HttpClientReq.CreateRequestMessage($"{calendarLanguage[CrunchyrollManager.Instance.CrunOptions.SelectedCalendarLanguage ?? "en-us"]}?filter=premium&date={weeksMondayDate}", HttpMethod.Get, false, false, null)
: HttpClientReq.CreateRequestMessage($"{calendarLanguage["en-us"]}?filter=premium&date={weeksMondayDate}", HttpMethod.Get, false, false, null);

View file

@ -147,64 +147,7 @@ public class CrunchyrollManager{
options.History = true;
if (Path.Exists(CfgManager.PathCrDownloadOptionsOld)){
var optionsYaml = new CrDownloadOptionsYaml();
optionsYaml.UseCrBetaApi = true;
optionsYaml.AutoDownload = false;
optionsYaml.RemoveFinishedDownload = false;
optionsYaml.Chapters = true;
optionsYaml.Hslang = "none";
optionsYaml.Force = "Y";
optionsYaml.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
optionsYaml.Partsize = 10;
optionsYaml.DlSubs = new List<string>{ "en-US" };
optionsYaml.SkipMuxing = false;
optionsYaml.MkvmergeOptions = new List<string>{ "--no-date", "--disable-track-statistics-tags", "--engage no_variable_data" };
optionsYaml.FfmpegOptions = new();
optionsYaml.DefaultAudio = "ja-JP";
optionsYaml.DefaultSub = "en-US";
optionsYaml.QualityAudio = "best";
optionsYaml.QualityVideo = "best";
optionsYaml.CcTag = "CC";
optionsYaml.CcSubsFont = "Trebuchet MS";
optionsYaml.FsRetryTime = 5;
optionsYaml.Numbers = 2;
optionsYaml.Timeout = 15000;
optionsYaml.DubLang = new List<string>(){ "ja-JP" };
optionsYaml.SimultaneousDownloads = 2;
// options.AccentColor = Colors.SlateBlue.ToString();
optionsYaml.Theme = "System";
optionsYaml.SelectedCalendarLanguage = "en-us";
optionsYaml.CalendarDubFilter = "none";
optionsYaml.CustomCalendar = true;
optionsYaml.DlVideoOnce = true;
optionsYaml.StreamEndpoint = "web/firefox";
optionsYaml.SubsAddScaledBorder = ScaledBorderAndShadowSelection.DontAdd;
optionsYaml.HistoryLang = DefaultLocale;
optionsYaml.BackgroundImageOpacity = 0.5;
optionsYaml.BackgroundImageBlurRadius = 10;
optionsYaml.HistoryPageProperties = new HistoryPageProperties{
SelectedView = HistoryViewType.Posters,
SelectedSorting = SortingType.SeriesTitle,
SelectedFilter = FilterType.All,
ScaleValue = 0.73,
Ascending = false,
ShowSeries = true,
ShowArtists = true
};
optionsYaml.History = true;
CfgManager.UpdateSettingsFromFileYAML(optionsYaml);
options = Helpers.MigrateSettings(optionsYaml);
} else{
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
}
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
return options;
}
@ -226,11 +169,6 @@ public class CrunchyrollManager{
PreferredContentSubtitleLanguage = DefaultLocale,
HasPremium = false,
};
if (Path.Exists(CfgManager.PathCrDownloadOptionsOld)){
CfgManager.WriteCrSettings();
Helpers.DeleteFile(CfgManager.PathCrDownloadOptionsOld);
}
}
public static async Task<string> GetBase64EncodedTokenAsync(){
@ -290,9 +228,6 @@ public class CrunchyrollManager{
if (CfgManager.CheckIfFileExists(CfgManager.PathCrToken)){
Token = CfgManager.ReadJsonFromFile<CrToken>(CfgManager.PathCrToken);
await CrAuth.LoginWithToken();
if (Path.Exists(CfgManager.PathCrTokenOld)){
Helpers.DeleteFile(CfgManager.PathCrTokenOld);
}
} else{
await CrAuth.AuthAnonymous();
}
@ -391,7 +326,6 @@ public class CrunchyrollManager{
? Path.Combine(res.TempFolderPath ?? string.Empty, res.FileName ?? string.Empty)
: Path.Combine(res.FolderPath ?? string.Empty, res.FileName ?? string.Empty);
if (options is{ DlVideoOnce: false, KeepDubsSeperate: true }){
var groupByDub = Helpers.GroupByLanguageWithSubtitles(res.Data);
var mergers = new List<Merger>();
foreach (var keyValue in groupByDub){
@ -999,9 +933,12 @@ public class CrunchyrollManager{
}
#endregion
var fetchPlaybackData = await FetchPlaybackData(options, mediaId, mediaGuid, data.Music);
var fetchPlaybackData = await FetchPlaybackData(options.StreamEndpoint ?? "web/firefox", mediaId, mediaGuid, data.Music);
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default;
if (!string.IsNullOrEmpty(options.StreamEndpointSecondary) && !(options.StreamEndpoint ?? "web/firefox").Equals(options.StreamEndpointSecondary)){
fetchPlaybackData2 = await FetchPlaybackData(options.StreamEndpointSecondary, mediaId, mediaGuid, data.Music);
}
if (!fetchPlaybackData.IsOk){
var errorJson = fetchPlaybackData.error;
@ -1048,6 +985,21 @@ public class CrunchyrollManager{
ErrorText = "Playback data not found"
};
}
if (fetchPlaybackData2.IsOk){
if (fetchPlaybackData.pbData.Data != null)
foreach (var keyValuePair in fetchPlaybackData.pbData.Data){
var value = fetchPlaybackData2.pbData?.Data?[keyValuePair.Key];
var url = value?.Url.First() ?? "";
var match = Regex.Match(url, @"(.*\.urlset\/)");
var shortendUrl = match.Success ? match.Value : url;
if (!keyValuePair.Value.Url.Any(arrayUrl => arrayUrl != null && arrayUrl.Contains(shortendUrl))){
keyValuePair.Value.Url.Add(url);
}
}
}
var pbData = fetchPlaybackData.pbData;
@ -1106,7 +1058,7 @@ public class CrunchyrollManager{
streams = streams.Select(s => {
s.AudioLang = audDub;
s.HardsubLang = s.HardsubLang;
s.Type = $"{s.Format}/{s.AudioLang}/{s.HardsubLang}";
s.Type = $"{s.Format}/{s.AudioLang.CrLocale}/{s.HardsubLang.CrLocale}";
return s;
}).ToList();
@ -1209,35 +1161,76 @@ public class CrunchyrollManager{
var videoDownloadMedia = new DownloadedMedia(){ Lang = Languages.DEFAULT_lang };
if (!dlFailed && curStream != null && options is not{ Novids: true, Noaudio: true }){
var streamPlaylistsReq = HttpClientReq.CreateRequestMessage(curStream.Url ?? string.Empty, HttpMethod.Get, true, true, null);
Dictionary<string, string> streamPlaylistsReqResponseList =[];
var streamPlaylistsReqResponse = await HttpClientReq.Instance.SendHttpRequest(streamPlaylistsReq);
foreach (var streamUrl in curStream.Url){
var streamPlaylistsReq = HttpClientReq.CreateRequestMessage(streamUrl ?? string.Empty, HttpMethod.Get, true, true, null);
var streamPlaylistsReqResponse = await HttpClientReq.Instance.SendHttpRequest(streamPlaylistsReq);
if (!streamPlaylistsReqResponse.IsOk){
dlFailed = true;
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = dlFailed,
FileName = "./unknown",
ErrorText = "Playlist fetch problem"
};
}
if (!streamPlaylistsReqResponse.IsOk){
dlFailed = true;
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = dlFailed,
FileName = "./unknown",
ErrorText = "Playlist fetch problem"
};
if (streamPlaylistsReqResponse.ResponseContent.Contains("MPD")){
streamPlaylistsReqResponseList[streamUrl ?? ""] = streamPlaylistsReqResponse.ResponseContent;
}
}
//Use again when cr has all endpoints with new encoding
// var streamPlaylistsReq = HttpClientReq.CreateRequestMessage(curStream.Url ?? string.Empty, HttpMethod.Get, true, true, null);
//
// var streamPlaylistsReqResponse = await HttpClientReq.Instance.SendHttpRequest(streamPlaylistsReq);
//
// if (!streamPlaylistsReqResponse.IsOk){
// dlFailed = true;
// return new DownloadResponse{
// Data = new List<DownloadedMedia>(),
// Error = dlFailed,
// FileName = "./unknown",
// ErrorText = "Playlist fetch problem"
// };
// }
if (dlFailed){
Console.WriteLine($"CAN\'T FETCH VIDEO PLAYLISTS!");
} else{
if (streamPlaylistsReqResponse.ResponseContent.Contains("MPD")){
var match = Regex.Match(curStream.Url ?? string.Empty, @"(.*\.urlset\/)");
var matchedUrl = match.Success ? match.Value : null;
//Parse MPD Playlists
var crLocal = "";
if (pbData.Meta != null){
crLocal = pbData.Meta.AudioLocale.CrLocale;
// if (streamPlaylistsReqResponse.ResponseContent.Contains("MPD")){
// var match = Regex.Match(curStream.Url ?? string.Empty, @"(.*\.urlset\/)");
// var matchedUrl = match.Success ? match.Value : null;
// //Parse MPD Playlists
// var crLocal = "";
// if (pbData.Meta != null){
// crLocal = pbData.Meta.AudioLocale.CrLocale;
// }
//
// MPDParsed streamPlaylists = MPDParser.Parse(streamPlaylistsReqResponse.ResponseContent, Languages.FindLang(crLocal), matchedUrl);
//
// List<string> streamServers = new List<string>(streamPlaylists.Data.Keys);
if (streamPlaylistsReqResponseList.Count > 0){
HashSet<string> streamServers =[];
Dictionary<string, ServerData> playListData = new Dictionary<string, ServerData>();
foreach (var curStreams in streamPlaylistsReqResponseList){
var match = Regex.Match(curStreams.Key ?? string.Empty, @"(.*\.urlset\/)");
var matchedUrl = match.Success ? match.Value : null;
//Parse MPD Playlists
var crLocal = "";
if (pbData.Meta != null){
crLocal = pbData.Meta.AudioLocale.CrLocale;
}
MPDParsed streamPlaylists = MPDParser.Parse(curStreams.Value, Languages.FindLang(crLocal), matchedUrl);
streamServers.UnionWith(streamPlaylists.Data.Keys);
Helpers.MergePlaylistData(playListData, streamPlaylists.Data);
}
MPDParsed streamPlaylists = MPDParser.Parse(streamPlaylistsReqResponse.ResponseContent, Languages.FindLang(crLocal), matchedUrl);
List<string> streamServers = new List<string>(streamPlaylists.Data.Keys);
options.StreamServer = options.StreamServer > streamServers.Count ? 1 : options.StreamServer;
if (streamServers.Count == 0){
@ -1253,8 +1246,11 @@ public class CrunchyrollManager{
options.StreamServer = 1;
}
string selectedServer = streamServers[options.StreamServer - 1];
ServerData selectedList = streamPlaylists.Data[selectedServer];
// string selectedServer = streamServers[options.StreamServer - 1];
// ServerData selectedList = streamPlaylists.Data[selectedServer];
string selectedServer = streamServers.ToList()[options.StreamServer - 1];
ServerData selectedList = playListData[selectedServer];
var videos = selectedList.video.Select(item => new VideoItem{
segments = item.segments,
@ -1273,8 +1269,20 @@ public class CrunchyrollManager{
resolutionText = $"{Math.Round(item.bandwidth / 1000.0)}kB/s"
}).ToList();
videos.Sort((a, b) => a.quality.width.CompareTo(b.quality.width));
audios.Sort((a, b) => a.bandwidth.CompareTo(b.bandwidth));
// Video: Remove duplicates by resolution (width, height), keep highest bandwidth, then sort
videos = videos
.GroupBy(v => new{ v.quality.width, v.quality.height })
.Select(g => g.OrderByDescending(v => v.bandwidth).First())
.OrderBy(v => v.quality.width)
.ThenBy(v => v.bandwidth)
.ToList();
// Audio: Remove duplicates, then sort by bandwidth
audios = audios
.GroupBy(a => new{ a.bandwidth, a.language }) // Add more properties if needed
.Select(g => g.First())
.OrderBy(a => a.bandwidth)
.ToList();
if (string.IsNullOrEmpty(data.VideoQuality)){
Console.Error.WriteLine("Warning: VideoQuality is null or empty. Defaulting to 'best' quality.");
@ -1478,9 +1486,11 @@ public class CrunchyrollManager{
};
}
await CrAuth.RefreshToken(true);
Dictionary<string, string> authDataDict = new Dictionary<string, string>
{ { "authorization", "Bearer " + Token?.access_token },{"x-cr-content-id", mediaGuid},{"x-cr-video-token", pbData.Meta?.Token ?? string.Empty} };
{ { "authorization", "Bearer " + Token?.access_token },{ "x-cr-content-id", mediaGuid },{ "x-cr-video-token", pbData.Meta?.Token ?? string.Empty } };
var encryptionKeys = await _widevine.getKeys(chosenVideoSegments.pssh, ApiUrls.WidevineLicenceUrl, authDataDict);
if (encryptionKeys.Count == 0){
@ -1494,25 +1504,46 @@ public class CrunchyrollManager{
};
}
if (Path.Exists(CfgManager.PathMP4Decrypt) || Path.Exists(CfgManager.PathShakaPackager)){
var keyId = BitConverter.ToString(encryptionKeys[0].KeyID).Replace("-", "").ToLower();
var key = BitConverter.ToString(encryptionKeys[0].Bytes).Replace("-", "").ToLower();
List<ContentKey> encryptionKeysAudio =[];
if (!string.IsNullOrEmpty(chosenVideoSegments.pssh) && !chosenVideoSegments.pssh.Equals(chosenAudioSegments.pssh)){
Console.WriteLine("Video and Audio PSSH different requesting Audio encryption keys");
encryptionKeysAudio = await _widevine.getKeys(chosenAudioSegments.pssh, ApiUrls.WidevineLicenceUrl, authDataDict);
if (encryptionKeysAudio.Count == 0){
Console.Error.WriteLine("Failed to get audio encryption keys");
dlFailed = true;
return new DownloadResponse{
Data = files,
Error = dlFailed,
FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(fileDir, fileName)) : "./unknown",
ErrorText = "Couldn't get DRM audio encryption keys"
};
}
}
//mp4decrypt
var commandBase = $"--show-progress --key {keyId}:{key}";
if (Path.Exists(CfgManager.PathMP4Decrypt) || Path.Exists(CfgManager.PathShakaPackager)){
var tempTsFileName = Path.GetFileName(tempTsFile);
var tempTsFileWorkDir = Path.GetDirectoryName(tempTsFile) ?? CfgManager.PathVIDEOS_DIR;
var commandVideo = commandBase + $" \"{tempTsFileName}.video.enc.m4s\" \"{tempTsFileName}.video.m4s\"";
var commandAudio = commandBase + $" \"{tempTsFileName}.audio.enc.m4s\" \"{tempTsFileName}.audio.m4s\"";
// Use audio keys if available, otherwise fallback to video keys
var audioKeysToUse = encryptionKeysAudio.Count > 0 ? encryptionKeysAudio : encryptionKeys;
// === mp4decrypt command ===
var videoKey = encryptionKeys[0];
var videoKeyParam = BuildMp4DecryptKeyParam(videoKey.KeyID, videoKey.Bytes);
var commandVideo = $"--show-progress {videoKeyParam} \"{tempTsFileName}.video.enc.m4s\" \"{tempTsFileName}.video.m4s\"";
var audioKey = audioKeysToUse[0];
var audioKeyParam = BuildMp4DecryptKeyParam(audioKey.KeyID, audioKey.Bytes);
var commandAudio = $"--show-progress {audioKeyParam} \"{tempTsFileName}.audio.enc.m4s\" \"{tempTsFileName}.audio.m4s\"";
bool shaka = Path.Exists(CfgManager.PathShakaPackager);
if (shaka){
commandBase = " --enable_raw_key_decryption " +
string.Join(" ",
encryptionKeys.Select(kb =>
$"--keys key_id={BitConverter.ToString(kb.KeyID).Replace("-", "").ToLower()}:key={BitConverter.ToString(kb.Bytes).Replace("-", "").ToLower()}"));
commandVideo = $"input=\"{tempTsFileName}.video.enc.m4s\",stream=video,output=\"{tempTsFileName}.video.m4s\"" + commandBase;
commandAudio = $"input=\"{tempTsFileName}.audio.enc.m4s\",stream=audio,output=\"{tempTsFileName}.audio.m4s\"" + commandBase;
// === shaka-packager command ===
var shakaVideoKeys = BuildShakaKeysParam(encryptionKeys);
commandVideo = $"input=\"{tempTsFileName}.video.enc.m4s\",stream=video,output=\"{tempTsFileName}.video.m4s\" {shakaVideoKeys}";
var shakaAudioKeys = BuildShakaKeysParam(audioKeysToUse);
commandAudio = $"input=\"{tempTsFileName}.audio.enc.m4s\",stream=audio,output=\"{tempTsFileName}.audio.m4s\" {shakaAudioKeys}";
}
if (videoDownloaded){
@ -2085,13 +2116,13 @@ public class CrunchyrollManager{
#region Fetch Playback Data
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrDownloadOptions options, string mediaId, string mediaGuidId, bool music){
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(string streamEndpoint, string mediaId, string mediaGuidId, bool music){
var temppbData = new PlaybackData{
Total = 0,
Data = new Dictionary<string, StreamDetails>()
};
var playbackEndpoint = $"{ApiUrls.Playback}/{(music ? "music/" : "")}{mediaGuidId}/{options.StreamEndpoint}/play";
var playbackEndpoint = $"{ApiUrls.Playback}/{(music ? "music/" : "")}{mediaGuidId}/{streamEndpoint}/play";
var playbackRequestResponse = await SendPlaybackRequestAsync(playbackEndpoint);
if (!playbackRequestResponse.IsOk){
@ -2119,12 +2150,12 @@ public class CrunchyrollManager{
return (playbackRequestResponse.IsOk, pbData: temppbData, error: playbackRequestResponse.IsOk ? "" : playbackRequestResponse.ResponseContent);
}
private async Task<(bool IsOk, string ResponseContent)> SendPlaybackRequestAsync(string endpoint){
private async Task<(bool IsOk, string ResponseContent, string error)> SendPlaybackRequestAsync(string endpoint){
var request = HttpClientReq.CreateRequestMessage(endpoint, HttpMethod.Get, true, false, null);
return await HttpClientReq.Instance.SendHttpRequest(request);
}
private async Task<(bool IsOk, string ResponseContent)> HandleStreamErrorsAsync((bool IsOk, string ResponseContent) response, string endpoint){
private async Task<(bool IsOk, string ResponseContent, string error)> HandleStreamErrorsAsync((bool IsOk, string ResponseContent, string error) response, string endpoint){
if (response.IsOk || string.IsNullOrEmpty(response.ResponseContent)) return response;
var error = StreamError.FromJson(response.ResponseContent);
@ -2158,7 +2189,7 @@ public class CrunchyrollManager{
foreach (var hardsub in playStream.HardSubs){
var stream = hardsub.Value;
derivedPlayCrunchyStreams[hardsub.Key] = new StreamDetails{
Url = stream.Url,
Url = [stream.Url],
IsHardsubbed = true,
HardsubLocale = stream.Hlang,
HardsubLang = Languages.FixAndFindCrLc((stream.Hlang ?? Locale.DefaulT).GetEnumMemberValue())
@ -2167,7 +2198,7 @@ public class CrunchyrollManager{
}
derivedPlayCrunchyStreams[""] = new StreamDetails{
Url = playStream.Url,
Url = [playStream.Url],
IsHardsubbed = false,
HardsubLocale = Locale.DefaulT,
HardsubLang = Languages.DEFAULT_lang
@ -2322,4 +2353,14 @@ public class CrunchyrollManager{
Console.Error.WriteLine("Chapter request failed");
}
}
private static string FormatKey(byte[] keyBytes) =>
BitConverter.ToString(keyBytes).Replace("-", "").ToLower();
private static string BuildMp4DecryptKeyParam(byte[] keyId, byte[] key) =>
$"--key {FormatKey(keyId)}:{FormatKey(key)}";
private static string BuildShakaKeysParam(List<ContentKey> keys) =>
"--enable_raw_key_decryption " + string.Join(" ",
keys.Select(k => $"--keys key_id={FormatKey(k.KeyID)}:key={FormatKey(k.Bytes)}"));
}

View file

@ -126,6 +126,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem _selectedStreamEndpoint;
[ObservableProperty]
private ComboBoxItem _selectedStreamEndpointSecondary;
[ObservableProperty]
private ComboBoxItem _selectedDefaultDubLang;
@ -202,13 +205,28 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
new(){ Content = "console/ps5" },
new(){ Content = "console/xbox_one" },
new(){ Content = "web/edge" },
// new (){ Content = "web/safari" },
new(){ Content = "web/chrome" },
new(){ Content = "web/fallback" },
// new (){ Content = "ios/iphone" },
// new (){ Content = "ios/ipad" },
new(){ Content = "android/phone" },
new(){ Content = "tv/samsung" }
new(){ Content = "android/tablet" },
new(){ Content = "tv/samsung" },
new(){ Content = "tv/vidaa" }
];
public ObservableCollection<ComboBoxItem> StreamEndpointsSecondary{ get; } =[
new(){ Content = "" },
new(){ Content = "web/firefox" },
new(){ Content = "console/switch" },
new(){ Content = "console/ps4" },
new(){ Content = "console/ps5" },
new(){ Content = "console/xbox_one" },
new(){ Content = "web/edge" },
new(){ Content = "web/chrome" },
new(){ Content = "web/fallback" },
new(){ Content = "android/phone" },
new(){ Content = "android/tablet" },
new(){ Content = "tv/samsung" },
new(){ Content = "tv/vidaa" }
];
public ObservableCollection<StringItemWithDisplayName> FFmpegHWAccel{ get; } =[];
@ -282,6 +300,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
ComboBoxItem? streamEndpoint = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.StreamEndpoint ?? "")) ?? null;
SelectedStreamEndpoint = streamEndpoint ?? StreamEndpoints[0];
ComboBoxItem? streamEndpointSecondary = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.StreamEndpointSecondary ?? "")) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondary ?? StreamEndpointsSecondary[0];
FFmpegHWAccel.AddRange(GetAvailableHWAccelOptions());
StringItemWithDisplayName? hwAccellFlag = FFmpegHWAccel.FirstOrDefault(a => a.value == options.FfmpegHwAccelFlag) ?? null;
@ -424,6 +445,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.StreamEndpoint = SelectedStreamEndpoint.Content + "";
CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondary = SelectedStreamEndpointSecondary.Content + "";
List<string> dubLangs = new List<string>();
foreach (var listBoxItem in SelectedDubLang){

View file

@ -233,8 +233,16 @@
</ComboBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Stream Endpoint Secondary">
<controls:SettingsExpanderItem.Footer>
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding StreamEndpointsSecondary}"
SelectedItem="{Binding SelectedStreamEndpointSecondary}">
</ComboBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Video">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding DownloadVideo}"> </CheckBox>

View file

@ -614,7 +614,7 @@ public class History{
private static readonly object _lock = new object();
public async Task MatchHistoryEpisodesWithSonarr(bool updateAll, HistorySeries historySeries){
public async Task MatchHistoryEpisodesWithSonarr(bool rematchAll, HistorySeries historySeries){
if (crunInstance.CrunOptions.SonarrProperties is{ SonarrEnabled: false }){
return;
}
@ -630,22 +630,38 @@ public class History{
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
}
if (!rematchAll){
var historyEpisodesWithSonarrIds = allHistoryEpisodes
.Where(e => !string.IsNullOrEmpty(e.SonarrEpisodeId))
.ToList();
Parallel.ForEach(historyEpisodesWithSonarrIds, historyEpisode => {
var sonarrEpisode = episodes.FirstOrDefault(e => e.Id.ToString().Equals(historyEpisode.SonarrEpisodeId));
if (sonarrEpisode != null){
historyEpisode.AssignSonarrEpisodeData(sonarrEpisode);
}
});
var historyEpisodeIds = new HashSet<string>(historyEpisodesWithSonarrIds.Select(e => e.SonarrEpisodeId!));
episodes.RemoveAll(e => historyEpisodeIds.Contains(e.Id.ToString()));
allHistoryEpisodes = allHistoryEpisodes
.Where(e => string.IsNullOrEmpty(e.SonarrEpisodeId))
.ToList();
}
List<HistoryEpisode> failedEpisodes =[];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (updateAll || string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){
// Create a copy of the episodes list for each thread
var episodesCopy = new List<SonarrEpisode>(episodes);
var episode = FindClosestMatchEpisodes(episodesCopy, historyEpisode.EpisodeTitle ?? string.Empty);
if (episode != null){
historyEpisode.SonarrEpisodeId = episode.Id + "";
historyEpisode.SonarrEpisodeNumber = episode.EpisodeNumber + "";
historyEpisode.SonarrHasFile = episode.HasFile;
historyEpisode.SonarrIsMonitored = episode.Monitored;
historyEpisode.SonarrAbsolutNumber = episode.AbsoluteEpisodeNumber + "";
historyEpisode.SonarrSeasonNumber = episode.SeasonNumber + "";
historyEpisode.AssignSonarrEpisodeData(episode);
lock (_lock){
episodes.Remove(episode);
}
@ -669,12 +685,8 @@ public class History{
return episodeNumberStr == historyEpisode.Episode && seasonNumberStr == historyEpisode.EpisodeSeasonNum;
});
if (episode != null){
historyEpisode.SonarrEpisodeId = episode.Id + "";
historyEpisode.SonarrEpisodeNumber = episode.EpisodeNumber + "";
historyEpisode.SonarrHasFile = episode.HasFile;
historyEpisode.SonarrIsMonitored = episode.Monitored;
historyEpisode.SonarrAbsolutNumber = episode.AbsoluteEpisodeNumber + "";
historyEpisode.SonarrSeasonNumber = episode.SeasonNumber + "";
historyEpisode.AssignSonarrEpisodeData(episode);
lock (_lock){
episodes.Remove(episode);
}
@ -688,12 +700,8 @@ public class History{
});
if (episode1 != null){
historyEpisode.SonarrEpisodeId = episode1.Id + "";
historyEpisode.SonarrEpisodeNumber = episode1.EpisodeNumber + "";
historyEpisode.SonarrHasFile = episode1.HasFile;
historyEpisode.SonarrIsMonitored = episode1.Monitored;
historyEpisode.SonarrAbsolutNumber = episode1.AbsoluteEpisodeNumber + "";
historyEpisode.SonarrSeasonNumber = episode1.SeasonNumber + "";
historyEpisode.AssignSonarrEpisodeData(episode1);
lock (_lock){
episodes.Remove(episode1);
}
@ -706,12 +714,8 @@ public class History{
return ele.AbsoluteEpisodeNumber + "" == historyEpisode.Episode;
});
if (episode2 != null){
historyEpisode.SonarrEpisodeId = episode2.Id + "";
historyEpisode.SonarrEpisodeNumber = episode2.EpisodeNumber + "";
historyEpisode.SonarrHasFile = episode2.HasFile;
historyEpisode.SonarrIsMonitored = episode2.Monitored;
historyEpisode.SonarrAbsolutNumber = episode2.AbsoluteEpisodeNumber + "";
historyEpisode.SonarrSeasonNumber = episode2.SeasonNumber + "";
historyEpisode.AssignSonarrEpisodeData(episode2);
lock (_lock){
episodes.Remove(episode2);
}

View file

@ -0,0 +1,202 @@
<Styles xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:ui="clr-namespace:CRD.Utils.UI">
<Design.PreviewWith>
<Border Padding="20">
<!-- Add Controls for Previewer Here -->
</Border>
</Design.PreviewWith>
<Style Selector="ui|CustomContentDialog">
<Setter Property="Foreground" Value="{DynamicResource ContentDialogForeground}" />
<Setter Property="Background" Value="{DynamicResource ContentDialogBackground}" />
<Setter Property="BorderBrush" Value="{DynamicResource ContentDialogBorderBrush}" />
<Setter Property="BorderThickness" Value="{DynamicResource ContentDialogBorderWidth}" />
<Setter Property="CornerRadius" Value="{DynamicResource OverlayCornerRadius}" />
<Setter Property="BackgroundSizing" Value="InnerBorderEdge" />
<Setter Property="Template">
<ControlTemplate>
<Border Name="Container">
<Panel Name="LayoutRoot" Background="{DynamicResource ContentDialogSmokeFill}">
<Border Name="BackgroundElement"
Background="{TemplateBinding Background}"
BorderThickness="{StaticResource ContentDialogBorderWidth}"
BorderBrush="{TemplateBinding BorderBrush}"
CornerRadius="{TemplateBinding CornerRadius}"
MinWidth="{DynamicResource ContentDialogMinWidth}"
MaxWidth="{DynamicResource ContentDialogMaxWidth}"
MinHeight="{DynamicResource ContentDialogMinHeight}"
MaxHeight="{DynamicResource ContentDialogMaxHeight}"
HorizontalAlignment="Center"
VerticalAlignment="Center" Margin="100"
Effect="drop-shadow(0 8 32 #66000000)"
BackgroundSizing="{TemplateBinding BackgroundSizing}">
<Border ClipToBounds="True"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid Name="DialogSpace" ClipToBounds="True"
RowDefinitions="*,Auto">
<Grid>
<Rectangle Fill="{TemplateBinding Background}" HorizontalAlignment="Stretch"
VerticalAlignment="Stretch" />
<Image x:Name="BackgroundImageElement"
Source="{TemplateBinding BackgroundImage}"
Stretch="UniformToFill"
Opacity="{TemplateBinding BackgroundImageOpacity}"
IsHitTestVisible="False"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
</Image>
<Border Background="{DynamicResource ContentDialogTopOverlay}"
Padding="{DynamicResource ContentDialogPadding}"
BorderThickness="{StaticResource ContentDialogSeparatorThickness}"
BorderBrush="{DynamicResource ContentDialogSeparatorBorderBrush}">
<Grid RowDefinitions="Auto,*">
<Grid.Styles>
<!--Make sure text wrapping is on-->
<Style Selector="TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
</Grid.Styles>
<ContentControl Name="Title"
Margin="{StaticResource ContentDialogTitleMargin}"
Content="{TemplateBinding Title}"
ContentTemplate="{TemplateBinding TitleTemplate}"
FontSize="20"
FontFamily="Default"
FontWeight="SemiBold"
Foreground="{TemplateBinding Foreground}"
HorizontalAlignment="Left"
VerticalAlignment="Top">
<ContentControl.Template>
<ControlTemplate>
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}"
Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}" />
</ControlTemplate>
</ContentControl.Template>
</ContentControl>
<!-- <ScrollViewer Grid.Row="1" Name="ContentScrollViewer" -->
<!-- HorizontalScrollBarVisibility="Disabled" -->
<!-- VerticalScrollBarVisibility="Auto"> -->
<ContentPresenter Name="Content"
ContentTemplate="{TemplateBinding ContentTemplate}"
Content="{TemplateBinding Content}"
FontSize="{StaticResource ControlContentThemeFontSize}"
FontFamily="{StaticResource ContentControlThemeFontFamily}"
Foreground="{TemplateBinding Foreground}"
Grid.Row="1" />
<!-- </ScrollViewer> -->
</Grid>
</Border>
</Grid>
<Border Padding="{StaticResource ContentDialogPadding}"
Grid.Row="1"
HorizontalAlignment="Stretch"
VerticalAlignment="Bottom"
Background="{TemplateBinding Background}">
<Grid Name="CommandSpace">
<!--
B/C we can't target Row/Column defs in Styles like WinUI
this still uses the old Col defs, but it works the same
way in the end...
-->
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="0.5*" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Button Name="PrimaryButton"
Content="{TemplateBinding PrimaryButtonText}"
IsEnabled="{TemplateBinding IsPrimaryButtonEnabled}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="False" />
<Button Name="SecondaryButton"
Content="{TemplateBinding SecondaryButtonText}"
IsEnabled="{TemplateBinding IsSecondaryButtonEnabled}"
HorizontalAlignment="Stretch" VerticalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="False" />
<Button Name="CloseButton"
Content="{TemplateBinding CloseButtonText}"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
HorizontalContentAlignment="Center"
VerticalContentAlignment="Center"
IsVisible="False" />
</Grid>
</Border>
</Grid>
</Border>
</Border>
</Panel>
</Border>
</ControlTemplate>
</Setter>
</Style>
<!-- Primary Only -->
<Style Selector="ui|CustomContentDialog:primary /template/ Button#PrimaryButton">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="Grid.ColumnSpan" Value="2" />
</Style>
<!-- Primary + Secondary -->
<Style Selector="ui|CustomContentDialog:primary:secondary /template/ Button#PrimaryButton">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Grid.Column" Value="0" />
<Setter Property="Grid.ColumnSpan" Value="2" />
<Setter Property="Margin" Value="0 0 4 0" />
</Style>
<Style Selector="ui|CustomContentDialog:primary:secondary /template/ Button#SecondaryButton">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="Grid.ColumnSpan" Value="2" />
<Setter Property="Margin" Value="4 0 0 0" />
</Style>
<!-- Primary + Close -->
<Style Selector="ui|CustomContentDialog:primary:close /template/ Button#PrimaryButton">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Grid.Column" Value="0" />
<Setter Property="Grid.ColumnSpan" Value="2" />
<Setter Property="Margin" Value="0 0 4 0" />
</Style>
<Style Selector="ui|CustomContentDialog:primary:close /template/ Button#CloseButton">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="Grid.ColumnSpan" Value="2" />
<Setter Property="Margin" Value="4 0 0 0" />
</Style>
<!-- Close Only -->
<Style Selector="ui|CustomContentDialog:close /template/ Button#CloseButton">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Grid.Column" Value="2" />
<Setter Property="Grid.ColumnSpan" Value="2" />
</Style>
</Styles>

View file

@ -80,12 +80,15 @@ public class Session{
public byte[] GetLicenseRequest(){
dynamic licenseRequest;
var random = new Random();
uint keyControlNonceId = (uint)(random.NextDouble() * Math.Pow(2, 31));
if (InitData is WidevineCencHeader){
licenseRequest = new SignedLicenseRequest{
Type = SignedLicenseRequest.MessageType.LicenseRequest,
Msg = new LicenseRequest{
Type = LicenseRequest.RequestType.New,
KeyControlNonce = 1093602366,
KeyControlNonce = keyControlNonceId,
ProtocolVersion = ProtocolVersion.Current,
RequestTime = uint.Parse((DateTime.Now - DateTime.UnixEpoch).TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture).Split('.')[0]),
ContentId = new LicenseRequest.ContentIdentification{
@ -102,7 +105,7 @@ public class Session{
Type = SignedLicenseRequestRaw.MessageType.LicenseRequest,
Msg = new LicenseRequestRaw{
Type = LicenseRequestRaw.RequestType.New,
KeyControlNonce = 1093602366,
KeyControlNonce = keyControlNonceId,
ProtocolVersion = ProtocolVersion.Current,
RequestTime = uint.Parse((DateTime.Now - DateTime.UnixEpoch).TotalSeconds.ToString(System.Globalization.CultureInfo.InvariantCulture).Split('.')[0]),
ContentId = new LicenseRequestRaw.ContentIdentification{

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.IO.IsolatedStorage;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
@ -111,7 +112,28 @@ public class Widevine{
content.Headers.ContentType = new System.Net.Http.Headers.MediaTypeHeaderValue("application/octet-stream");
playbackRequest2.Content = content;
var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
// var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2);
var response = (IsOk: false, ResponseContent: "", error: "");
for (var attempt = 0; attempt < 3 + 1; attempt++){
using (var request = Helpers.CloneHttpRequestMessage(playbackRequest2)){
response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.IsOk){
break;
}
if (response.error.Contains("System.Net.Sockets.SocketException (10054)")){
Console.Error.WriteLine($"Key Request Attempt {attempt + 1} failed.");
if (attempt == 3)
break;
await Task.Delay(1000);
} else{
break;
}
}
}
if (!response.IsOk){
Console.Error.WriteLine("Failed to get Keys!");

View file

@ -16,10 +16,7 @@ namespace CRD.Utils.Files;
public class CfgManager{
private static string workingDirectory = AppContext.BaseDirectory;
public static readonly string PathCrTokenOld = Path.Combine(workingDirectory, "config", "cr_token.yml");
public static readonly string PathCrDownloadOptionsOld = Path.Combine(workingDirectory, "config", "settings.yml");
public static readonly string PathCrToken = Path.Combine(workingDirectory, "config", "cr_token.json");
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
@ -182,73 +179,6 @@ public class CfgManager{
return properties;
}
#region YAML OLD
public static void UpdateSettingsFromFileYAML(CrDownloadOptionsYaml options){
string dirPath = Path.GetDirectoryName(PathCrDownloadOptionsOld) ?? string.Empty;
if (!Directory.Exists(dirPath)){
Directory.CreateDirectory(dirPath);
}
if (!File.Exists(PathCrDownloadOptionsOld)){
using (var fileStream = File.Create(PathCrDownloadOptionsOld)){
}
return;
}
var input = File.ReadAllText(PathCrDownloadOptionsOld);
if (input.Length <= 0){
return;
}
var deserializer = new DeserializerBuilder()
.WithNamingConvention(UnderscoredNamingConvention.Instance)
.IgnoreUnmatchedProperties()
.Build();
var propertiesPresentInYaml = GetTopLevelPropertiesInYaml(input);
var loadedOptions = deserializer.Deserialize<CrDownloadOptionsYaml>(new StringReader(input));
var instanceOptions = options;
foreach (PropertyInfo property in typeof(CrDownloadOptionsYaml).GetProperties()){
var yamlMemberAttribute = property.GetCustomAttribute<YamlMemberAttribute>();
// var jsonMemberAttribute = property.GetCustomAttribute<JsonPropertyAttribute>();
string yamlPropertyName = yamlMemberAttribute?.Alias ?? property.Name;
if (propertiesPresentInYaml.Contains(yamlPropertyName)){
PropertyInfo? instanceProperty = instanceOptions.GetType().GetProperty(property.Name);
if (instanceProperty != null && instanceProperty.CanWrite){
instanceProperty.SetValue(instanceOptions, property.GetValue(loadedOptions));
}
}
}
}
private static HashSet<string> GetTopLevelPropertiesInYaml(string yamlContent){
var reader = new StringReader(yamlContent);
var yamlStream = new YamlStream();
yamlStream.Load(reader);
var properties = new HashSet<string>();
if (yamlStream.Documents.Count > 0 && yamlStream.Documents[0].RootNode is YamlMappingNode rootNode){
foreach (var entry in rootNode.Children){
if (entry.Key is YamlScalarNode scalarKey){
properties.Add(scalarKey.Value);
}
}
}
return properties;
}
#endregion
public static void UpdateHistoryFile(){
if (!CrunchyrollManager.Instance.CrunOptions.History){
return;

View file

@ -4,6 +4,7 @@ using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
using System.Text.RegularExpressions;
@ -16,6 +17,7 @@ using Avalonia.Media.Imaging;
using CRD.Downloader;
using CRD.Utils.Ffmpeg_Encoding;
using CRD.Utils.Files;
using CRD.Utils.HLS;
using CRD.Utils.JsonConv;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
@ -39,6 +41,22 @@ public class Helpers{
return default;
}
public static HttpRequestMessage CloneHttpRequestMessage(HttpRequestMessage originalRequest){
var clone = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri){
Content = originalRequest.Content?.Clone(),
Version = originalRequest.Version
};
foreach (var header in originalRequest.Headers){
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var property in originalRequest.Properties){
clone.Properties.Add(property);
}
return clone;
}
public static T DeepCopy<T>(T obj){
var settings = new JsonSerializerSettings{
ContractResolver = new DefaultContractResolver{
@ -626,7 +644,7 @@ public class Helpers{
group.Add(descriptionMedia[0]);
}
}
return languageGroups;
}
@ -765,94 +783,27 @@ public class Helpers{
}
}
public static CrDownloadOptions MigrateSettings(CrDownloadOptionsYaml yaml){
if (yaml == null){
throw new ArgumentNullException(nameof(yaml));
public static void MergePlaylistData(
Dictionary<string, ServerData> target,
Dictionary<string, ServerData> source){
foreach (var kvp in source){
if (target.TryGetValue(kvp.Key, out var existing)){
// Merge audio
existing.audio ??=[];
if (kvp.Value.audio != null)
existing.audio.AddRange(kvp.Value.audio);
// Merge video
existing.video ??=[];
if (kvp.Value.video != null)
existing.video.AddRange(kvp.Value.video);
} else{
// Add new entry (clone lists to avoid reference issues)
target[kvp.Key] = new ServerData{
audio = kvp.Value.audio != null ? new List<AudioPlaylist>(kvp.Value.audio) : new List<AudioPlaylist>(),
video = kvp.Value.video != null ? new List<VideoPlaylist>(kvp.Value.video) : new List<VideoPlaylist>()
};
}
}
return new CrDownloadOptions{
// General Settings
AutoDownload = yaml.AutoDownload,
RemoveFinishedDownload = yaml.RemoveFinishedDownload,
Timeout = yaml.Timeout,
RetryDelay = yaml.FsRetryTime,
Force = yaml.Force,
SimultaneousDownloads = yaml.SimultaneousDownloads,
Theme = yaml.Theme,
AccentColor = yaml.AccentColor,
BackgroundImagePath = yaml.BackgroundImagePath,
BackgroundImageOpacity = yaml.BackgroundImageOpacity,
BackgroundImageBlurRadius = yaml.BackgroundImageBlurRadius,
Override = yaml.Override,
CcTag = yaml.CcTag,
Nocleanup = yaml.Nocleanup,
History = yaml.History,
HistoryIncludeCrArtists = yaml.HistoryIncludeCrArtists,
HistoryLang = yaml.HistoryLang,
HistoryAddSpecials = yaml.HistoryAddSpecials,
HistorySkipUnmonitored = yaml.HistorySkipUnmonitored,
HistoryCountSonarr = yaml.HistoryCountSonarr,
SonarrProperties = yaml.SonarrProperties,
LogMode = yaml.LogMode,
DownloadDirPath = yaml.DownloadDirPath,
DownloadTempDirPath = yaml.DownloadTempDirPath,
DownloadToTempFolder = yaml.DownloadToTempFolder,
HistoryPageProperties = yaml.HistoryPageProperties,
SeasonsPageProperties = yaml.SeasonsPageProperties,
DownloadSpeedLimit = yaml.DownloadSpeedLimit,
ProxyEnabled = yaml.ProxyEnabled,
ProxySocks = yaml.ProxySocks,
ProxyHost = yaml.ProxyHost,
ProxyPort = yaml.ProxyPort,
ProxyUsername = yaml.ProxyUsername,
ProxyPassword = yaml.ProxyPassword,
// Crunchyroll Settings
Hslang = yaml.Hslang,
Kstream = yaml.Kstream,
Novids = yaml.Novids,
Noaudio = yaml.Noaudio,
StreamServer = yaml.StreamServer,
QualityVideo = yaml.QualityVideo,
QualityAudio = yaml.QualityAudio,
FileName = yaml.FileName,
Numbers = yaml.Numbers,
Partsize = yaml.Partsize,
DlSubs = yaml.DlSubs,
SkipSubs = yaml.SkipSubs,
SkipSubsMux = yaml.SkipSubsMux,
SubsAddScaledBorder = yaml.SubsAddScaledBorder,
IncludeSignsSubs = yaml.IncludeSignsSubs,
SignsSubsAsForced = yaml.SignsSubsAsForced,
IncludeCcSubs = yaml.IncludeCcSubs,
CcSubsFont = yaml.CcSubsFont,
CcSubsMuxingFlag = yaml.CcSubsMuxingFlag,
Mp4 = yaml.Mp4,
VideoTitle = yaml.VideoTitle,
IncludeVideoDescription = yaml.IncludeVideoDescription,
DescriptionLang = yaml.DescriptionLang,
FfmpegOptions = yaml.FfmpegOptions,
MkvmergeOptions = yaml.MkvmergeOptions,
DefaultSub = yaml.DefaultSub,
DefaultSubSigns = yaml.DefaultSubSigns,
DefaultSubForcedDisplay = yaml.DefaultSubForcedDisplay,
DefaultAudio = yaml.DefaultAudio,
DlVideoOnce = yaml.DlVideoOnce,
KeepDubsSeperate = yaml.KeepDubsSeperate,
SkipMuxing = yaml.SkipMuxing,
SyncTiming = yaml.SyncTiming,
IsEncodeEnabled = yaml.IsEncodeEnabled,
EncodingPresetName = yaml.EncodingPresetName,
Chapters = yaml.Chapters,
DubLang = yaml.DubLang,
SelectedCalendarLanguage = yaml.SelectedCalendarLanguage,
CalendarDubFilter = yaml.CalendarDubFilter,
CustomCalendar = yaml.CustomCalendar,
CalendarHideDubs = yaml.CalendarHideDubs,
CalendarFilterByAirDate = yaml.CalendarFilterByAirDate,
CalendarShowUpcomingEpisodes = yaml.CalendarShowUpcomingEpisodes,
StreamEndpoint = yaml.StreamEndpoint,
SearchFetchFeaturedMusic = yaml.SearchFetchFeaturedMusic
};
}
}

View file

@ -163,7 +163,7 @@ public class HttpClientReq{
cookieStore[domain].Add(cookie);
}
public async Task<(bool IsOk, string ResponseContent)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false){
public async Task<(bool IsOk, string ResponseContent,string error)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false){
string content = string.Empty;
try{
AttachCookies(request);
@ -178,14 +178,14 @@ public class HttpClientReq{
response.EnsureSuccessStatusCode();
return (IsOk: true, ResponseContent: content);
return (IsOk: true, ResponseContent: content,error:"");
} catch (Exception e){
// Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
}
return (IsOk: false, ResponseContent: content);
return (IsOk: false, ResponseContent: content,error: e.Message);
}
}

View file

@ -59,8 +59,8 @@ public class MPDParsed{
}
public class ServerData{
public List<AudioPlaylist> audio{ get; set; }
public List<VideoPlaylist> video{ get; set; }
public List<AudioPlaylist> audio{ get; set; } =[];
public List<VideoPlaylist> video{ get; set; } =[];
}
public static class MPDParser{

View file

@ -274,261 +274,12 @@ public class CrDownloadOptions{
[JsonProperty("stream_endpoint")]
public string? StreamEndpoint{ get; set; }
[JsonProperty("stream_endpoint_secondary")]
public string? StreamEndpointSecondary { get; set; }
[JsonProperty("search_fetch_featured_music")]
public bool SearchFetchFeaturedMusic{ get; set; }
#endregion
}
public class CrDownloadOptionsYaml{
#region General Settings
[YamlMember(Alias = "auto_download", ApplyNamingConventions = false)]
public bool AutoDownload{ get; set; }
[YamlMember(Alias = "remove_finished_downloads", ApplyNamingConventions = false)]
public bool RemoveFinishedDownload{ get; set; }
[YamlIgnore]
public int Timeout{ get; set; }
[YamlIgnore]
public int FsRetryTime{ get; set; }
[YamlIgnore]
public string Force{ get; set; } = "";
[YamlMember(Alias = "simultaneous_downloads", ApplyNamingConventions = false)]
public int SimultaneousDownloads{ get; set; }
[YamlMember(Alias = "theme", ApplyNamingConventions = false)]
public string Theme{ get; set; } = "";
[YamlMember(Alias = "accent_color", ApplyNamingConventions = false)]
public string? AccentColor{ get; set; }
[YamlMember(Alias = "background_image_path", ApplyNamingConventions = false)]
public string? BackgroundImagePath{ get; set; }
[YamlMember(Alias = "background_image_opacity", ApplyNamingConventions = false)]
public double BackgroundImageOpacity{ get; set; }
[YamlMember(Alias = "background_image_blur_radius", ApplyNamingConventions = false)]
public double BackgroundImageBlurRadius{ get; set; }
[YamlIgnore]
public List<string> Override{ get; } =[];
[YamlIgnore]
public string CcTag{ get; set; } = "";
[YamlIgnore]
public bool Nocleanup{ get; } = false;
[YamlMember(Alias = "history", ApplyNamingConventions = false)]
public bool History{ get; set; }
[YamlMember(Alias = "history_include_cr_artists", ApplyNamingConventions = false)]
public bool HistoryIncludeCrArtists{ get; set; }
[YamlMember(Alias = "history_lang", ApplyNamingConventions = false)]
public string? HistoryLang{ get; set; }
[YamlMember(Alias = "history_add_specials", ApplyNamingConventions = false)]
public bool HistoryAddSpecials{ get; set; }
[YamlMember(Alias = "history_skip_unmonitored", ApplyNamingConventions = false)]
public bool HistorySkipUnmonitored{ get; set; }
[YamlMember(Alias = "history_count_sonarr", ApplyNamingConventions = false)]
public bool HistoryCountSonarr{ get; set; }
[YamlMember(Alias = "sonarr_properties", ApplyNamingConventions = false)]
public SonarrProperties? SonarrProperties{ get; set; }
[YamlMember(Alias = "log_mode", ApplyNamingConventions = false)]
public bool LogMode{ get; set; }
[YamlMember(Alias = "download_dir_path", ApplyNamingConventions = false)]
public string? DownloadDirPath{ get; set; }
[YamlMember(Alias = "download_temp_dir_path", ApplyNamingConventions = false)]
public string? DownloadTempDirPath{ get; set; }
[YamlMember(Alias = "download_to_temp_folder", ApplyNamingConventions = false)]
public bool DownloadToTempFolder{ get; set; }
[YamlMember(Alias = "history_page_properties", ApplyNamingConventions = false)]
public HistoryPageProperties? HistoryPageProperties{ get; set; }
[YamlMember(Alias = "seasons_page_properties", ApplyNamingConventions = false)]
public SeasonsPageProperties? SeasonsPageProperties{ get; set; }
[YamlMember(Alias = "download_speed_limit", ApplyNamingConventions = false)]
public int DownloadSpeedLimit{ get; set; }
[YamlMember(Alias = "proxy_enabled", ApplyNamingConventions = false)]
public bool ProxyEnabled{ get; set; }
[YamlMember(Alias = "proxy_socks", ApplyNamingConventions = false)]
public bool ProxySocks{ get; set; }
[YamlMember(Alias = "proxy_host", ApplyNamingConventions = false)]
public string? ProxyHost{ get; set; }
[YamlMember(Alias = "proxy_port", ApplyNamingConventions = false)]
public int ProxyPort{ get; set; }
[YamlMember(Alias = "proxy_username", ApplyNamingConventions = false)]
public string? ProxyUsername{ get; set; }
[YamlMember(Alias = "proxy_password", ApplyNamingConventions = false)]
public string? ProxyPassword{ get; set; }
#endregion
#region Crunchyroll Settings
[YamlIgnore]
public bool UseCrBetaApi{ get; set; }
[YamlMember(Alias = "hard_sub_lang", ApplyNamingConventions = false)]
public string Hslang{ get; set; } = "";
[YamlIgnore]
public int Kstream{ get; set; }
[YamlMember(Alias = "no_video", ApplyNamingConventions = false)]
public bool Novids{ get; set; }
[YamlMember(Alias = "no_audio", ApplyNamingConventions = false)]
public bool Noaudio{ get; set; }
[YamlIgnore]
public int StreamServer{ get; set; }
[YamlMember(Alias = "quality_video", ApplyNamingConventions = false)]
public string QualityVideo{ get; set; } = "";
[YamlMember(Alias = "quality_audio", ApplyNamingConventions = false)]
public string QualityAudio{ get; set; } = "";
[YamlMember(Alias = "file_name", ApplyNamingConventions = false)]
public string FileName{ get; set; } = "";
[YamlMember(Alias = "leading_numbers", ApplyNamingConventions = false)]
public int Numbers{ get; set; }
[YamlMember(Alias = "download_part_size", ApplyNamingConventions = false)]
public int Partsize{ get; set; }
[YamlMember(Alias = "soft_subs", ApplyNamingConventions = false)]
public List<string> DlSubs{ get; set; } =[];
[YamlIgnore]
public bool SkipSubs{ get; set; }
[YamlMember(Alias = "mux_skip_subs", ApplyNamingConventions = false)]
public bool SkipSubsMux{ get; set; }
[YamlMember(Alias = "subs_add_scaled_border", ApplyNamingConventions = false)]
public ScaledBorderAndShadowSelection SubsAddScaledBorder{ get; set; }
[YamlMember(Alias = "include_signs_subs", ApplyNamingConventions = false)]
public bool IncludeSignsSubs{ get; set; }
[YamlMember(Alias = "mux_signs_subs_flag", ApplyNamingConventions = false)]
public bool SignsSubsAsForced{ get; set; }
[YamlMember(Alias = "include_cc_subs", ApplyNamingConventions = false)]
public bool IncludeCcSubs{ get; set; }
[YamlMember(Alias = "cc_subs_font", ApplyNamingConventions = false)]
public string? CcSubsFont{ get; set; }
[YamlMember(Alias = "mux_cc_subs_flag", ApplyNamingConventions = false)]
public bool CcSubsMuxingFlag{ get; set; }
[YamlMember(Alias = "mux_mp4", ApplyNamingConventions = false)]
public bool Mp4{ get; set; }
[YamlMember(Alias = "mux_video_title", ApplyNamingConventions = false)]
public string? VideoTitle{ get; set; }
[YamlMember(Alias = "mux_video_description", ApplyNamingConventions = false)]
public bool IncludeVideoDescription{ get; set; }
[YamlMember(Alias = "mux_description_lang", ApplyNamingConventions = false)]
public string? DescriptionLang{ get; set; }
[YamlMember(Alias = "mux_ffmpeg", ApplyNamingConventions = false)]
public List<string> FfmpegOptions{ get; set; } =[];
[YamlMember(Alias = "mux_mkvmerge", ApplyNamingConventions = false)]
public List<string> MkvmergeOptions{ get; set; } =[];
[YamlMember(Alias = "mux_default_sub", ApplyNamingConventions = false)]
public string DefaultSub{ get; set; } = "";
[YamlMember(Alias = "mux_default_sub_signs", ApplyNamingConventions = false)]
public bool DefaultSubSigns{ get; set; }
[YamlMember(Alias = "mux_default_sub_forced_display", ApplyNamingConventions = false)]
public bool DefaultSubForcedDisplay{ get; set; }
[YamlMember(Alias = "mux_default_dub", ApplyNamingConventions = false)]
public string DefaultAudio{ get; set; } = "";
[YamlMember(Alias = "dl_video_once", ApplyNamingConventions = false)]
public bool DlVideoOnce{ get; set; }
[YamlMember(Alias = "keep_dubs_seperate", ApplyNamingConventions = false)]
public bool KeepDubsSeperate{ get; set; }
[YamlMember(Alias = "mux_skip_muxing", ApplyNamingConventions = false)]
public bool SkipMuxing{ get; set; }
[YamlMember(Alias = "mux_sync_dubs", ApplyNamingConventions = false)]
public bool SyncTiming{ get; set; }
[YamlMember(Alias = "encode_enabled", ApplyNamingConventions = false)]
public bool IsEncodeEnabled{ get; set; }
[YamlMember(Alias = "encode_preset", ApplyNamingConventions = false)]
public string? EncodingPresetName{ get; set; }
[YamlMember(Alias = "chapters", ApplyNamingConventions = false)]
public bool Chapters{ get; set; }
[YamlMember(Alias = "dub_lang", ApplyNamingConventions = false)]
public List<string> DubLang{ get; set; } =[];
[YamlMember(Alias = "calendar_language", ApplyNamingConventions = false)]
public string? SelectedCalendarLanguage{ get; set; }
[YamlMember(Alias = "calendar_dub_filter", ApplyNamingConventions = false)]
public string? CalendarDubFilter{ get; set; }
[YamlMember(Alias = "calendar_custom", ApplyNamingConventions = false)]
public bool CustomCalendar{ get; set; }
[YamlMember(Alias = "calendar_hide_dubs", ApplyNamingConventions = false)]
public bool CalendarHideDubs{ get; set; }
[YamlMember(Alias = "calendar_filter_by_air_date", ApplyNamingConventions = false)]
public bool CalendarFilterByAirDate{ get; set; }
[YamlMember(Alias = "calendar_show_upcoming_episodes", ApplyNamingConventions = false)]
public bool CalendarShowUpcomingEpisodes{ get; set; }
[YamlMember(Alias = "stream_endpoint", ApplyNamingConventions = false)]
public string? StreamEndpoint{ get; set; }
[YamlMember(Alias = "search_fetch_featured_music", ApplyNamingConventions = false)]
public bool SearchFetchFeaturedMusic{ get; set; }
#endregion
}

View file

@ -13,7 +13,7 @@ public class StreamDetails{
[JsonProperty("hardsub_locale")]
public Locale? HardsubLocale{ get; set; }
public string? Url{ get; set; }
public List<string?> Url{ get; set; }
[JsonProperty("hardsub_lang")]
public required LanguageItem HardsubLang{ get; set; }
@ -28,7 +28,7 @@ public class StreamDetails{
public class StreamDetailsPop{
public Locale? HardsubLocale{ get; set; }
public string? Url{ get; set; }
public List<string?> Url{ get; set; }
public required LanguageItem HardsubLang{ get; set; }
public bool IsHardsubbed{ get; set; }

View file

@ -5,6 +5,7 @@ using System.Threading.Tasks;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils.Files;
using CRD.Utils.Sonarr.Models;
using Newtonsoft.Json;
namespace CRD.Utils.Structs.History;
@ -58,6 +59,18 @@ public class HistoryEpisode : INotifyPropertyChanged{
[JsonProperty("sonarr_absolut_number")]
public string? SonarrAbsolutNumber{ get; set; }
[JsonIgnore]
public string SonarrSeasonEpisodeText{
get{
if (int.TryParse(SonarrSeasonNumber, out int season) &&
int.TryParse(SonarrEpisodeNumber, out int episode)){
return $"S{season:D2}E{episode:D2}";
}
return $"S{SonarrSeasonNumber}E{SonarrEpisodeNumber}";
}
}
[JsonProperty("history_episode_available_soft_subs")]
public List<string> HistoryEpisodeAvailableSoftSubs{ get; set; } =[];
@ -118,7 +131,16 @@ public class HistoryEpisode : INotifyPropertyChanged{
CrunchyrollManager.Instance.CrunOptions.DubLang, false, onlySubs);
break;
}
}
public void AssignSonarrEpisodeData(SonarrEpisode episode) {
SonarrEpisodeId = episode.Id.ToString();
SonarrEpisodeNumber = episode.EpisodeNumber.ToString();
SonarrHasFile = episode.HasFile;
SonarrIsMonitored = episode.Monitored;
SonarrAbsolutNumber = episode.AbsoluteEpisodeNumber.ToString();
SonarrSeasonNumber = episode.SeasonNumber.ToString();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SonarrSeasonEpisodeText)));
}
}

View file

@ -0,0 +1,55 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Media;
using FluentAvalonia.UI.Controls;
namespace CRD.Utils.UI;
public class CustomContentDialog : ContentDialog{
public static readonly StyledProperty<IImage> BackgroundImageProperty =
AvaloniaProperty.Register<CustomContentDialog, IImage>(nameof(BackgroundImage));
public static readonly StyledProperty<double> BackgroundImageOpacityProperty =
AvaloniaProperty.Register<CustomContentDialog, double>(nameof(BackgroundImageOpacity), 0.5);
public static readonly StyledProperty<double> BackgroundImageBlurRadiusProperty =
AvaloniaProperty.Register<CustomContentDialog, double>(nameof(BackgroundImageBlurRadius), 10);
public IImage BackgroundImage{
get => GetValue(BackgroundImageProperty);
set => SetValue(BackgroundImageProperty, value);
}
public double BackgroundImageOpacity{
get => GetValue(BackgroundImageOpacityProperty);
set => SetValue(BackgroundImageOpacityProperty, value);
}
public double BackgroundImageBlurRadius{
get => GetValue(BackgroundImageBlurRadiusProperty);
set => SetValue(BackgroundImageBlurRadiusProperty, value);
}
private Image? _backgroundImage;
protected override void OnApplyTemplate(TemplateAppliedEventArgs e){
base.OnApplyTemplate(e);
_backgroundImage = e.NameScope.Find<Image>("BackgroundImageElement");
if (_backgroundImage is not null){
_backgroundImage.Effect = new BlurEffect{
Radius = BackgroundImageBlurRadius
};
}
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change){
base.OnPropertyChanged(change);
if (change.Property == BackgroundImageBlurRadiusProperty && _backgroundImage?.Effect is BlurEffect blur){
blur.Radius = BackgroundImageBlurRadius;
}
}
}

View file

@ -285,8 +285,11 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
private (string locale, string id)? ExtractLocaleAndIdFromUrl(){
var match = Regex.Match(UrlInput, "/([^/]+)/(?:artist|watch|series)(?:/(?:musicvideo|concert))?/([^/]+)/?");
return match.Success ? (match.Groups[1].Value, match.Groups[2].Value) : null;
var match = Regex.Match(UrlInput, @"^(?:https?:\/\/[^/]+)?(?:\/([a-z]{2}))?\/(?:[^/]+\/)?(artist|watch|series)(?:\/(musicvideo|concert))?\/([^/]+)(?:\/[^/]*)?$");
return match.Success
? (match.Groups[1].Value ?? "", match.Groups[4].Value)
: null;
}
private CrunchyUrlType GetUrlType(){
@ -341,12 +344,11 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
if (CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic){
var musicList = await CrunchyrollManager.Instance.CrMusic.ParseFeaturedMusicVideoByIdAsync(id, DetermineLocale(locale), true);
if (musicList != null){
currentMusicVideoList = musicList;
PopulateItemsFromMusicVideoList();
}
}
SetLoadingState(false);
@ -477,7 +479,6 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
private void OnSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e){
CurrentSeasonFullySelected = Items.All(item => SelectedItems.Contains(item));
if (CurrentSeasonFullySelected){
@ -581,18 +582,17 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
episodesBySeason[seasonKey].Add(episodeModel);
}
}
if (CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic){
var locale = string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang)
? CrunchyrollManager.Instance.DefaultLocale
: CrunchyrollManager.Instance.CrunOptions.HistoryLang;
var musicList = await CrunchyrollManager.Instance.CrMusic.ParseFeaturedMusicVideoByIdAsync(seriesId, DetermineLocale(locale), true);
if (musicList != null){
currentMusicVideoList = musicList;
PopulateItemsFromMusicVideoList();
}
}
CurrentSelectedSeason = SeasonList.First();

View file

@ -7,7 +7,6 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using DynamicData;

View file

@ -1,6 +1,5 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Linq;
@ -11,7 +10,6 @@ using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.CustomList;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;

View file

@ -114,6 +114,8 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private static string _progressText;
public Vector LastScrollOffset { get; set; } = Vector.Zero;
public HistoryPageViewModel(){
ProgramManager = ProgramManager.Instance;
@ -343,7 +345,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
NavToSeries();
if (!string.IsNullOrEmpty(value.SonarrSeriesId) && CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true }){
if (SelectedSeries != null) _ = CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(true, SelectedSeries);
if (SelectedSeries != null) _ = CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(false, SelectedSeries);
CfgManager.UpdateHistoryFile();
}

View file

@ -1,6 +1,5 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform.Storage;
@ -8,10 +7,10 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Utils.UI;
using CRD.ViewModels.Utils;
using CRD.Views;
using CRD.Views.Utils;
@ -133,6 +132,42 @@ public partial class SeriesPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public async Task MatchSonarrEpisode_Button(HistoryEpisode episode){
var dialog = new CustomContentDialog(){
Name = "CustomDialog",
Title = "Sonarr Episode Matching",
PrimaryButtonText = "Save",
CloseButtonText = "Close",
FullSizeDesired = true,
};
var viewModel = new ContentDialogSonarrMatchEpisodeViewModel(dialog, SelectedSeries, episode);
dialog.Content = new ContentDialogSonarrMatchEpisodeView(){
DataContext = viewModel
};
var dialogResult = await dialog.ShowAsync();
if (dialogResult == ContentDialogResult.Primary){
var sonarrEpisode = viewModel.CurrentSonarrEpisode;
foreach (var selectedSeriesSeason in SelectedSeries.Seasons){
foreach (var historyEpisode in selectedSeriesSeason.EpisodesList.Where(historyEpisode => historyEpisode.SonarrEpisodeId == sonarrEpisode.Id.ToString())){
historyEpisode.SonarrEpisodeId = string.Empty;
historyEpisode.SonarrAbsolutNumber = string.Empty;
historyEpisode.SonarrSeasonNumber = string.Empty;
historyEpisode.SonarrEpisodeNumber = string.Empty;
historyEpisode.SonarrHasFile = false;
historyEpisode.SonarrIsMonitored = false;
}
}
episode.AssignSonarrEpisodeData(sonarrEpisode);
CfgManager.UpdateHistoryFile();
}
}
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
@ -173,6 +208,12 @@ public partial class SeriesPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public async Task RefreshSonarrEpisodeMatch(){
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(true, SelectedSeries);
CfgManager.UpdateHistoryFile();
}
[RelayCommand]
public async Task UpdateData(string? season){
var result = await SelectedSeries.FetchData(season);

View file

@ -10,8 +10,6 @@ using CRD.Views.Utils;
using FluentAvalonia.UI.Controls;
using Image = Avalonia.Controls.Image;
// ReSharper disable InconsistentNaming
namespace CRD.ViewModels;
public class SettingsPageViewModel : ViewModelBase{

View file

@ -1,21 +1,30 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Text;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.Media;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils.Updater;
using Markdig;
using Markdig.Syntax;
using Markdig.Syntax.Inlines;
using Inline = Markdig.Syntax.Inlines.Inline;
namespace CRD.ViewModels;
public partial class UpdateViewModel : ViewModelBase{
[ObservableProperty]
private bool _updateAvailable;
@ -27,24 +36,22 @@ public partial class UpdateViewModel : ViewModelBase{
[ObservableProperty]
private bool _failed;
private AccountPageViewModel accountPageViewModel;
[ObservableProperty]
private string _changelogText = "<p><strong>No changelog found.</strong></p>";
private AccountPageViewModel accountPageViewModel;
[ObservableProperty]
private string _currentVersion;
public ObservableCollection<Control> ChangelogBlocks{ get; } = new();
public UpdateViewModel(){
var version = Assembly.GetExecutingAssembly().GetName().Version;
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
LoadChangelog();
UpdateAvailable = ProgramManager.Instance.UpdateAvailable;
Updater.Instance.PropertyChanged += Progress_PropertyChanged;
}
@ -52,7 +59,6 @@ public partial class UpdateViewModel : ViewModelBase{
public void StartUpdate(){
Updating = true;
ProgramManager.Instance.NavigationLock = true;
// Title = "Updating";
_ = Updater.Instance.DownloadAndUpdateAsync();
}
@ -65,11 +71,20 @@ public partial class UpdateViewModel : ViewModelBase{
}
}
private void LoadChangelog(){
#region Changelog Builder
private int textSize = 16;
public void LoadChangelog(){
string changelogPath = "CHANGELOG.md";
if (!File.Exists(changelogPath)){
ChangelogText = "<p><strong>No changelog found.</strong></p>";
ChangelogBlocks.Clear();
ChangelogBlocks.Add(new TextBlock{
Text = "No changelog found",
FontSize = 16,
TextWrapping = TextWrapping.Wrap
});
return;
}
@ -77,104 +92,206 @@ public partial class UpdateViewModel : ViewModelBase{
markdownText = PreprocessMarkdown(markdownText);
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UseSoftlineBreakAsHardlineBreak()
.Build();
var pipeline = new MarkdownPipelineBuilder().Build();
var document = Markdown.Parse(markdownText, pipeline);
string htmlContent = Markdown.ToHtml(markdownText, pipeline);
ChangelogBlocks.Clear();
htmlContent = MakeIssueLinksClickable(htmlContent);
htmlContent = ModifyImages(htmlContent);
Color themeTextColor = Application.Current?.RequestedThemeVariant == ThemeVariant.Dark ? Colors.White : Color.Parse("#E4000000");
string cssColor = $"#{themeTextColor.R:X2}{themeTextColor.G:X2}{themeTextColor.B:X2}";
try{
foreach (var block in document){
switch (block){
case HeadingBlock heading:
string headingText = string.Concat(heading.Inline.Select(i => i.ToString()));
ChangelogBlocks.Add(new TextBlock{
Text = headingText,
FontSize = heading.Level switch{ 1 => textSize + 10, 2 => textSize + 6, _ => textSize + 4 },
FontWeight = FontWeight.Bold,
Margin = new Thickness(0, 20, 0, 5),
TextWrapping = TextWrapping.Wrap
});
break;
string styledHtml = $@"
<html>
<head>
<style type=""text/css"">
body {{
color: {cssColor};
background: transparent;
font-family: Arial, sans-serif;
}}
img {{
max-width: 100%;
height: auto;
display: block;
margin: 10px auto;
max-height: 300px;
object-fit: contain;
}}
li {{
margin-bottom: 10px;
line-height: 1.6;
}}
code {{
background: #f0f0f0;
font-family: Consolas, Monaco, 'Courier New', monospace;
white-space: nowrap;
vertical-align: middle;
display: inline-block;
}}
pre code {{
background: #f5f5f5;
display: block;
padding: 10px;
border-radius: 5px;
white-space: pre-wrap;
word-wrap: break-word;
font-family: Consolas, Monaco, 'Courier New', monospace;
}}
</style>
</head>
<body>
{htmlContent}
</body>
</html>";
case ParagraphBlock paragraph:
var inlineControls = BuildInlineControls(paragraph.Inline?.FirstChild);
var container = new WrapPanel{
Margin = new Thickness(0, 5, 0, 5)
};
foreach (var ctrl in inlineControls)
container.Children.Add(ctrl);
ChangelogText = styledHtml;
ChangelogBlocks.Add(container);
break;
case ListBlock list:
foreach (ListItemBlock item in list){
foreach (var blocki in item){
if (blocki is ParagraphBlock para){
var container1 = new WrapPanel{ Margin = new Thickness(10, 2, 0, 2) };
container1.Children.Add(new TextBlock{ Text = "• ", FontWeight = FontWeight.Bold, FontSize = textSize});
foreach (var ctrl in BuildInlineControls(para.Inline?.FirstChild))
container1.Children.Add(ctrl);
ChangelogBlocks.Add(container1);
}
}
}
break;
}
}
} catch (Exception e){
Console.Error.WriteLine(e);
}
}
private string MakeIssueLinksClickable(string htmlContent){
// Match GitHub issue links
string issuePattern = @"<a href=['""](https:\/\/github\.com\/Crunchy-DL\/Crunchy-Downloader\/issues\/(\d+))['""][^>]*>[^<]+<\/a>";
IEnumerable<Control> BuildInlineControls(Inline? inline){
var controls = new List<Control>();
var urlRegex = new Regex(@"https?://[^\s]+", RegexOptions.Compiled);
var githubRefRegex = new Regex(
@"https://github\.com/[^/]+/[^/]+/(issues|discussions)/(\d+)",
RegexOptions.Compiled);
// Match GitHub discussion links
string discussionPattern = @"<a href=['""](https:\/\/github\.com\/Crunchy-DL\/Crunchy-Downloader\/discussions\/(\d+))['""][^>]*>[^<]+<\/a>";
while (inline != null){
switch (inline){
case LiteralInline lit:
var text = lit.Content.Text.Substring(lit.Content.Start, lit.Content.Length);
htmlContent = Regex.Replace(htmlContent, issuePattern, match => {
string fullUrl = match.Groups[1].Value;
string issueNumber = match.Groups[2].Value;
return $"<a href='{fullUrl}' target='_blank'>#{issueNumber}</a>";
});
var lastIndex = 0;
foreach (Match match in urlRegex.Matches(text)){
if (match.Index > lastIndex){
controls.Add(new TextBlock{
Text = text.Substring(lastIndex, match.Index - lastIndex),
TextWrapping = TextWrapping.Wrap,
FontSize = textSize
});
}
htmlContent = Regex.Replace(htmlContent, discussionPattern, match => {
string fullUrl = match.Groups[1].Value;
string discussionNumber = match.Groups[2].Value;
return $"<a href='{fullUrl}' target='_blank'>#{discussionNumber}</a>";
});
string url = match.Value;
string buttonText = url;
return htmlContent;
var ghMatch = githubRefRegex.Match(url);
if (ghMatch.Success && ghMatch.Groups.Count > 2){
buttonText = $"#{ghMatch.Groups[2].Value}";
}
controls.Add(CreateLinkButton(buttonText, url));
lastIndex = match.Index + match.Length;
}
if (lastIndex < text.Length){
controls.Add(new TextBlock{
Text = text.Substring(lastIndex),
TextWrapping = TextWrapping.Wrap,
FontSize = textSize
});
}
break;
case EmphasisInline emph:
var emphControls = BuildInlineControls(emph.FirstChild);
foreach (var ec in emphControls){
if (ec is TextBlock tb){
tb.FontWeight = emph.DelimiterChar == '*' ? FontWeight.Bold : FontWeight.Normal;
tb.FontStyle = emph.DelimiterChar == '_' ? FontStyle.Italic : FontStyle.Normal;
}
controls.Add(ec);
}
break;
case LinkInline link:
var linkText = ConvertInlinesToText(link.FirstChild);
controls.Add(CreateLinkButton(linkText, link.Url));
break;
}
inline = inline.NextSibling;
}
return controls;
}
private string ModifyImages(string htmlContent){
// Regex to match <img> tags
string imgPattern = @"<img\s+src=['""]([^'""]+)['""]( alt=['""]([^'""]+)['""])?\s*\/?>";
string ConvertInlinesToText(Inline? inline){
var result = new StringBuilder();
return Regex.Replace(htmlContent, imgPattern, match => {
string imgUrl = match.Groups[1].Value;
string altText = "View Image"; // match.Groups[3].Success ? match.Groups[3].Value : "View Image";
while (inline != null){
switch (inline){
case LiteralInline lit:
result.Append(lit.Content.Text.Substring(lit.Content.Start, lit.Content.Length));
break;
return $"<a href='{imgUrl}' target='_blank'>{altText}</a>";
});
case EmphasisInline emph:
result.Append(ConvertInlinesToText(emph.FirstChild));
break;
case LinkInline link:
var linkText = ConvertInlinesToText(link.FirstChild);
result.Append($"{linkText} ({link.Url})");
break;
case LineBreakInline:
result.Append('\n');
break;
default:
if (inline is ContainerInline{ FirstChild: not null } container){
result.Append(ConvertInlinesToText(container.FirstChild));
}
break;
}
inline = inline.NextSibling;
}
return result.ToString();
}
Brush GetLinkBrush(){
try{
var color = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor ?? Brushes.LightBlue.Color.ToString());
return new SolidColorBrush(color);
} catch{
return new SolidColorBrush(Brushes.LightBlue.Color);
}
}
Button CreateLinkButton(string text, string url){
var button = new Button{
Content = new TextBlock{
Text = text,
FontSize = textSize,
Foreground = GetLinkBrush(),
TextDecorations = TextDecorations.Underline
},
Background = Brushes.Transparent,
BorderThickness = new Thickness(0),
Padding = new Thickness(0),
Cursor = new Cursor(StandardCursorType.Hand)
};
button.Click += (_, __) => {
try{
using var p = new Process();
p.StartInfo = new ProcessStartInfo{
FileName = url,
UseShellExecute = true
};
p.Start();
} catch{
Console.Error.WriteLine($"Failed to open link: {url}");
}
};
return button;
}
private string PreprocessMarkdown(string markdownText){
// Regex to match <details> blocks containing an image
string detailsPattern = @"<details>\s*<summary>.*?<\/summary>\s*<img\s+src=['""]([^'""]+)['""]\s+alt=['""]([^'""]+)['""]\s*\/?>\s*<\/details>";
return Regex.Replace(markdownText, detailsPattern, match => {
@ -184,5 +301,6 @@ public partial class UpdateViewModel : ViewModelBase{
return $"![{altText}]({imageUrl})";
});
}
#endregion
}

View file

@ -5,7 +5,6 @@ using System.Linq;
using Avalonia.Controls;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Utils;
using CRD.Utils.Ffmpeg_Encoding;
using CRD.Utils.Files;
using CRD.Utils.Structs;

View file

@ -0,0 +1,135 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Sonarr;
using CRD.Utils.Sonarr.Models;
using CRD.Utils.Structs.History;
using CRD.Utils.UI;
using DynamicData;
using FluentAvalonia.UI.Controls;
namespace CRD.ViewModels.Utils;
public partial class ContentDialogSonarrMatchEpisodeViewModel : ViewModelBase{
private readonly CustomContentDialog dialog;
[ObservableProperty]
private SonarrEpisode _currentSonarrEpisode;
[ObservableProperty]
private HistoryEpisode _currentHistoryEpisode;
[ObservableProperty]
private SonarrEpisode _selectedItem;
[ObservableProperty]
private ObservableCollection<SeasonsItem> _sonarrSeasonList = new();
[ObservableProperty]
private SonarrSeries _sonarrSeries;
public ContentDialogSonarrMatchEpisodeViewModel(CustomContentDialog contentDialog, HistorySeries selectedSeries, HistoryEpisode episode){
ArgumentNullException.ThrowIfNull(contentDialog);
dialog = contentDialog;
dialog.Closed += DialogOnClosed;
dialog.PrimaryButtonClick += SaveButton;
CurrentHistoryEpisode = episode;
SonarrSeries = SonarrClient.Instance.SonarrSeries.Find(e => e.Id.ToString() == selectedSeries.SonarrSeriesId) ?? new SonarrSeries(){ Title = "No series matched" };
SetImageUrl(SonarrSeries);
_ = LoadList(selectedSeries.SonarrSeriesId);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
dialog.PrimaryButtonClick -= SaveButton;
}
private async Task LoadList(string? sonarrSeriesId){
if (string.IsNullOrEmpty(sonarrSeriesId)){
return;
}
var list = await PopulateSeriesList(sonarrSeriesId);
SonarrSeasonList.AddRange(list);
}
private async Task<List<SeasonsItem>> PopulateSeriesList(string sonarrSeriesId){
List<SonarrEpisode> episodes = await SonarrClient.Instance.GetEpisodes(int.Parse(sonarrSeriesId));
var seasonsDict = new Dictionary<int, SeasonsItem>();
foreach (var episode in episodes){
if (CurrentHistoryEpisode.SonarrEpisodeId == episode.Id.ToString()){
CurrentSonarrEpisode = episode;
}
int seasonNumber = episode.SeasonNumber;
if (!seasonsDict.TryGetValue(seasonNumber, out var season)){
season = new SeasonsItem{
SeasonName = $"Season {seasonNumber}",
Episodes = new List<SonarrEpisode>()
};
seasonsDict[seasonNumber] = season;
}
season.Episodes.Add(episode);
}
var seasons = seasonsDict.Values
.OrderBy(s => int.TryParse(System.Text.RegularExpressions.Regex.Match(s.SeasonName, @"\d+").Value, out int n) ? n : int.MaxValue)
.ToList();
foreach (var season in seasons){
season.Episodes.Sort((a, b) => a.EpisodeNumber.CompareTo(b.EpisodeNumber));
}
return seasons;
}
private async void SetImageUrl(SonarrSeries sonarrSeries){
var properties = CrunchyrollManager.Instance.CrunOptions.SonarrProperties;
if (properties == null || sonarrSeries.Images == null){
return;
}
var baseUrl = "";
baseUrl = $"http{(properties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(properties.Host) ? properties.Host : "localhost")}:{properties.Port}{(properties.UrlBase ?? "")}";
sonarrSeries.ImageUrl = baseUrl + sonarrSeries.Images.Find(e => e.CoverType == SonarrCoverType.FanArt)?.Url;
var image = await Helpers.LoadImage(sonarrSeries.ImageUrl);
if (image == null) return;
dialog.BackgroundImage = image;
dialog.BackgroundImageOpacity = CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity;
dialog.BackgroundImageBlurRadius = CrunchyrollManager.Instance.CrunOptions.BackgroundImageBlurRadius;
}
[RelayCommand]
public void SetSonarrEpisodeMatch(SonarrEpisode episode){
CurrentSonarrEpisode = episode;
}
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
dialog.Closed -= DialogOnClosed;
}
}
public class SeasonsItem(){
public string SeasonName{ get; set; }
public string IsExpanded{ get; set; }
public List<SonarrEpisode> Episodes{ get; set; }
}

View file

@ -9,7 +9,9 @@
xmlns:structs="clr-namespace:CRD.Utils.Structs"
x:DataType="vm:HistoryPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.HistoryPageView">
x:Class="CRD.Views.HistoryPageView"
Unloaded="OnUnloaded"
Loaded="Control_OnLoaded">
<UserControl.Resources>
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
@ -235,7 +237,7 @@
</StackPanel>
<ListBox Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" ItemsSource="{Binding FilteredItems}"
<ListBox x:Name="SeriesListBox" Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" ItemsSource="{Binding FilteredItems}"
SelectedItem="{Binding SelectedSeries}"
Margin="5" IsVisible="{Binding IsPosterViewSelected}">

View file

@ -1,11 +1,26 @@
using Avalonia.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using CRD.ViewModels;
namespace CRD.Views;
public partial class HistoryPageView : UserControl{
public HistoryPageView(){
InitializeComponent();
}
private void OnUnloaded(object? sender, RoutedEventArgs e){
if (DataContext is HistoryPageViewModel viewModel){
viewModel.LastScrollOffset = SeriesListBox.Scroll?.Offset ?? Vector.Zero;
}
}
private void Control_OnLoaded(object? sender, RoutedEventArgs e){
if (DataContext is HistoryPageViewModel viewModel){
if (SeriesListBox.Scroll != null) SeriesListBox.Scroll.Offset = viewModel.LastScrollOffset;
}
}
}

View file

@ -152,7 +152,8 @@
<ToggleButton Margin="0 0 5 10" FontStyle="Italic"
VerticalAlignment="Center"
IsChecked="{Binding SelectedSeries.IsInactive}" Command="{Binding ToggleInactive}">
IsChecked="{Binding SelectedSeries.IsInactive}"
Command="{Binding ToggleInactive}">
<ToolTip.Tip>
<StackPanel Orientation="Vertical">
<TextBlock Text="Set Active"
@ -323,12 +324,33 @@
</StackPanel>
<StackPanel IsVisible="{Binding EditMode}">
<Button Width="30" Height="30" Margin="0 0 10 0"
<Button Width="30" Height="30" Margin="0 0 5 0"
BorderThickness="0"
IsVisible="{Binding SonarrConnected}"
Command="{Binding MatchSonarrSeries_Button}">
<Grid>
<controls:ImageIcon Source="../Assets/sonarr.png" Width="25" Height="25" />
<ToolTip.Tip>
<TextBlock Text="Match Sonarr Series" FontSize="15" />
</ToolTip.Tip>
</Grid>
</Button>
</StackPanel>
<StackPanel IsVisible="{Binding EditMode}">
<Button Height="30" Margin="0 0 10 0"
BorderThickness="0"
IsVisible="{Binding SonarrConnected}"
Command="{Binding RefreshSonarrEpisodeMatch}">
<Grid>
<StackPanel Orientation="Horizontal">
<controls:ImageIcon Source="../Assets/sonarr.png" Width="25" Height="25" />
<TextBlock Margin="5 0 0 0" Text="Rematch Episodes"></TextBlock>
</StackPanel>
<ToolTip.Tip>
<TextBlock Text="Rematch all Sonarr Episodes" FontSize="15" />
</ToolTip.Tip>
</Grid>
</Button>
</StackPanel>
@ -366,20 +388,21 @@
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<controls:SymbolIcon Grid.Column="0" IsVisible="{Binding !IsEpisodeAvailableOnStreamingService}"
Margin="0 0 5 0 "
Symbol="AlertOn"
FontSize="18"
<controls:SymbolIcon Grid.Column="0" IsVisible="{Binding !IsEpisodeAvailableOnStreamingService}"
Margin="0 0 5 0 "
Symbol="AlertOn"
FontSize="18"
HorizontalAlignment="Center"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Episode unavailable — it might not be available on the streaming service" FontSize="15" />
<TextBlock Text="Episode unavailable — it might not be available on the streaming service or got moved to a different season" FontSize="15" />
</ToolTip.Tip>
</controls:SymbolIcon>
<StackPanel Grid.Column="1" VerticalAlignment="Center" HorizontalAlignment="Left">
<StackPanel Grid.Column="1" VerticalAlignment="Center">
<ui:EpisodeHighlightTextBlock
Series="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries}"
Season="{Binding $parent[controls:SettingsExpander].((history:HistorySeason)DataContext)}"
@ -425,9 +448,13 @@
<TextBlock Text="{Binding ReleaseDateFormated}" VerticalAlignment="Center" FontSize="15" Opacity="0.8" Margin="0 0 20 0"></TextBlock>
<StackPanel VerticalAlignment="Center" Margin="0 0 5 0" Orientation="Horizontal"
IsVisible="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).SonarrAvailable}">
<TextBlock Text="{Binding SonarrSeasonEpisodeText}" VerticalAlignment="Center" FontSize="15" Opacity="0.8" Margin="0 0 20 0"></TextBlock>
<StackPanel IsVisible="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).ShowMonitoredBookmark}"
HorizontalAlignment="Center"
@ -449,13 +476,21 @@
</controls:SymbolIcon>
</StackPanel>
<controls:ImageIcon IsVisible="{Binding SonarrHasFile}"
Source="../Assets/sonarr.png" Width="25"
Height="25" />
<Button Width="34" Height="34" Margin="0 0 10 0" Background="Transparent"
BorderThickness="0" CornerRadius="50"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).MatchSonarrEpisode_Button}"
CommandParameter="{Binding }">
<StackPanel>
<controls:ImageIcon IsVisible="{Binding SonarrHasFile}"
Source="../Assets/sonarr.png" Width="25"
Height="25" />
<controls:ImageIcon IsVisible="{Binding !SonarrHasFile}"
Source="../Assets/sonarr_inactive.png" Width="25"
Height="25" />
<controls:ImageIcon IsVisible="{Binding !SonarrHasFile}"
Source="../Assets/sonarr_inactive.png" Width="25"
Height="25" />
</StackPanel>
</Button>
</StackPanel>

View file

@ -17,19 +17,5 @@ public partial class SettingsPageView : UserControl{
SonarrClient.Instance.RefreshSonarr();
}
}
private void ListBox_PointerWheelChanged(object sender, Avalonia.Input.PointerWheelEventArgs e){
var listBox = sender as ListBox;
var scrollViewer = listBox?.GetVisualDescendants().OfType<ScrollViewer>().FirstOrDefault();
if (scrollViewer != null){
// Determine if the ListBox is at its bounds (top or bottom)
bool atTop = scrollViewer.Offset.Y <= 0 && e.Delta.Y > 0;
bool atBottom = scrollViewer.Offset.Y >= scrollViewer.Extent.Height - scrollViewer.Viewport.Height && e.Delta.Y < 0;
if (atTop || atBottom){
e.Handled = true; // Stop the event from propagating to the parent
}
}
}
}

View file

@ -3,12 +3,12 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:CRD.ViewModels"
xmlns:avalonia="clr-namespace:TheArtOfDev.HtmlRenderer.Avalonia;assembly=Avalonia.HtmlRenderer"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
x:DataType="vm:UpdateViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.UpdateView">
<Grid>
<Grid.RowDefinitions>
@ -59,25 +59,37 @@
</StackPanel>
<ScrollViewer Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" HorizontalAlignment="Center" IsVisible="{Binding !Updating}" MaxWidth="700"
<ScrollViewer Grid.Row="1"
Grid.Column="0"
Grid.ColumnSpan="3"
HorizontalAlignment="Stretch"
IsVisible="{Binding !Updating}"
Margin="10"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<avalonia:HtmlLabel Name="HtmlContent"
Text="{Binding ChangelogText}"
Margin="10"
VerticalAlignment="Stretch" />
<Border HorizontalAlignment="Center"
MaxWidth="800">
<ItemsControl ItemsSource="{Binding ChangelogBlocks}">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel />
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
</ItemsControl>
</Border>
</ScrollViewer>
<!-- Update Progress Section -->
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Spacing="10" IsVisible="{Binding Updating}">
<TextBlock IsVisible="{Binding !Failed}" Text="Please wait while the update is being downloaded..." HorizontalAlignment="Center" Margin="0,10,0,20" />
<TextBlock IsVisible="{Binding Failed}" Foreground="IndianRed" Text="Update failed check the log for more information" HorizontalAlignment="Center" Margin="0,10,0,20" />
<ProgressBar Minimum="0" Maximum="100" Value="{Binding Progress}" HorizontalAlignment="Center" VerticalAlignment="Center" Width="350" />
<StackPanel Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" Spacing="10" VerticalAlignment="Center" HorizontalAlignment="Center" IsVisible="{Binding Updating}">
<TextBlock IsVisible="{Binding !Failed}" FontSize="24" Text="Please wait while the update is being downloaded..." HorizontalAlignment="Center" Margin="0,10,0,20" />
<TextBlock IsVisible="{Binding Failed}" FontSize="24" Foreground="IndianRed" Text="Update failed check the log for more information" HorizontalAlignment="Center" Margin="0,10,0,20" />
<ProgressBar Minimum="0" Maximum="100" FontSize="24" Value="{Binding Progress}" HorizontalAlignment="Center" VerticalAlignment="Center" Width="350" />
</StackPanel>
</Grid>
</UserControl>

View file

@ -0,0 +1,167 @@
<ui:CustomContentDialog xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:CRD.ViewModels.Utils"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:ui="clr-namespace:CRD.Utils.UI"
x:DataType="vm:ContentDialogSonarrMatchEpisodeViewModel"
x:Class="CRD.Views.Utils.ContentDialogSonarrMatchEpisodeView">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Grid.Row="1" CornerRadius="10" Background="{DynamicResource ButtonBackground}">
<Grid Margin="10" VerticalAlignment="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Grid Grid.Column="1" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <!-- Takes up most space for the title -->
<ColumnDefinition Width="Auto" />
<!-- Takes up space as needed for the time -->
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" FontSize="16" FontWeight="Bold" Text="History Episode:" />
<StackPanel Grid.Row="1" Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="S"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentHistoryEpisode.EpisodeSeasonNum}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="E"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentHistoryEpisode.Episode}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text=" - "
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentHistoryEpisode.EpisodeTitle}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Row="1" Grid.Column="1" Text="{Binding CurrentHistoryEpisode.EpisodeCrPremiumAirDate}" FontStyle="Italic"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Row="2" Grid.Column="0" FontSize="16" FontWeight="Bold" Text="Sonarr Episode:" />
<StackPanel Grid.Row="3" Grid.Column="0" Orientation="Horizontal">
<TextBlock Text="S"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentSonarrEpisode.SeasonNumber}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="E"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentSonarrEpisode.EpisodeNumber}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text=" ("
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentSonarrEpisode.AbsoluteEpisodeNumber}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text=") - "
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Text="{Binding CurrentSonarrEpisode.Title}"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
</StackPanel>
<TextBlock Grid.Row="3" Grid.Column="1" Text="{Binding CurrentSonarrEpisode.AirDateUtc}" FontStyle="Italic"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="0" FontSize="1" Text=" " Width="1500" Opacity="0" />
</Grid>
</Grid>
</Border>
<ScrollViewer Grid.Row="3" VerticalScrollBarVisibility="Auto" HorizontalScrollBarVisibility="Disabled">
<ItemsControl ItemsSource="{Binding SonarrSeasonList}">
<ItemsControl.ItemTemplate>
<DataTemplate>
<controls:SettingsExpander
Header="{Binding SeasonName}"
ItemsSource="{Binding Episodes}"
IsExpanded="{Binding IsExpanded}">
<controls:SettingsExpander.ItemTemplate>
<DataTemplate>
<Grid VerticalAlignment="Center">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Column="1" VerticalAlignment="Center" Orientation="Horizontal">
<TextBlock Text="E" />
<TextBlock Text="{Binding EpisodeNumber}" />
<TextBlock Text=" (" />
<TextBlock Text="{Binding AbsoluteEpisodeNumber}" />
<TextBlock Text=") - " />
<TextBlock Text="{Binding Title}" />
</StackPanel>
<StackPanel Grid.Column="2" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="{Binding AirDateUtc}"
VerticalAlignment="Center"
FontSize="15"
Opacity="0.8"
Margin="0 0 20 0" />
<Button Margin="0 0 10 0"
Command="{Binding $parent[ScrollViewer].((vm:ContentDialogSonarrMatchEpisodeViewModel)DataContext).SetSonarrEpisodeMatch}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="Accept" FontSize="18" />
</StackPanel>
<ToolTip.Tip>
<TextBlock Text="Set Sonarr Episode" FontSize="15" />
</ToolTip.Tip>
</Button>
</StackPanel>
</Grid>
</DataTemplate>
</controls:SettingsExpander.ItemTemplate>
</controls:SettingsExpander>
</DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer>
</Grid>
</ui:CustomContentDialog>

View file

@ -0,0 +1,12 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using CRD.Utils.UI;
namespace CRD.Views.Utils;
public partial class ContentDialogSonarrMatchEpisodeView : UserControl{
public ContentDialogSonarrMatchEpisodeView(){
InitializeComponent();
}
}

View file

@ -6,8 +6,8 @@
xmlns:asyncImageLoader="clr-namespace:AsyncImageLoader;assembly=AsyncImageLoader.Avalonia"
x:DataType="vm:ContentDialogSonarrMatchViewModel"
x:Class="CRD.Views.Utils.ContentDialogSonarrMatchView">
<Grid HorizontalAlignment="Stretch" MaxHeight="500" >
<Grid HorizontalAlignment="Stretch" MaxHeight="600">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
@ -18,7 +18,7 @@
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Border Grid.Column="0" Grid.Row="1" CornerRadius="10" Background="{DynamicResource ButtonBackground}">
<Grid Margin="10" VerticalAlignment="Top">
<Grid.ColumnDefinitions>
@ -27,7 +27,7 @@
</Grid.ColumnDefinitions>
<!-- Image -->
<Image Grid.Column="0" Margin="10" asyncImageLoader:ImageLoader.Source="{Binding CurrentSonarrSeries.ImageUrl}" MaxWidth="120" MaxHeight="180"></Image>
<Grid Grid.Column="1" Margin="10">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <!-- Takes up most space for the title -->
@ -37,62 +37,64 @@
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" Grid.Column="0" Text="{Binding CurrentSonarrSeries.Title}" FontWeight="Bold"
FontSize="16"
TextWrapping="Wrap" VerticalAlignment="Center" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding CurrentSonarrSeries.Year}" FontStyle="Italic"
HorizontalAlignment="Right" VerticalAlignment="Center" />
<TextBlock Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2"
<TextBlock Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2" MaxHeight="150"
Text="{Binding CurrentSonarrSeries.Overview}"
FontStyle="Italic" Opacity="0.8" TextWrapping="Wrap" />
<TextBlock Grid.Row="0" Grid.Column="0" FontSize="1" Text=" " Width="1500" Opacity="0" />
</Grid>
</Grid>
</Border>
<!-- <Rectangle Grid.Row="2" Width="1500" Height="0" Fill="Gray" Margin="10,0" /> -->
<!-- <TextBlock Grid.Column="0" Grid.Row="2" Text="Series"></TextBlock> -->
<ListBox Grid.Row="3" SelectedItem="{Binding SelectedItem}" ItemsSource="{Binding SonarrSeriesList}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type models:SonarrSeries}">
<StackPanel>
<StackPanel Height="220">
<Border Padding="10" Margin="5" BorderThickness="1">
<Grid Margin="10" VerticalAlignment="Top">
<Grid VerticalAlignment="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Image -->
<asyncImageLoader:AdvancedImage Grid.Column="0" MaxWidth="120" MaxHeight="180" Source="{Binding ImageUrl}"
Stretch="Fill" />
<!-- Text Content -->
<Grid Grid.Column="1" Margin="10" VerticalAlignment="Top">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" /> <!-- Takes up most space for the title -->
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
<!-- Takes up space as needed for the time -->
</Grid.ColumnDefinitions>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Column="0" Text="{Binding Title}" FontWeight="Bold"
FontSize="16"
TextWrapping="Wrap" />
<TextBlock Grid.Row="0" Grid.Column="1" Text="{Binding Year}" FontStyle="Italic"
HorizontalAlignment="Right" TextWrapping="Wrap" />
<TextBlock Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2"
<TextBlock Grid.Column="0" Grid.Row="1" Grid.ColumnSpan="2" MaxHeight="150"
Text="{Binding Overview}"
FontStyle="Italic" Opacity="0.8" TextWrapping="Wrap" />
<TextBlock Grid.Row="2" Grid.Column="0" FontSize="1" Text=" " Width="1500" Opacity="0" />
</Grid>
</Grid>
</Border>
<Border Background="LightGray" Height="1" Margin="0,5" HorizontalAlignment="Stretch" />
<Border Background="LightGray" Height="1" Margin="0,5" HorizontalAlignment="Stretch" VerticalAlignment="Bottom" />
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>