Add - Added option to **mux fonts into the MKV**

Add - Added **completion sound** when downloads finish, allowing a specified sound to be played
Add - Added **changelog** for easier tracking of changes and updates
Add - Added **Retry button** to reset all failed downloads, allowing "Auto Download" to restart them
Add - Added **dub/sub info** to the Upcoming tab
Chg - Changed **history table view** to include missing features from the poster view
Chg - Changed and **adjusted error messages**
Chg - Changed **chapters formatting** for consistent output across locales
Chg - Changed **Clear Queue** button to icon-only
Chg - Changed **update button** behavior
Chg - Changed some **error messages** for better debugging
Chg - Changed **history series access**, now allowing it to open while others are refreshing
Chg - Changed **device ID reuse** to fix continuous login emails
Chg - Changed **authentication log** to write the full message for better debugging
Chg - Updated **dependencies**
Fix - Temporary fix for **authentication issues**
Fix - Fixed **unable to download movies** [#237](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/237)
Fix - Fixed **buggy queue behavior** during active downloads
Fix - Fixed **duplicate seasons in history** when adding multiple episodes from the calendar
Fix - Fixed crash if  **all** subtitles option is selected
Fix - Fixed "**Cannot set download directory to Drive**" https://github.com/Crunchy-DL/Crunchy-Downloader/issues/220
Fix - Fixed missing **subtitles and none subs** for some series
This commit is contained in:
Elwador 2025-04-04 20:12:29 +02:00
parent 40b7b6d1de
commit aca28a4e17
43 changed files with 1372 additions and 446 deletions

View file

@ -81,8 +81,8 @@ public class CrAuth{
string uuid = Guid.NewGuid().ToString();
var formData = new Dictionary<string, string>{
{ "username", data.Username },
{ "password", data.Password },
{ "username", data.Username },
{ "password", data.Password },
{ "grant_type", "password" },
{ "scope", "offline_access" },
{ "device_id", uuid },
@ -115,9 +115,16 @@ public class CrAuth{
} else{
if (response.ResponseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", ToastType.Error, 10));
} else if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error try to change to BetaAPI in settings", ToastType.Error, 10));
} else{
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - {response.ResponseContent.Substring(0, response.ResponseContent.Length < 200 ? response.ResponseContent.Length : 200)}",
ToastType.Error, 10));
await Console.Error.WriteLineAsync("Full Response: " + response.ResponseContent);
}
}
@ -191,7 +198,7 @@ public class CrAuth{
return;
}
string uuid = Guid.NewGuid().ToString();
string uuid = string.IsNullOrEmpty(crunInstance.Token.device_id) ? Guid.NewGuid().ToString() : crunInstance.Token.device_id;
var formData = new Dictionary<string, string>{
{ "refresh_token", crunInstance.Token.refresh_token },
@ -252,7 +259,7 @@ public class CrAuth{
return;
}
string uuid = Guid.NewGuid().ToString();
string uuid = string.IsNullOrEmpty(crunInstance.Token?.device_id) ? Guid.NewGuid().ToString() : crunInstance.Token.device_id;
var formData = new Dictionary<string, string>{
{ "refresh_token", crunInstance.Token?.refresh_token ?? "" },

View file

@ -25,7 +25,7 @@ public class CrMovies{
}
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Cms}/movies/{id}", HttpMethod.Get, true, true, query);
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Cms}/objects/{id}", HttpMethod.Get, true, true, query);
var response = await HttpClientReq.Instance.SendHttpRequest(request);

View file

@ -454,7 +454,7 @@ public class CrSeries{
query["q"] = searchString;
query["n"] = "6";
query["type"] = "top_results";
query["type"] = "series";
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Search}", HttpMethod.Get, true, false, query);
@ -525,4 +525,29 @@ public class CrSeries{
return complete;
}
public async Task<CrBrowseSeriesBase?> GetSeasonalSeries(string season, string year, string? crLocale){
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
if (!string.IsNullOrEmpty(crLocale)){
query["locale"] = crLocale;
}
query["seasonal_tag"] = season.ToLower() + "-" + year;
query["n"] = "100";
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Browse}", HttpMethod.Get, true, false, query);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
return null;
}
CrBrowseSeriesBase? series = Helpers.Deserialize<CrBrowseSeriesBase>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
return series;
}
}

View file

@ -126,7 +126,7 @@ public class CrunchyrollManager{
options.CustomCalendar = true;
options.DlVideoOnce = true;
options.StreamEndpoint = "web/firefox";
options.SubsAddScaledBorder = ScaledBorderAndShadowSelection.ScaledBorderAndShadowYes;
options.SubsAddScaledBorder = ScaledBorderAndShadowSelection.DontAdd;
options.HistoryLang = DefaultLocale;
options.BackgroundImageOpacity = 0.5;
@ -177,7 +177,7 @@ public class CrunchyrollManager{
optionsYaml.CustomCalendar = true;
optionsYaml.DlVideoOnce = true;
optionsYaml.StreamEndpoint = "web/firefox";
optionsYaml.SubsAddScaledBorder = ScaledBorderAndShadowSelection.ScaledBorderAndShadowYes;
optionsYaml.SubsAddScaledBorder = ScaledBorderAndShadowSelection.DontAdd;
optionsYaml.HistoryLang = DefaultLocale;
optionsYaml.BackgroundImageOpacity = 0.5;
@ -230,6 +230,29 @@ public class CrunchyrollManager{
}
}
public static async Task<string> GetBase64EncodedTokenAsync(){
string url = "https://static.crunchyroll.com/vilos-v2/web/vilos/js/bundle.js";
try{
string jsContent = await HttpClientReq.Instance.GetHttpClient().GetStringAsync(url);
Match match = Regex.Match(jsContent, @"prod=""([\w-]+:[\w-]+)""");
if (!match.Success)
throw new Exception("Token not found in JS file.");
string token = match.Groups[1].Value;
byte[] tokenBytes = Encoding.UTF8.GetBytes(token);
string base64Token = Convert.ToBase64String(tokenBytes);
return base64Token;
} catch (Exception ex){
Console.Error.WriteLine($"Auth Token Fetch Error: {ex.Message}");
return "";
}
}
public async Task Init(){
if (CrunOptions.LogMode){
CfgManager.EnableLogMode();
@ -237,6 +260,12 @@ public class CrunchyrollManager{
CfgManager.DisableLogMode();
}
var token = await GetBase64EncodedTokenAsync();
if (!string.IsNullOrEmpty(token)){
ApiUrls.authBasicMob = "Basic " + token;
}
var jsonFiles = Directory.Exists(CfgManager.PathENCODING_PRESETS_DIR) ? Directory.GetFiles(CfgManager.PathENCODING_PRESETS_DIR, "*.json") :[];
foreach (var file in jsonFiles){
@ -331,6 +360,7 @@ public class CrunchyrollManager{
if (options.SkipMuxing == false){
bool syncError = false;
bool muxError = false;
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
@ -342,6 +372,10 @@ public class CrunchyrollManager{
QueueManager.Instance.Queue.Refresh();
if (options.MuxFonts){
await FontsManager.Instance.GetFontsAsync();
}
var fileNameAndPath = options.DownloadToTempFolder
? Path.Combine(res.TempFolderPath ?? string.Empty, res.FileName ?? string.Empty)
: Path.Combine(res.FolderPath ?? string.Empty, res.FileName ?? string.Empty);
@ -357,6 +391,7 @@ public class CrunchyrollManager{
SkipSubMux = options.SkipSubsMux,
Output = fileNameAndPath,
Mp4 = options.Mp4,
MuxFonts = options.MuxFonts,
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
@ -380,6 +415,10 @@ public class CrunchyrollManager{
mergers.Add(result.merger);
}
if (!result.isMuxed){
muxError = true;
}
if (result.syncError){
syncError = true;
}
@ -417,6 +456,7 @@ public class CrunchyrollManager{
SkipSubMux = options.SkipSubsMux,
Output = fileNameAndPath,
Mp4 = options.Mp4,
MuxFonts = options.MuxFonts,
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
@ -437,12 +477,13 @@ public class CrunchyrollManager{
fileNameAndPath);
syncError = result.syncError;
muxError = !result.isMuxed;
if (result is{ merger: not null, isMuxed: true }){
result.merger.CleanUp();
}
if (options.IsEncodeEnabled){
if (options.IsEncodeEnabled && !muxError){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
@ -469,10 +510,10 @@ public class CrunchyrollManager{
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Done" + (syncError ? " - Couldn't sync dubs" : "")
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? " - Couldn't sync dubs" : "")
};
if (options.RemoveFinishedDownload && !syncError){
if (CrunOptions.RemoveFinishedDownload && !syncError){
QueueManager.Instance.Queue.Remove(data);
}
} else{
@ -498,7 +539,7 @@ public class CrunchyrollManager{
Doing = "Done - Skipped muxing"
};
if (options.RemoveFinishedDownload){
if (CrunOptions.RemoveFinishedDownload){
QueueManager.Instance.Queue.Remove(data);
}
}
@ -515,6 +556,18 @@ public class CrunchyrollManager{
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
}
if (QueueManager.Instance.Queue.Count == 0){
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer();
player.Play(audioPath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
}
}
return true;
}
@ -630,7 +683,7 @@ public class CrunchyrollManager{
bool muxDesc = false;
if (options.MuxDescription){
var descriptionPath = data.Where(a => a.Type == DownloadMediaType.Description).First().Path;
var descriptionPath = data.First(a => a.Type == DownloadMediaType.Description).Path;
if (File.Exists(descriptionPath)){
muxDesc = true;
} else{
@ -649,7 +702,7 @@ public class CrunchyrollManager{
Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
KeepAllVideos = options.KeepAllVideos,
Fonts = FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList),
Fonts = options.MuxFonts ? FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList) :[],
Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(),
VideoTitle = options.VideoTitle,
Options = new MuxOptions(){
@ -714,11 +767,9 @@ public class CrunchyrollManager{
}
if (!options.Mp4 && !muxToMp3){
await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE);
isMuxed = true;
isMuxed = await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE);
} else{
await merger.Merge("ffmpeg", CfgManager.PathFFMPEG);
isMuxed = true;
isMuxed = await merger.Merge("ffmpeg", CfgManager.PathFFMPEG);
}
return (merger, isMuxed, syncError);
@ -916,21 +967,43 @@ public class CrunchyrollManager{
var fetchPlaybackData = await FetchPlaybackData(options, mediaId, mediaGuid, data.Music);
if (!fetchPlaybackData.IsOk){
if (!fetchPlaybackData.IsOk && fetchPlaybackData.error != string.Empty){
var s = fetchPlaybackData.error;
var error = StreamError.FromJson(s);
if (error != null && error.IsTooManyActiveStreamsError()){
var errorJson = fetchPlaybackData.error;
if (!string.IsNullOrEmpty(errorJson)){
var error = StreamError.FromJson(errorJson);
if (error?.IsTooManyActiveStreamsError() == true){
MainWindow.Instance.ShowError("Too many active streams that couldn't be stopped");
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
FileName = "./unknown",
ErrorText = "Too many active streams that couldn't be stopped\nClose open cruchyroll tabs in your browser"
ErrorText = "Too many active streams that couldn't be stopped\nClose open Crunchyroll tabs in your browser"
};
}
if (error?.Error.Contains("Account maturity rating is lower than video rating") == true ||
errorJson.Contains("Account maturity rating is lower than video rating")){
MainWindow.Instance.ShowError("Account maturity rating is lower than video rating\nChange it in the Crunchyroll account settings");
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
FileName = "./unknown",
ErrorText = "Account maturity rating is lower than video rating"
};
}
if (!string.IsNullOrEmpty(error?.Error)){
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
FileName = "./unknown",
ErrorText = "Playback data not found"
};
}
}
MainWindow.Instance.ShowError("Couldn't get Playback Data\nTry again later or else check logs and crunchyroll");
MainWindow.Instance.ShowError("Couldn't get Playback Data\nTry again later or else check logs and Crunchyroll");
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
@ -939,6 +1012,7 @@ public class CrunchyrollManager{
};
}
var pbData = fetchPlaybackData.pbData;
List<string> hsLangs = new List<string>();
@ -1815,7 +1889,11 @@ public class CrunchyrollManager{
}
if (data.DownloadSubs.Contains("all") || data.DownloadSubs.Contains(langItem.CrLocale)){
var subsAssReq = HttpClientReq.CreateRequestMessage(subsItem.url ?? string.Empty, HttpMethod.Get, false, false, null);
if (string.IsNullOrEmpty(subsItem.url)){
continue;
}
var subsAssReq = HttpClientReq.CreateRequestMessage(subsItem.url, HttpMethod.Get, false, false, null);
var subsAssReqResponse = await HttpClientReq.Instance.SendHttpRequest(subsAssReq);
@ -2025,7 +2103,7 @@ public class CrunchyrollManager{
Data = new Dictionary<string, StreamDetails>()
};
var playbackEndpoint = $"https://cr-play-service.prd.crunchyrollsvc.com/v1/{(music ? "music/" : "")}{mediaGuidId}/{options.StreamEndpoint}/play";
var playbackEndpoint = $"https://cr-play-service.prd.crunchyrollsvc.com/v2/{(music ? "music/" : "")}{mediaGuidId}/{options.StreamEndpoint}/play";
var playbackRequestResponse = await SendPlaybackRequestAsync(playbackEndpoint);
if (!playbackRequestResponse.IsOk){
@ -2036,7 +2114,7 @@ public class CrunchyrollManager{
temppbData = await ProcessPlaybackResponseAsync(playbackRequestResponse.ResponseContent, mediaId, mediaGuidId);
} else{
Console.WriteLine("Request Stream URLs FAILED! Attempting fallback");
playbackEndpoint = $"https://cr-play-service.prd.crunchyrollsvc.com/v1/{(music ? "music/" : "")}{mediaGuidId}/web/firefox/play";
playbackEndpoint = $"https://cr-play-service.prd.crunchyrollsvc.com/v2/{(music ? "music/" : "")}{mediaGuidId}/web/firefox/play";
playbackRequestResponse = await SendPlaybackRequestAsync(playbackEndpoint);
if (!playbackRequestResponse.IsOk){
@ -2185,13 +2263,11 @@ public class CrunchyrollManager{
foreach (CrunchyChapter chapter in chapterData.Chapters){
if (chapter.start == null || chapter.end == null) continue;
DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
TimeSpan startTime = TimeSpan.FromSeconds(chapter.start.Value);
TimeSpan endTime = TimeSpan.FromSeconds(chapter.end.Value);
DateTime startTime = epoch.AddSeconds(chapter.start.Value);
DateTime endTime = epoch.AddSeconds(chapter.end.Value);
string startFormatted = startTime.ToString("HH:mm:ss") + ".00";
string endFormatted = endTime.ToString("HH:mm:ss") + ".00";
string startFormatted = startTime.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
string endFormatted = endTime.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
int chapterNumber = (compiledChapters.Count / 2) + 1;
if (chapter.type == "intro"){
@ -2227,19 +2303,12 @@ public class CrunchyrollManager{
if (showRequestResponse.IsOk){
CrunchyOldChapter chapterData = Helpers.Deserialize<CrunchyOldChapter>(showRequestResponse.ResponseContent, SettingsJsonSerializerSettings) ?? new CrunchyOldChapter();
DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc);
TimeSpan startTime = TimeSpan.FromSeconds(chapterData.startTime);
TimeSpan endTime = TimeSpan.FromSeconds(chapterData.endTime);
DateTime startTime = epoch.AddSeconds(chapterData.startTime);
DateTime endTime = epoch.AddSeconds(chapterData.endTime);
string startFormatted = startTime.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
string endFormatted = endTime.ToString(@"hh\:mm\:ss\.fff", CultureInfo.InvariantCulture);
string[] startTimeParts = startTime.ToString(CultureInfo.CurrentCulture).Split('.');
string[] endTimeParts = endTime.ToString(CultureInfo.CurrentCulture).Split('.');
string startMs = startTimeParts.Length > 1 ? startTimeParts[1] : "00";
string endMs = endTimeParts.Length > 1 ? endTimeParts[1] : "00";
string startFormatted = startTime.ToString("HH:mm:ss") + "." + startMs;
string endFormatted = endTime.ToString("HH:mm:ss") + "." + endMs;
int chapterNumber = (compiledChapters.Count / 2) + 1;
if (chapterData.startTime > 1){

View file

@ -58,6 +58,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _muxToMp4;
[ObservableProperty]
private bool _muxFonts;
[ObservableProperty]
private bool _syncTimings;
@ -310,6 +313,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
KeepDubsSeparate = options.KeepDubsSeperate;
DownloadChapters = options.Chapters;
MuxToMp4 = options.Mp4;
MuxFonts = options.MuxFonts;
SyncTimings = options.SyncTiming;
SkipSubMux = options.SkipSubsMux;
LeadingNumbers = options.Numbers;
@ -375,6 +379,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.Chapters = DownloadChapters;
CrunchyrollManager.Instance.CrunOptions.SkipMuxing = SkipMuxing;
CrunchyrollManager.Instance.CrunOptions.Mp4 = MuxToMp4;
CrunchyrollManager.Instance.CrunOptions.MuxFonts = MuxFonts;
CrunchyrollManager.Instance.CrunOptions.SyncTiming = SyncTimings;
CrunchyrollManager.Instance.CrunOptions.SkipSubsMux = SkipSubMux;
CrunchyrollManager.Instance.CrunOptions.Numbers = Math.Clamp((int)(LeadingNumbers ?? 0), 0, 10);

View file

@ -377,6 +377,12 @@
<CheckBox IsChecked="{Binding DefaultSubSigns}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Include Fonts" Description="Includes the fonts in the mkv">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding MuxFonts}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="File title"
Description="${seriesTitle} ${seasonTitle} ${title} ${season} ${episode} ${height} ${width} ${dubs}">

View file

@ -49,9 +49,12 @@ public partial class ProgramManager : ObservableObject{
[ObservableProperty]
private bool _updateAvailable = true;
[ObservableProperty]
private double _opacityButton = 0.4;
[ObservableProperty]
private bool _finishedLoading;
[ObservableProperty]
private bool _navigationLock;
@ -122,6 +125,8 @@ public partial class ProgramManager : ObservableObject{
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
OpacityButton = UpdateAvailable ? 1.0 : 0.4;
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
}
@ -176,9 +181,8 @@ public partial class ProgramManager : ObservableObject{
private void CleanUpOldUpdater(){
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
string backupFilePath = Path.Combine(Directory.GetCurrentDirectory(), $"Updater{executableExtension}.bak");
if (File.Exists(backupFilePath)){

View file

@ -4,6 +4,7 @@ using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.Linq;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.CustomList;
@ -16,7 +17,7 @@ using ReactiveUI;
namespace CRD.Downloader;
public class QueueManager{
public partial class QueueManager : ObservableObject{
#region Download Variables
public RefreshableObservableCollection<CrunchyEpMeta> Queue = new RefreshableObservableCollection<CrunchyEpMeta>();
@ -24,7 +25,9 @@ public class QueueManager{
public int ActiveDownloads;
#endregion
[ObservableProperty]
private bool _hasFailedItem;
#region Singelton
@ -87,8 +90,11 @@ public class QueueManager{
downloadItem.StartDownload();
}
}
}
HasFailedItem = Queue.Any(item => item.DownloadProgress.Error);
}
public async Task CrAddEpisodeToQueue(string epId, string crLocale, List<string> dubLang, bool updateHistory = false, bool onlySubs = false){
if (string.IsNullOrEmpty(epId)){
@ -230,7 +236,9 @@ public class QueueManager{
newOptions.DubLang = dubLang;
movieMeta.DownloadSettings = newOptions;
movieMeta.VideoQuality = CrunchyrollManager.Instance.CrunOptions.QualityVideo;
Queue.Add(movieMeta);
Console.WriteLine("Added Movie to Queue");

29
CRD/Utils/AudioPlayer.cs Normal file
View file

@ -0,0 +1,29 @@
using System;
using NetCoreAudio;
namespace CRD.Utils;
public class AudioPlayer{
private readonly Player _player;
private bool _isPlaying = false;
public AudioPlayer(){
_player = new Player();
}
public async void Play(string path){
if (_isPlaying){
Console.WriteLine("Audio is already playing, ignoring duplicate request.");
return;
}
_isPlaying = true;
await _player.Play(path);
_isPlaying = false;
}
public async void Stop(){
await _player.Stop();
_isPlaying = false;
}
}

View file

@ -39,41 +39,57 @@ public class Widevine{
public Widevine(){
try{
if (Directory.Exists(CfgManager.PathWIDEVINE_DIR)){
var files = Directory.GetFiles(CfgManager.PathWIDEVINE_DIR);
foreach (var file in files){
foreach (var file in Directory.EnumerateFiles(CfgManager.PathWIDEVINE_DIR)){
var fileInfo = new FileInfo(file);
if (fileInfo.Length < 1024 * 8 && !fileInfo.Attributes.HasFlag(FileAttributes.Directory)){
string fileContents = File.ReadAllText(file, Encoding.UTF8);
if (fileContents.Contains("-BEGIN RSA PRIVATE KEY-") || fileContents.Contains("-BEGIN PRIVATE KEY-")){
privateKey = File.ReadAllBytes(file);
}
if (fileInfo.Length >= 1024 * 8 || fileInfo.Attributes.HasFlag(FileAttributes.Directory))
continue;
string fileContents = File.ReadAllText(file, Encoding.UTF8);
if (fileContents.Contains("widevine_cdm_version")){
identifierBlob = File.ReadAllBytes(file);
}
if (IsPrivateKey(fileContents)){
privateKey = File.ReadAllBytes(file);
} else if (IsWidevineIdentifierBlob(fileContents)){
identifierBlob = File.ReadAllBytes(file);
}
}
}
if (privateKey.Length != 0 && identifierBlob.Length != 0){
if (privateKey?.Length > 0 && identifierBlob?.Length > 0){
canDecrypt = true;
} else if (privateKey.Length == 0){
Console.Error.WriteLine("Private key missing");
canDecrypt = false;
} else if (identifierBlob.Length == 0){
Console.Error.WriteLine("Identifier blob missing");
} else{
canDecrypt = false;
if (privateKey == null || privateKey.Length == 0){
Console.Error.WriteLine("Private key missing");
}
if (identifierBlob == null || identifierBlob.Length == 0){
Console.Error.WriteLine("Identifier blob missing");
}
}
} catch (Exception e){
Console.Error.WriteLine("Widevine: " + e);
} catch (IOException ioEx){
Console.Error.WriteLine("I/O error accessing Widevine files: " + ioEx);
canDecrypt = false;
} catch (UnauthorizedAccessException uaEx){
Console.Error.WriteLine("Permission error accessing Widevine files: " + uaEx);
canDecrypt = false;
} catch (Exception ex){
Console.Error.WriteLine("Unexpected Widevine error: " + ex);
canDecrypt = false;
}
Console.WriteLine($"CDM available: {canDecrypt}");
}
private bool IsPrivateKey(string content){
return content.Contains("-BEGIN RSA PRIVATE KEY-", StringComparison.Ordinal) ||
content.Contains("-BEGIN PRIVATE KEY-", StringComparison.Ordinal);
}
private bool IsWidevineIdentifierBlob(string content){
return content.Contains("widevine_cdm_version", StringComparison.Ordinal);
}
public async Task<List<ContentKey>> getKeys(string? pssh, string licenseServer, Dictionary<string, string> authData){
if (pssh == null || !canDecrypt){
Console.Error.WriteLine("Missing pssh or cdm files");

View file

@ -15,36 +15,36 @@ using YamlDotNet.Serialization.NamingConventions;
namespace CRD.Utils.Files;
public class CfgManager{
private static string WorkingDirectory = AppContext.BaseDirectory;
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 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");
public static readonly string PathCrToken = Path.Combine(workingDirectory, "config", "cr_token.json");
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
public static readonly string PathCrHistory = Path.Combine(WorkingDirectory, "config", "history.json");
public static readonly string PathWindowSettings = Path.Combine(WorkingDirectory, "config", "windowSettings.json");
public static readonly string PathCrHistory = Path.Combine(workingDirectory, "config", "history.json");
public static readonly string PathWindowSettings = Path.Combine(workingDirectory, "config", "windowSettings.json");
private static readonly string ExecutableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
public static readonly string PathFFMPEG = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(WorkingDirectory, "lib", "ffmpeg.exe") :
File.Exists(Path.Combine(WorkingDirectory, "lib", "ffmpeg")) ? Path.Combine(WorkingDirectory, "lib", "ffmpeg") : "ffmpeg";
public static readonly string PathFFMPEG = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(workingDirectory, "lib", "ffmpeg.exe") :
File.Exists(Path.Combine(workingDirectory, "lib", "ffmpeg")) ? Path.Combine(workingDirectory, "lib", "ffmpeg") : "ffmpeg";
public static readonly string PathMKVMERGE = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(WorkingDirectory, "lib", "mkvmerge.exe") :
File.Exists(Path.Combine(WorkingDirectory, "lib", "mkvmerge")) ? Path.Combine(WorkingDirectory, "lib", "mkvmerge") : "mkvmerge";
public static readonly string PathMKVMERGE = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? Path.Combine(workingDirectory, "lib", "mkvmerge.exe") :
File.Exists(Path.Combine(workingDirectory, "lib", "mkvmerge")) ? Path.Combine(workingDirectory, "lib", "mkvmerge") : "mkvmerge";
public static readonly string PathMP4Decrypt = Path.Combine(WorkingDirectory, "lib", "mp4decrypt" + ExecutableExtension);
public static readonly string PathShakaPackager = Path.Combine(WorkingDirectory, "lib", "shaka-packager" + ExecutableExtension);
public static readonly string PathMP4Decrypt = Path.Combine(workingDirectory, "lib", "mp4decrypt" + ExecutableExtension);
public static readonly string PathShakaPackager = Path.Combine(workingDirectory, "lib", "shaka-packager" + ExecutableExtension);
public static readonly string PathWIDEVINE_DIR = Path.Combine(WorkingDirectory, "widevine");
public static readonly string PathWIDEVINE_DIR = Path.Combine(workingDirectory, "widevine");
public static readonly string PathVIDEOS_DIR = Path.Combine(WorkingDirectory, "video");
public static readonly string PathENCODING_PRESETS_DIR = Path.Combine(WorkingDirectory, "presets");
public static readonly string PathTEMP_DIR = Path.Combine(WorkingDirectory, "temp");
public static readonly string PathFONTS_DIR = Path.Combine(WorkingDirectory, "fonts");
public static readonly string PathVIDEOS_DIR = Path.Combine(workingDirectory, "video");
public static readonly string PathENCODING_PRESETS_DIR = Path.Combine(workingDirectory, "presets");
public static readonly string PathTEMP_DIR = Path.Combine(workingDirectory, "temp");
public static readonly string PathFONTS_DIR = Path.Combine(workingDirectory, "fonts");
public static readonly string PathLogFile = Path.Combine(WorkingDirectory, "logfile.txt");
public static readonly string PathLogFile = Path.Combine(workingDirectory, "logfile.txt");
private static StreamWriter logFile;
private static bool isLogModeEnabled = false;
@ -290,7 +290,7 @@ public class CfgManager{
}
lock (fileLock){
using (var fileStream = new FileStream(pathToFile, FileMode.Create, FileAccess.Write))
using (var fileStream = new FileStream(pathToFile, FileMode.Create, FileAccess.Write, FileShare.None))
using (var streamWriter = new StreamWriter(fileStream))
using (var jsonWriter = new JsonTextWriter(streamWriter){ Formatting = Formatting.Indented }){
var serializer = new JsonSerializer();

View file

@ -453,6 +453,8 @@ public class HlsDownloader{
Console.WriteLine($"\tError: {ex.Message}");
if (attempt == retryCount)
throw; // rethrow after last retry
await Task.Delay(_data.WaitTime);
}
}
}

View file

@ -237,7 +237,11 @@ public class Helpers{
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
if (e.Data.StartsWith("Error:")){
Console.Error.WriteLine(e.Data);
} else{
Console.WriteLine(e.Data);
}
}
};

View file

@ -216,6 +216,11 @@ public class HttpClientReq{
public static HttpRequestMessage CreateRequestMessage(string uri, HttpMethod requestMethod, bool authHeader, bool disableDrmHeader, NameValueCollection? query){
if (string.IsNullOrEmpty(uri)){
Console.Error.WriteLine($" Request URI is empty");
return new HttpRequestMessage(HttpMethod.Get, "about:blank");
}
UriBuilder uriBuilder = new UriBuilder(uri);
if (query != null){
@ -263,12 +268,8 @@ public static class ApiUrls{
public static readonly string BetaBrowse = ApiBeta + "/content/v1/browse";
public static readonly string BetaCms = ApiBeta + "/cms/v2";
public static readonly string DRM = ApiBeta + "/drm/v1/auth";
public static string authBasicMob = "Basic eHVuaWh2ZWRidDNtYmlzdWhldnQ6MWtJUzVkeVR2akUwX3JxYUEzWWVBaDBiVVhVbXhXMTE=";
public static readonly string authBasic = "Basic bm9haWhkZXZtXzZpeWcwYThsMHE6";
public static readonly string authBasicMob = "Basic ZG1yeWZlc2NkYm90dWJldW56NXo6NU45aThPV2cyVmtNcm1oekNfNUNXekRLOG55SXo0QU0=";
public static readonly string authBasicSwitch = "Basic dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4=";
public static readonly string ChromeUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36";
public static readonly string MobileUserAgent = "Crunchyroll/3.74.2 Android/14 okhttp/4.12.0";
public static readonly string MobileUserAgent = "Crunchyroll/3.78.3 Android/15 okhttp/4.12.0";
}

View file

@ -6,6 +6,7 @@ using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Views;
namespace CRD.Utils.Muxing;
@ -65,7 +66,7 @@ public class FontsManager{
var fontLoc = Path.Combine(CfgManager.PathFONTS_DIR, font);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length != 0){
Console.WriteLine($"{font} already downloaded!");
// Console.WriteLine($"{font} already downloaded!");
} else{
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){
@ -82,19 +83,18 @@ public class FontsManager{
var fontUrl = root + font;
using (var httpClient = HttpClientReq.Instance.GetHttpClient()){
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
var httpClient = HttpClientReq.Instance.GetHttpClient();
try{
var response = await httpClient.GetAsync(fontUrl);
if (response.IsSuccessStatusCode){
var fontData = await response.Content.ReadAsByteArrayAsync();
await File.WriteAllBytesAsync(fontLoc, fontData);
Console.WriteLine($"Downloaded: {font}");
} else{
Console.Error.WriteLine($"Failed to download: {font}");
}
} catch (Exception e){
Console.Error.WriteLine($"Error downloading {font}: {e.Message}");
}
}
}
@ -171,6 +171,8 @@ public class FontsManager{
Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count);
}
List<string> missingFonts = new List<string>();
foreach (var f in fontsNameList){
if (Fonts.TryGetValue(f.Key, out var fontFiles)){
foreach (var fontFile in fontFiles){
@ -180,9 +182,15 @@ public class FontsManager{
fontsList.Add(new ParsedFont{ Name = fontFile, Path = fontPath, Mime = mime });
}
}
} else{
missingFonts.Add(f.Key);
}
}
if (missingFonts.Count > 0){
MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}");
}
return fontsList;
}
}

View file

@ -385,7 +385,7 @@ public class Merger{
}
public async Task Merge(string type, string bin){
public async Task<bool> Merge(string type, string bin){
string command = type switch{
"ffmpeg" => FFmpeg(),
"mkvmerge" => MkvMerge(),
@ -394,7 +394,7 @@ public class Merger{
if (string.IsNullOrEmpty(command)){
Console.Error.WriteLine("Unable to merge files.");
return;
return false;
}
Console.WriteLine($"[{type}] Started merging");
@ -404,9 +404,12 @@ public class Merger{
Console.WriteLine($"[{type}] Mkvmerge finished with at least one warning");
} else if (!result.IsOk){
Console.Error.WriteLine($"[{type}] Merging failed with exit code {result.ErrorCode}");
return false;
} else{
Console.WriteLine($"[{type} Done]");
}
return true;
}
@ -459,6 +462,7 @@ public class CrunchyMuxOptions{
public bool? KeepAllVideos{ get; set; }
public bool? Novids{ get; set; }
public bool Mp4{ get; set; }
public bool MuxFonts{ get; set; }
public bool MuxDescription{ get; set; }
public string ForceMuxer{ get; set; }
public bool? NoCleanup{ get; set; }

View file

@ -64,6 +64,16 @@ public partial class AnilistSeries : ObservableObject{
[JsonIgnore]
[ObservableProperty]
public bool _isInHistory;
[ObservableProperty]
public bool _isExpanded;
[JsonIgnore]
public List<string> AudioLocales{ get; set; } =[];
[JsonIgnore]
public List<string> SubtitleLocales{ get; set; } =[];
}
public class Title{

View file

@ -47,22 +47,23 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
public event PropertyChangedEventHandler? PropertyChanged;
[RelayCommand]
public void AddEpisodeToQue(){
if (CalendarEpisodes.Count > 0){
foreach (var calendarEpisode in CalendarEpisodes){
calendarEpisode.AddEpisodeToQue();
}
}
public async Task AddEpisodeToQue(){
if (EpisodeUrl != null){
var match = Regex.Match(EpisodeUrl, "/([^/]+)/watch/([^/]+)");
if (match.Success){
var locale = match.Groups[1].Value; // Capture the locale part
var id = match.Groups[2].Value; // Capture the ID part
QueueManager.Instance.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
await QueueManager.Instance.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
}
}
if (CalendarEpisodes.Count > 0){
foreach (var calendarEpisode in CalendarEpisodes){
calendarEpisode.AddEpisodeToQue();
}
}
}
public async Task LoadImage(int width = 0, int height = 0){

View file

@ -36,6 +36,13 @@ public class CrDownloadOptions{
[JsonProperty("background_image_path")]
public string? BackgroundImagePath{ get; set; }
[JsonProperty("download_finished_play_sound")]
public bool DownloadFinishedPlaySound{ get; set; }
[JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; }
[JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; }
@ -180,6 +187,9 @@ public class CrDownloadOptions{
[JsonProperty("mux_mp4")]
public bool Mp4{ get; set; }
[JsonProperty("mux_fonts")]
public bool MuxFonts{ get; set; }
[JsonProperty("mux_video_title")]
public string? VideoTitle{ get; set; }
@ -369,8 +379,7 @@ public class CrDownloadOptionsYaml{
public string? ProxyPassword{ get; set; }
#endregion
#region Crunchyroll Settings
[YamlIgnore]

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Structs.History;
using CRD.Views;
using Newtonsoft.Json;
@ -73,7 +74,7 @@ public class DownloadResponse{
public string? FolderPath{ get; set; }
public string? TempFolderPath{ get; set; }
public string VideoTitle{ get; set; }
public bool Error{ get; set; }
public string ErrorText{ get; set; }
@ -107,14 +108,13 @@ public class StringItem{
public string stringValue{ get; set; }
}
public class WindowSettings
{
public double Width { get; set; }
public double Height { get; set; }
public int ScreenIndex { get; set; }
public int PosX { get; set; }
public int PosY { get; set; }
public bool IsMaximized { get; set; }
public class WindowSettings{
public double Width{ get; set; }
public double Height{ get; set; }
public int ScreenIndex{ get; set; }
public int PosX{ get; set; }
public int PosY{ get; set; }
public bool IsMaximized{ get; set; }
}
public class ToastMessage(string message, ToastType type, int i){
@ -143,4 +143,14 @@ public partial class SeasonViewModel : ObservableObject{
public int Year{ get; set; }
public string Display => $"{Season}\n{Year}";
}
public class SeasonDialogArgs{
public HistorySeries? Series{ get; set; }
public HistorySeason? Season{ get; set; }
public SeasonDialogArgs(HistorySeries? series, HistorySeason? season){
Series = series;
Season = season;
}
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
@ -99,6 +100,12 @@ public class HistorySeries : INotifyPropertyChanged{
[JsonIgnore]
private bool _editModeEnabled;
[JsonIgnore]
public string SeriesFolderPath{ get; set; }
[JsonIgnore]
public bool SeriesFolderPathExists{ get; set; }
#region Settings Override
[JsonIgnore]
@ -213,6 +220,9 @@ public class HistorySeries : INotifyPropertyChanged{
SelectedSubLang.CollectionChanged += Changes;
SelectedDubLang.CollectionChanged += Changes;
UpdateSeriesFolderPath();
Loading = false;
}
@ -516,4 +526,59 @@ public class HistorySeries : INotifyPropertyChanged{
break;
}
}
public void UpdateSeriesFolderPath(){
var season = Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath));
if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){
SeriesFolderPath = SeriesDownloadPath;
SeriesFolderPathExists = true;
}
if (season is{ SeasonDownloadPath: not null }){
try{
var seasonPath = season.SeasonDownloadPath;
var directoryInfo = new DirectoryInfo(seasonPath);
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
if (Directory.Exists(parentFolderPath)){
SeriesFolderPath = parentFolderPath;
SeriesFolderPathExists = true;
}
}
} catch (Exception e){
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}");
}
} else{
string customPath;
if (string.IsNullOrEmpty(SeriesTitle))
return;
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle))
return;
// Check Crunchyroll download directory
var downloadDirPath = CrunchyrollManager.Instance.CrunOptions.DownloadDirPath;
if (!string.IsNullOrEmpty(downloadDirPath)){
customPath = Path.Combine(downloadDirPath, seriesTitle);
} else{
// Fallback to configured VIDEOS_DIR path
customPath = Path.Combine(CfgManager.PathVIDEOS_DIR, seriesTitle);
}
// Check if custom path exists
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
}
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
}
}

View file

@ -40,26 +40,29 @@ public class Languages{
};
public static List<string> SortListByLangList(List<string> langList){
var orderMap = languages.Select((value, index) => new { Value = value.CrLocale, Index = index })
var orderMap = languages.Select((value, index) => new{ Value = value.CrLocale, Index = index })
.ToDictionary(x => x.Value, x => x.Index);
langList.Sort((x, y) =>
{
langList.Sort((x, y) => {
bool xExists = orderMap.ContainsKey(x);
bool yExists = orderMap.ContainsKey(y);
if (xExists && yExists)
return orderMap[x].CompareTo(orderMap[y]); // Sort by main list order
return orderMap[x].CompareTo(orderMap[y]); // Sort by main list order
else if (xExists)
return -1; // x comes before any missing value
return -1; // x comes before any missing value
else if (yExists)
return 1; // y comes before any missing value
return 1; // y comes before any missing value
else
return string.CompareOrdinal(x, y); // Sort alphabetically or by another logic for missing values
return string.CompareOrdinal(x, y); // Sort alphabetically or by another logic for missing values
});
return langList;
}
public static List<string> LocalListToLangList(List<Locale> langList){
return SortListByLangList(langList.Select(seriesMetadataAudioLocale => seriesMetadataAudioLocale.GetEnumMemberValue()).ToList());
}
public static LanguageItem FixAndFindCrLc(string cr_locale){
if (string.IsNullOrEmpty(cr_locale)){
return new LanguageItem();
@ -69,14 +72,14 @@ public class Languages{
return FindLang(str);
}
public static string SubsFile(string fnOutput, string subsIndex, LanguageItem langItem, bool isCC, string ccTag , bool? isSigns = false, string? format = "ass", bool addIndexAndLangCode = true){
public static string SubsFile(string fnOutput, string subsIndex, LanguageItem langItem, bool isCC, string ccTag, bool? isSigns = false, string? format = "ass", bool addIndexAndLangCode = true){
subsIndex = (int.Parse(subsIndex) + 1).ToString().PadLeft(2, '0');
string fileName = $"{fnOutput}";
if (addIndexAndLangCode){
fileName += $".{subsIndex}.{langItem.CrLocale}";
fileName += $".{langItem.CrLocale}"; //.{subsIndex}
}
//removed .{langItem.language} from file name at end
if (isCC){

View file

@ -0,0 +1,19 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using Avalonia.Data.Converters;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
namespace CRD.Utils.UI;
public class UiSeriesSeasonConverter : IMultiValueConverter{
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture){
var series = values.Count > 0 && values[0] is HistorySeries hs ? hs : null;
var season = values.Count > 1 && values[1] is HistorySeason hsn ? hsn : null;
return new SeasonDialogArgs(series, season);
}
public IList<object> ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
}

View file

@ -8,8 +8,10 @@ using System.Net.Http;
using System.Reflection;
using System.Runtime.CompilerServices;
using System.Runtime.InteropServices;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CRD.Utils.Files;
using Newtonsoft.Json;
namespace CRD.Utils.Updater;
@ -17,6 +19,8 @@ public class Updater : INotifyPropertyChanged{
public double progress = 0;
public bool failed = false;
public string latestVersion = "";
#region Singelton
private static Updater? _instance;
@ -47,8 +51,10 @@ public class Updater : INotifyPropertyChanged{
private string downloadUrl = "";
private readonly string tempPath = Path.Combine(CfgManager.PathTEMP_DIR, "Update.zip");
private readonly string extractPath = Path.Combine(CfgManager.PathTEMP_DIR, "ExtractedUpdate");
private readonly string changelogFilePath = Path.Combine(AppContext.BaseDirectory, "CHANGELOG.md");
private readonly string apiEndpoint = "https://api.github.com/repos/Crunchy-DL/Crunchy-Downloader/releases/latest";
private static readonly string apiEndpoint = "https://api.github.com/repos/Crunchy-DL/Crunchy-Downloader/releases";
private static readonly string apiEndpointLatest = apiEndpoint + "/latest";
public async Task<bool> CheckForUpdatesAsync(){
if (File.Exists(tempPath)){
@ -86,19 +92,25 @@ public class Updater : INotifyPropertyChanged{
handler.UseProxy = false;
using (var client = new HttpClient(handler)){
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
var response = await client.GetStringAsync(apiEndpoint);
var releaseInfo = Helpers.Deserialize<dynamic>(response, null);
var response = await client.GetStringAsync(apiEndpointLatest);
var releaseInfo = Helpers.Deserialize<GithubRelease>(response, null);
var latestVersion = releaseInfo.tag_name;
foreach (var asset in releaseInfo.assets){
string assetName = (string)asset.name;
if (assetName.Contains(platformName)){
downloadUrl = asset.browser_download_url;
break;
}
if (releaseInfo == null){
Console.WriteLine($"Failed to get Update info");
return false;
}
latestVersion = releaseInfo.TagName;
if (releaseInfo.Assets != null)
foreach (var asset in releaseInfo.Assets){
string assetName = (string)asset.name;
if (assetName.Contains(platformName)){
downloadUrl = asset.browser_download_url;
break;
}
}
if (string.IsNullOrEmpty(downloadUrl)){
Console.WriteLine($"Failed to get Update url for {platformName}");
return false;
@ -109,10 +121,12 @@ public class Updater : INotifyPropertyChanged{
if (latestVersion != currentVersion){
Console.WriteLine("Update available: " + latestVersion + " - Current Version: " + currentVersion);
_ = UpdateChangelogAsync();
return true;
}
Console.WriteLine("No updates available.");
_ = UpdateChangelogAsync();
return false;
}
} catch (Exception e){
@ -121,6 +135,99 @@ public class Updater : INotifyPropertyChanged{
}
}
public async Task UpdateChangelogAsync(){
var client = HttpClientReq.Instance.GetHttpClient();
client.DefaultRequestHeaders.Add("User-Agent", "C# App");
string existingVersion = GetLatestVersionFromFile();
if (string.IsNullOrEmpty(existingVersion)){
existingVersion = "v1.0.0";
}
if (string.IsNullOrEmpty(latestVersion)){
latestVersion = "v1.0.0";
}
if (existingVersion == latestVersion || Version.Parse(existingVersion.TrimStart('v')) >= Version.Parse(latestVersion.TrimStart('v'))){
Console.WriteLine("CHANGELOG.md is already up to date.");
return;
}
try{
string jsonResponse = await client.GetStringAsync(apiEndpoint); // + "?per_page=100&page=1"
var releases = Helpers.Deserialize<List<GithubRelease>>(jsonResponse, null);
// Filter out pre-releases
if (releases != null){
releases = releases.Where(r => !r.Prerelease).ToList();
if (releases.Count == 0){
Console.WriteLine("No stable releases found.");
return;
}
var newReleases = releases.TakeWhile(r => r.TagName != existingVersion).ToList();
if (newReleases.Count == 0){
Console.WriteLine("CHANGELOG.md is already up to date.");
return;
}
Console.WriteLine($"Adding {newReleases.Count} new releases to CHANGELOG.md...");
AppendNewReleasesToChangelog(newReleases);
Console.WriteLine("CHANGELOG.md updated successfully.");
}
} catch (Exception ex){
Console.Error.WriteLine($"Error updating changelog: {ex.Message}");
}
}
private string GetLatestVersionFromFile(){
if (!File.Exists(changelogFilePath))
return string.Empty;
string[] lines = File.ReadAllLines(changelogFilePath);
foreach (string line in lines){
Match match = Regex.Match(line, @"## \[(v?\d+\.\d+\.\d+)\]");
if (match.Success)
return match.Groups[1].Value;
}
return string.Empty;
}
private void AppendNewReleasesToChangelog(List<GithubRelease> newReleases){
string existingContent = "";
if (File.Exists(changelogFilePath)){
existingContent = File.ReadAllText(changelogFilePath);
}
string newEntries = "";
foreach (var release in newReleases){
string version = release.TagName;
string date = release.PublishedAt.Split('T')[0];
string notes = RemoveUnwantedContent(release.Body);
newEntries += $"## [{version}] - {date}\n\n{notes}\n\n---\n\n";
}
if (string.IsNullOrWhiteSpace(existingContent)){
File.WriteAllText(changelogFilePath, "# Changelog\n\n" + newEntries);
} else{
File.WriteAllText(changelogFilePath, "# Changelog\n\n" + newEntries + existingContent.Substring("# Changelog\n\n".Length));
}
}
private static string RemoveUnwantedContent(string notes){
return Regex.Split(notes, @"##\r\n\r\n### Linux/MacOS Builds", RegexOptions.IgnoreCase)[0].Trim();
}
public async Task DownloadAndUpdateAsync(){
try{
@ -216,4 +323,17 @@ public class Updater : INotifyPropertyChanged{
OnPropertyChanged(nameof(failed));
}
}
public class GithubRelease{
[JsonProperty("tag_name")]
public string TagName{ get; set; } = string.Empty;
public dynamic? Assets{ get; set; }
public string Body{ get; set; } = string.Empty;
[JsonProperty("published_at")]
public string PublishedAt{ get; set; } = string.Empty;
public bool Prerelease{ get; set; }
}
}

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.ObjectModel;
using System.Collections.Specialized;
using System.ComponentModel;
using System.IO;
using System.Linq;
@ -10,6 +11,7 @@ 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;
@ -24,18 +26,23 @@ public partial class DownloadsPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _removeFinished;
[ObservableProperty]
private QueueManager _queueManagerIns;
public DownloadsPageViewModel(){
QueueManager.Instance.UpdateDownloadListItems();
Items = QueueManager.Instance.DownloadItemModels;
QueueManagerIns = QueueManager.Instance;
QueueManagerIns.UpdateDownloadListItems();
Items = QueueManagerIns.DownloadItemModels;
AutoDownload = CrunchyrollManager.Instance.CrunOptions.AutoDownload;
RemoveFinished = CrunchyrollManager.Instance.CrunOptions.RemoveFinishedDownload;
}
partial void OnAutoDownloadChanged(bool value){
CrunchyrollManager.Instance.CrunOptions.AutoDownload = value;
if (value){
QueueManager.Instance.UpdateDownloadListItems();
QueueManagerIns.UpdateDownloadListItems();
}
CfgManager.WriteCrSettings();
@ -48,8 +55,8 @@ public partial class DownloadsPageViewModel : ViewModelBase{
[RelayCommand]
public void ClearQueue(){
var items = QueueManager.Instance.Queue;
QueueManager.Instance.Queue.Clear();
var items = QueueManagerIns.Queue;
QueueManagerIns.Queue.Clear();
foreach (var crunchyEpMeta in items){
if (!crunchyEpMeta.DownloadProgress.Done){
@ -65,6 +72,20 @@ public partial class DownloadsPageViewModel : ViewModelBase{
}
}
}
[RelayCommand]
public void RetryQueue(){
var items = QueueManagerIns.Queue;
foreach (var crunchyEpMeta in items){
if (crunchyEpMeta.DownloadProgress.Error){
crunchyEpMeta.DownloadProgress = new();
}
}
QueueManagerIns.UpdateDownloadListItems();
}
}
public partial class DownloadItemModel : INotifyPropertyChanged{

View file

@ -2,6 +2,7 @@ using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Diagnostics;
using System.Linq;
using System.Threading.Tasks;
using Avalonia;
@ -339,7 +340,6 @@ public partial class HistoryPageViewModel : ViewModelBase{
}
SelectedSeries = null;
}
[RelayCommand]
@ -356,7 +356,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand]
public void NavToSeries(){
if (ProgramManager.FetchingData){
if (ProgramManager.FetchingData && SelectedSeries is{ FetchingData: true }){
return;
}
@ -461,8 +461,9 @@ public partial class HistoryPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public async Task OpenFolderDialogAsyncSeason(HistorySeason? season){
public async Task OpenFolderDialogAsync(SeasonDialogArgs? seriesArgs){
if (_storageProvider == null){
Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
throw new InvalidOperationException("StorageProvider must be set before using the dialog.");
@ -475,38 +476,19 @@ public partial class HistoryPageViewModel : ViewModelBase{
if (result.Count > 0){
var selectedFolder = result[0];
// Do something with the selected folder path
Console.WriteLine($"Selected folder: {selectedFolder.Path.LocalPath}");
var folderPath = selectedFolder.Path.IsAbsoluteUri ? selectedFolder.Path.LocalPath : selectedFolder.Path.ToString();
Console.WriteLine($"Selected folder: {folderPath}");
if (season != null){
season.SeasonDownloadPath = selectedFolder.Path.LocalPath;
if (seriesArgs?.Season != null){
seriesArgs.Season.SeasonDownloadPath = folderPath;
CfgManager.UpdateHistoryFile();
} else if (seriesArgs?.Series != null){
seriesArgs.Series.SeriesDownloadPath = folderPath;
CfgManager.UpdateHistoryFile();
}
}
}
[RelayCommand]
public async Task OpenFolderDialogAsyncSeries(HistorySeries? series){
if (_storageProvider == null){
Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
throw new InvalidOperationException("StorageProvider must be set before using the dialog.");
}
var result = await _storageProvider.OpenFolderPickerAsync(new FolderPickerOpenOptions{
Title = "Select Folder"
});
if (result.Count > 0){
var selectedFolder = result[0];
// Do something with the selected folder path
Console.WriteLine($"Selected folder: {selectedFolder.Path.LocalPath}");
if (series != null){
series.SeriesDownloadPath = selectedFolder.Path.LocalPath;
CfgManager.UpdateHistoryFile();
}
}
seriesArgs?.Series?.UpdateSeriesFolderPath();
}
[RelayCommand]
@ -534,6 +516,34 @@ public partial class HistoryPageViewModel : ViewModelBase{
await Task.WhenAll(downloadTasks);
}
[RelayCommand]
public void ToggleDownloadedMark(SeasonDialogArgs seriesArgs){
if (seriesArgs.Season != null){
bool allDownloaded = seriesArgs.Season.EpisodesList.All(ep => ep.WasDownloaded);
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId);
}
}
}
seriesArgs.Series?.UpdateNewEpisodes();
}
[RelayCommand]
public void OpenFolderPath(HistorySeries? series){
try{
Process.Start(new ProcessStartInfo{
FileName = series?.SeriesFolderPath,
UseShellExecute = true,
Verb = "open"
});
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred while opening the folder: {ex.Message}");
}
}
}
public class HistoryPageProperties{

View file

@ -43,13 +43,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[ObservableProperty]
private string _availableSubs;
[ObservableProperty]
private string _seriesFolderPath;
[ObservableProperty]
public bool _seriesFolderPathExists;
public SeriesPageViewModel(){
_storageProvider = ProgramManager.Instance.StorageProvider ?? throw new ArgumentNullException(nameof(ProgramManager.Instance.StorageProvider));
@ -79,60 +73,10 @@ public partial class SeriesPageViewModel : ViewModelBase{
AvailableDubs = "Available Dubs: " + string.Join(", ", SelectedSeries.HistorySeriesAvailableDubLang);
AvailableSubs = "Available Subs: " + string.Join(", ", SelectedSeries.HistorySeriesAvailableSoftSubs);
UpdateSeriesFolderPath();
SelectedSeries.UpdateSeriesFolderPath();
}
private void UpdateSeriesFolderPath(){
var season = SelectedSeries.Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath));
if (!string.IsNullOrEmpty(SelectedSeries.SeriesDownloadPath) && Directory.Exists(SelectedSeries.SeriesDownloadPath)){
SeriesFolderPath = SelectedSeries.SeriesDownloadPath;
SeriesFolderPathExists = true;
}
if (season is{ SeasonDownloadPath: not null }){
try{
var seasonPath = season.SeasonDownloadPath;
var directoryInfo = new DirectoryInfo(seasonPath);
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
if (Directory.Exists(parentFolderPath)){
SeriesFolderPath = parentFolderPath;
SeriesFolderPathExists = true;
}
}
} catch (Exception e){
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}");
}
} else{
string customPath;
if (string.IsNullOrEmpty(SelectedSeries.SeriesTitle))
return;
var seriesTitle = FileNameManager.CleanupFilename(SelectedSeries.SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle))
return;
// Check Crunchyroll download directory
var downloadDirPath = CrunchyrollManager.Instance.CrunOptions.DownloadDirPath;
if (!string.IsNullOrEmpty(downloadDirPath)){
customPath = Path.Combine(downloadDirPath, seriesTitle);
} else{
// Fallback to configured VIDEOS_DIR path
customPath = Path.Combine(CfgManager.PathVIDEOS_DIR, seriesTitle);
}
// Check if custom path exists
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
}
}
}
[RelayCommand]
public async Task OpenFolderDialogAsync(HistorySeason? season){
@ -148,19 +92,19 @@ public partial class SeriesPageViewModel : ViewModelBase{
if (result.Count > 0){
var selectedFolder = result[0];
// Do something with the selected folder path
Console.WriteLine($"Selected folder: {selectedFolder.Path.LocalPath}");
var folderPath = selectedFolder.Path.IsAbsoluteUri ? selectedFolder.Path.LocalPath : selectedFolder.Path.ToString();
Console.WriteLine($"Selected folder: {folderPath}");
if (season != null){
season.SeasonDownloadPath = selectedFolder.Path.LocalPath;
season.SeasonDownloadPath = folderPath;
CfgManager.UpdateHistoryFile();
} else{
SelectedSeries.SeriesDownloadPath = selectedFolder.Path.LocalPath;
SelectedSeries.SeriesDownloadPath = folderPath;
CfgManager.UpdateHistoryFile();
}
}
UpdateSeriesFolderPath();
SelectedSeries.UpdateSeriesFolderPath();
}
[RelayCommand]
@ -277,7 +221,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
public void OpenFolderPath(){
try{
Process.Start(new ProcessStartInfo{
FileName = SeriesFolderPath,
FileName = SelectedSeries.SeriesFolderPath,
UseShellExecute = true,
Verb = "open"
});

View file

@ -197,8 +197,18 @@ public partial class UpcomingPageViewModel : ViewModelBase{
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false);
SelectedSeason.Clear();
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
foreach (var anilistSeries in list){
SelectedSeason.Add(anilistSeries);
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
if (crunchySeries != null){
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[]));
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[]));
}
}
}
SortItems();
@ -212,8 +222,18 @@ public partial class UpcomingPageViewModel : ViewModelBase{
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false);
SelectedSeason.Clear();
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
foreach (var anilistSeries in list){
SelectedSeason.Add(anilistSeries);
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
var crunchySeries = crunchySimul.Data.FirstOrDefault(ele => ele.Id == anilistSeries.CrunchyrollID);
if (crunchySeries != null){
anilistSeries.AudioLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.AudioLocales ??[]));
anilistSeries.SubtitleLocales.AddRange(Languages.LocalListToLangList(crunchySeries.SeriesMetadata.SubtitleLocales ??[]));
}
}
}
SortItems();
}
@ -411,6 +431,7 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
public void SelectionChangedOfSeries(AnilistSeries? value){
if (value != null) value.IsExpanded = !value.IsExpanded;
SelectedSeries = null;
SelectedIndex = -1;
}

View file

@ -0,0 +1,188 @@
using System;
using System.ComponentModel;
using System.IO;
using System.Reflection;
using System.Text.RegularExpressions;
using Avalonia;
using Avalonia.Media;
using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Utils.Updater;
using Markdig;
namespace CRD.ViewModels;
public partial class UpdateViewModel : ViewModelBase{
[ObservableProperty]
private bool _updateAvailable;
[ObservableProperty]
private bool _updating;
[ObservableProperty]
private double _progress;
[ObservableProperty]
private bool _failed;
private AccountPageViewModel accountPageViewModel;
[ObservableProperty]
private string _changelogText = "<p><strong>No changelog found.</strong></p>";
[ObservableProperty]
private string _currentVersion;
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;
}
[RelayCommand]
public void StartUpdate(){
Updating = true;
ProgramManager.Instance.NavigationLock = true;
// Title = "Updating";
_ = Updater.Instance.DownloadAndUpdateAsync();
}
private void Progress_PropertyChanged(object? sender, PropertyChangedEventArgs e){
if (e.PropertyName == nameof(Updater.Instance.progress)){
Progress = Updater.Instance.progress;
} else if (e.PropertyName == nameof(Updater.Instance.failed)){
Failed = Updater.Instance.failed;
ProgramManager.Instance.NavigationLock = !Failed;
}
}
private void LoadChangelog(){
string changelogPath = "CHANGELOG.md";
if (!File.Exists(changelogPath)){
ChangelogText = "<p><strong>No changelog found.</strong></p>";
return;
}
string markdownText = File.ReadAllText(changelogPath);
markdownText = PreprocessMarkdown(markdownText);
var pipeline = new MarkdownPipelineBuilder()
.UseAdvancedExtensions()
.UseSoftlineBreakAsHardlineBreak()
.Build();
string htmlContent = Markdown.ToHtml(markdownText, pipeline);
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}";
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>";
ChangelogText = styledHtml;
}
private string MakeIssueLinksClickable(string htmlContent){
// Match GitHub issue links
string issuePattern = @"<a href=['""](https:\/\/github\.com\/Crunchy-DL\/Crunchy-Downloader\/issues\/(\d+))['""][^>]*>[^<]+<\/a>";
// Match GitHub discussion links
string discussionPattern = @"<a href=['""](https:\/\/github\.com\/Crunchy-DL\/Crunchy-Downloader\/discussions\/(\d+))['""][^>]*>[^<]+<\/a>";
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>";
});
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>";
});
return htmlContent;
}
private string ModifyImages(string htmlContent){
// Regex to match <img> tags
string imgPattern = @"<img\s+src=['""]([^'""]+)['""]( alt=['""]([^'""]+)['""])?\s*\/?>";
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";
return $"<a href='{imgUrl}' target='_blank'>{altText}</a>";
});
}
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 => {
string imageUrl = match.Groups[1].Value;
string altText = match.Groups[2].Value;
return $"![{altText}]({imageUrl})";
});
}
}

View file

@ -1,44 +0,0 @@
using System;
using System.ComponentModel;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Updater;
using FluentAvalonia.UI.Controls;
namespace CRD.ViewModels.Utils;
public partial class ContentDialogUpdateViewModel : ViewModelBase{
private readonly ContentDialog dialog;
[ObservableProperty]
private double _progress;
[ObservableProperty]
private bool _failed;
private AccountPageViewModel accountPageViewModel;
public ContentDialogUpdateViewModel(ContentDialog dialog){
if (dialog is null){
throw new ArgumentNullException(nameof(dialog));
}
this.dialog = dialog;
dialog.Closed += DialogOnClosed;
Updater.Instance.PropertyChanged += Progress_PropertyChanged;
}
private void Progress_PropertyChanged(object? sender, PropertyChangedEventArgs e){
if (e.PropertyName == nameof(Updater.Instance.progress)){
Progress = Updater.Instance.progress;
}else if (e.PropertyName == nameof(Updater.Instance.failed)){
Failed = Updater.Instance.failed;
dialog.IsPrimaryButtonEnabled = !Failed;
}
}
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
dialog.Closed -= DialogOnClosed;
}
}

View file

@ -38,10 +38,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _historyIncludeCrArtists;
[ObservableProperty]
private bool _historyAddSpecials;
[ObservableProperty]
private bool _historySkipUnmonitored;
@ -193,6 +193,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private string _tempDownloadDirPath;
[ObservableProperty]
private bool _downloadFinishedPlaySound;
[ObservableProperty]
private string _downloadFinishedSoundPath;
[ObservableProperty]
private string _currentIp = "";
@ -208,7 +214,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
var version = Assembly.GetExecutingAssembly().GetName().Version;
_currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}";
_faTheme = App.Current.Styles[0] as FluentAvaloniaTheme;
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme ??[];
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
@ -221,6 +227,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
BackgroundImageBlurRadius = options.BackgroundImageBlurRadius;
BackgroundImageOpacity = options.BackgroundImageOpacity;
BackgroundImagePath = options.BackgroundImagePath ?? string.Empty;
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -269,6 +279,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
return;
}
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
CrunchyrollManager.Instance.CrunOptions.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity = Math.Clamp((BackgroundImageOpacity ?? 0), 0, 1);
@ -371,14 +383,17 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
if (result.Count > 0){
var selectedFolder = result[0];
Console.WriteLine($"Selected folder: {selectedFolder.Path.LocalPath}");
pathSetter(selectedFolder.Path.LocalPath);
var folderPath = selectedFolder.Path.IsAbsoluteUri ? selectedFolder.Path.LocalPath : selectedFolder.Path.ToString();
Console.WriteLine($"Selected folder: {folderPath}");
pathSetter(folderPath);
var finalPath = string.IsNullOrEmpty(pathGetter()) ? defaultPath : pathGetter();
pathSetter(finalPath);
CfgManager.WriteCrSettings();
}
}
#region Background Image
[RelayCommand]
public void ClearBackgroundImagePath(){
CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath = string.Empty;
@ -388,7 +403,13 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[RelayCommand]
public async Task OpenImageFileDialogAsyncInternalBackgroundImage(){
await OpenImageFileDialogAsyncInternal(
await OpenFileDialogAsyncInternal(
title: "Select Image File",
fileTypes: new List<FilePickerFileType>{
new("Image Files"){
Patterns = new[]{ "*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif" }
}
},
pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath = path;
BackgroundImagePath = path;
@ -399,20 +420,51 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
);
}
private async Task OpenImageFileDialogAsyncInternal(Action<string> pathSetter, Func<string> pathGetter, string defaultPath){
#endregion
#region Download Finished Sound
[RelayCommand]
public void ClearFinishedSoundPath(){
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = string.Empty;
DownloadFinishedSoundPath = string.Empty;
}
[RelayCommand]
public async Task OpenImageFileDialogAsyncInternalFinishedSound(){
await OpenFileDialogAsyncInternal(
title: "Select Audio File",
fileTypes: new List<FilePickerFileType>{
new("Audio Files"){
Patterns = new[]{ "*.mp3", "*.wav", "*.ogg", "*.flac", "*.aac" }
}
},
pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
DownloadFinishedSoundPath = path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath,
defaultPath: string.Empty
);
}
#endregion
private async Task OpenFileDialogAsyncInternal(
string title,
List<FilePickerFileType> fileTypes,
Action<string> pathSetter,
Func<string> pathGetter,
string defaultPath){
if (_storageProvider == null){
Console.Error.WriteLine("StorageProvider must be set before using the dialog.");
throw new InvalidOperationException("StorageProvider must be set before using the dialog.");
}
// Open the file picker dialog with only image file types allowed
var result = await _storageProvider.OpenFilePickerAsync(new FilePickerOpenOptions{
Title = "Select Image File",
FileTypeFilter = new List<FilePickerFileType>{
new FilePickerFileType("Image Files"){
Patterns = new[]{ "*.png", "*.jpg", "*.jpeg", "*.bmp", "*.gif" }
}
},
Title = title,
FileTypeFilter = fileTypes,
AllowMultiple = false
});
@ -426,6 +478,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
}
partial void OnCurrentAppThemeChanged(ComboBoxItem? value){
if (value?.Content?.ToString() == "System"){
_faTheme.PreferSystemTheme = true;

View file

@ -23,15 +23,32 @@
<StackPanel Grid.Row="0" Orientation="Horizontal" HorizontalAlignment="Right" VerticalAlignment="Center">
<ToggleSwitch HorizontalAlignment="Right" Margin="0 0 10 0 " IsChecked="{Binding RemoveFinished}" OffContent="Remove Finished" OnContent="Remove Finished"></ToggleSwitch>
<ToggleSwitch HorizontalAlignment="Right" Margin="0 0 10 0 " IsChecked="{Binding AutoDownload}" OffContent="Auto Download" OnContent="Auto Download"></ToggleSwitch>
<Button BorderThickness="0"
HorizontalAlignment="Right"
<Button BorderThickness="0"
HorizontalAlignment="Right"
Margin="0 0 10 0 "
VerticalAlignment="Center"
Command="{Binding ClearQueue}">
IsEnabled="{Binding QueueManagerIns.HasFailedItem}"
Command="{Binding RetryQueue}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<controls:SymbolIcon Symbol="Refresh" FontSize="22" />
</StackPanel>
<ToolTip.Tip>
<TextBlock Text="Retry failed" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap"></TextBlock>
</ToolTip.Tip>
</Button>
<Button BorderThickness="0"
HorizontalAlignment="Right"
Margin="0 0 10 0 "
VerticalAlignment="Center"
Command="{Binding ClearQueue}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Center">
<controls:SymbolIcon Symbol="Delete" FontSize="22" />
<TextBlock Text="Clear Queue" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" ></TextBlock>
</StackPanel>
<ToolTip.Tip>
<TextBlock Text="Clear Queue" HorizontalAlignment="Center" VerticalAlignment="Center" TextWrapping="Wrap" ></TextBlock>
</ToolTip.Tip>
</Button>
</StackPanel>

View file

@ -1,4 +1,6 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using CRD.ViewModels;
namespace CRD.Views;
@ -6,4 +8,5 @@ public partial class DownloadsPageView : UserControl{
public DownloadsPageView(){
InitializeComponent();
}
}

View file

@ -7,7 +7,6 @@
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:history="clr-namespace:CRD.Utils.Structs.History"
xmlns:structs="clr-namespace:CRD.Utils.Structs"
xmlns:local="clr-namespace:CRD.Utils"
x:DataType="vm:HistoryPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.HistoryPageView">
@ -15,7 +14,11 @@
<UserControl.Resources>
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
<ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" />
<ui:UiListToStringConverter x:Key="UiListToStringConverter" />
<ui:UiListHasElementsConverter x:Key="UiListHasElementsConverter" />
<ui:UiSeriesSeasonConverter x:Key="UiSeriesSeasonConverter"/>
</UserControl.Resources>
<Grid>
@ -25,7 +28,7 @@
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" /> <!-- Takes up most space for the title -->
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
@ -393,13 +396,26 @@
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<TextBlock Grid.Row="0" FontSize="25" Text="{Binding SeriesTitle}"></TextBlock>
<TextBlock Grid.Row="1" FontSize="15" TextWrapping="Wrap"
<TextBlock Grid.Row="0" FontSize="25" Text="{Binding SeriesTitle}" TextTrimming="CharacterEllipsis"></TextBlock>
<TextBlock Grid.Row="1" FontSize="15" Margin="0 0 0 5" TextWrapping="Wrap"
Text="{Binding SeriesDescription}">
</TextBlock>
<StackPanel Grid.Row="3" Orientation="Vertical">
<StackPanel Grid.Row="3" Orientation="Horizontal" IsVisible="{Binding HistorySeriesAvailableDubLang, Converter={StaticResource UiListHasElementsConverter}}">
<TextBlock FontSize="15" Opacity="0.8" Text="Available Dubs: " />
<TextBlock FontSize="15" Opacity="0.8" TextWrapping="Wrap" Text="{Binding HistorySeriesAvailableDubLang, Converter={StaticResource UiListToStringConverter}}"></TextBlock>
</StackPanel>
<StackPanel Grid.Row="4" Orientation="Horizontal" IsVisible="{Binding HistorySeriesAvailableSoftSubs, Converter={StaticResource UiListHasElementsConverter}}">
<TextBlock FontSize="15" Opacity="0.8" Text="Available Subs: " />
<TextBlock FontSize="15" Opacity="0.8" TextWrapping="Wrap" Text="{Binding HistorySeriesAvailableSoftSubs, Converter={StaticResource UiListToStringConverter}}"></TextBlock>
</StackPanel>
<StackPanel Grid.Row="5" Orientation="Vertical">
<StackPanel Orientation="Horizontal" Margin="0 10 10 10">
@ -427,6 +443,20 @@
Height="30" />
</Grid>
</Button>
<Button Width="34" Height="34" Margin="0 0 10 0" Background="Transparent"
BorderThickness="0" CornerRadius="50"
IsVisible="{Binding SeriesFolderPathExists}"
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).OpenFolderPath}"
CommandParameter="{Binding .}">
<ToolTip.Tip>
<TextBlock Text="Open Series Folder" FontSize="15" />
</ToolTip.Tip>
<Grid>
<!-- <controls:ImageIcon Source="../Assets/sonarr.png" Width="30" Height="30" /> -->
<controls:SymbolIcon Symbol="Folder" FontSize="20" Width="30" Height="30" />
</Grid>
</Button>
</StackPanel>
@ -437,8 +467,13 @@
<Button Margin="0 0 5 10" FontStyle="Italic"
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).OpenFolderDialogAsyncSeries}"
CommandParameter="{Binding .}">
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).OpenFolderDialogAsync}"
>
<Button.CommandParameter>
<MultiBinding Converter="{StaticResource UiSeriesSeasonConverter}">
<Binding />
</MultiBinding>
</Button.CommandParameter>
<ToolTip.Tip>
<TextBlock Text="{Binding SeriesDownloadPath}"
FontSize="15" />
@ -613,7 +648,7 @@
<controls:SettingsExpanderItem>
<ScrollViewer>
<ScrollViewer x:Name="TableViewScrollViewer">
<ItemsControl ItemsSource="{Binding Seasons}">
<ItemsControl.ItemTemplate>
<DataTemplate DataType="{x:Type history:HistorySeason}">
@ -705,6 +740,19 @@
FontSize="18" />
</StackPanel>
</Button>
<Button Margin="0 0 5 0" FontStyle="Italic" HorizontalAlignment="Right"
VerticalAlignment="Center"
IsEnabled="{Binding HistoryEpisodeAvailableSoftSubs, Converter={StaticResource UiListHasElementsConverter}}"
Command="{Binding DownloadEpisode}"
CommandParameter="true">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="ClosedCaption" FontSize="18" />
</StackPanel>
<ToolTip.Tip>
<TextBlock Text="Download Subs" FontSize="15" />
</ToolTip.Tip>
</Button>
</StackPanel>
</Grid>
@ -770,16 +818,34 @@
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).DownloadSeasonMissingSonarr}"
CommandParameter="{Binding }">
</Button>
<Button Margin="10 5"
VerticalAlignment="Center"
HorizontalAlignment="Stretch"
Content="Toggle Downloaded"
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).ToggleDownloadedMark}">
<Button.CommandParameter>
<MultiBinding Converter="{StaticResource UiSeriesSeasonConverter}">
<Binding Path="DataContext" ElementName="TableViewScrollViewer" />
<Binding />
</MultiBinding>
</Button.CommandParameter>
</Button>
</StackPanel>
</Border>
</Popup>
</StackPanel>
<Button Margin="10 0 0 0" FontStyle="Italic"
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).OpenFolderDialogAsyncSeason}"
CommandParameter="{Binding .}">
Command="{Binding $parent[UserControl].((vm:HistoryPageViewModel)DataContext).OpenFolderDialogAsync}"
>
<Button.CommandParameter>
<MultiBinding Converter="{StaticResource UiSeriesSeasonConverter}">
<Binding Path="DataContext" RelativeSource="{RelativeSource AncestorType=ScrollViewer}" />
<Binding />
</MultiBinding>
</Button.CommandParameter>
<ToolTip.Tip>
<TextBlock Text="{Binding SeasonDownloadPath}"
FontSize="15" />

View file

@ -5,6 +5,7 @@
xmlns:vm="clr-namespace:CRD.ViewModels"
xmlns:ui="using:FluentAvalonia.UI.Controls"
xmlns:views="clr-namespace:CRD.Views"
xmlns:ui1="clr-namespace:CRD.Utils.UI"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.MainWindow"
x:DataType="vm:MainWindowViewModel"
@ -68,8 +69,8 @@
IconSource="Library" />
</ui:NavigationView.MenuItems>
<ui:NavigationView.FooterMenuItems>
<ui:NavigationViewItem Classes="SampleAppNav" Content="Update Available" Tag="UpdateAvailable"
IconSource="CloudDownload" Focusable="False" IsEnabled="{Binding ProgramManager.UpdateAvailable}" />
<ui:NavigationViewItem Classes="SampleAppNav" Content="Update" Tag="Update" Opacity="{Binding ProgramManager.OpacityButton}"
IconSource="CloudDownload" Focusable="False" />
<ui:NavigationViewItem Classes="SampleAppNav" Content="Account" Tag="Account"
IconSource="Contact" />
<ui:NavigationViewItem Classes="SampleAppNav" Content="Settings" Tag="Settings"

View file

@ -10,14 +10,12 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Updater;
using CRD.ViewModels;
using CRD.Views.Utils;
using FluentAvalonia.UI.Controls;
using FluentAvalonia.UI.Windowing;
using Newtonsoft.Json;
using ReactiveUI;
using ContentDialogUpdateViewModel = CRD.ViewModels.Utils.ContentDialogUpdateViewModel;
using UpdateViewModel = CRD.ViewModels.UpdateViewModel;
namespace CRD.Views;
@ -163,9 +161,9 @@ public partial class MainWindow : AppWindow{
navView.Content = viewModel;
selectedNavVieItem = selectedItem;
break;
case "UpdateAvailable":
Updater.Instance.DownloadAndUpdateAsync();
ShowUpdateDialog();
case "Update":
navView.Content = Activator.CreateInstance(typeof(UpdateViewModel));
selectedNavVieItem = selectedItem;
break;
default:
// (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
@ -174,23 +172,7 @@ public partial class MainWindow : AppWindow{
}
}
}
public async void ShowUpdateDialog(){
var dialog = new ContentDialog(){
Title = "Updating",
PrimaryButtonText = "Close"
};
dialog.IsPrimaryButtonEnabled = false;
var viewModel = new ContentDialogUpdateViewModel(dialog);
dialog.Content = new ContentDialogUpdateView(){
DataContext = viewModel
};
_ = await dialog.ShowAsync();
}
private void OnOpened(object sender, EventArgs e){
if (File.Exists(CfgManager.PathWindowSettings)){
var settings = JsonConvert.DeserializeObject<WindowSettings>(File.ReadAllText(CfgManager.PathWindowSettings));

View file

@ -89,7 +89,7 @@
<Button Width="34" Height="34" Margin="0 0 10 0" Background="Transparent"
BorderThickness="0" CornerRadius="50"
IsVisible="{Binding SeriesFolderPathExists}"
IsVisible="{Binding SelectedSeries.SeriesFolderPathExists}"
Command="{Binding OpenFolderPath}">
<ToolTip.Tip>
<TextBlock Text="Open Series Folder" FontSize="15" />

View file

@ -5,10 +5,16 @@
xmlns:vm="clr-namespace:CRD.ViewModels"
xmlns:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
xmlns:structs="clr-namespace:CRD.Utils.Structs"
xmlns:ui="clr-namespace:CRD.Utils.UI"
x:DataType="vm:UpcomingPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.UpcomingPageView">
<UserControl.Resources>
<ui:UiListToStringConverter x:Key="UiListToStringConverter" />
<ui:UiListHasElementsConverter x:Key="UiListHasElementsConverter" />
</UserControl.Resources>
<Grid>
<Grid.RowDefinitions>
@ -140,6 +146,141 @@
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<Expander Grid.Column="0" Grid.ColumnSpan="2" ExpandDirection="Right" IsExpanded="{Binding IsExpanded}">
<Expander.Styles>
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="ToggleButton:pointerover /template/ Border#ExpandCollapseChevronBorder">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ToggleButton:not(:checked) /template/ TextBlock#ExpandCollapseChevron">
<Setter Property="Foreground" Value="Transparent" />
</Style>
<Style Selector="ToggleButton:checked /template/ TextBlock#ExpandCollapseChevron">
<Setter Property="Foreground" Value="Transparent" />
</Style>
</Expander.Styles>
<Expander.Header>
<Border Width="117" Height="315" />
</Expander.Header>
<Expander.Content>
<StackPanel>
<ScrollViewer MaxHeight="265" MinHeight="265" PointerWheelChanged="ScrollViewer_PointerWheelChanged" Margin="0 0 0 5">
<TextBlock HorizontalAlignment="Center" TextAlignment="Center"
Text="{Binding Description}"
TextWrapping="Wrap"
Width="185"
FontSize="16"
Margin="5">
</TextBlock>
</ScrollViewer>
<Grid MaxWidth="185" IsVisible="{Binding AudioLocales, Converter={StaticResource UiListHasElementsConverter}}">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Dubs -->
<TextBlock Grid.Row="0" Grid.Column="0"
FontStyle="Italic"
FontSize="12"
Opacity="0.8"
Text="Dubs: " />
<TextBlock Grid.Row="0" Grid.Column="1"
FontStyle="Italic"
FontSize="12"
Opacity="0.8"
Text="{Binding AudioLocales, Converter={StaticResource UiListToStringConverter}}"
TextWrapping="NoWrap" />
<!-- Subs -->
<TextBlock Grid.Row="1" Grid.Column="0"
FontStyle="Italic"
FontSize="12"
Opacity="0.8"
Text="Subs: " />
<TextBlock Grid.Row="1" Grid.Column="1"
FontStyle="Italic"
FontSize="12"
Opacity="0.8"
Text="{Binding SubtitleLocales, Converter={StaticResource UiListToStringConverter}}"
TextWrapping="NoWrap" />
<ToolTip.Tip>
<Grid>
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
</Grid.ColumnDefinitions>
<!-- Dubs -->
<TextBlock Grid.Row="0" Grid.Column="0"
FontSize="17"
Text="Dubs: " />
<TextBlock Grid.Row="0" Grid.Column="1"
FontSize="17"
Text="{Binding AudioLocales, Converter={StaticResource UiListToStringConverter}}"
TextWrapping="Wrap" />
<!-- Subs -->
<TextBlock Grid.Row="1" Grid.Column="0"
FontSize="17"
Text="Subs: " />
<TextBlock Grid.Row="1" Grid.Column="1"
FontSize="17"
Text="{Binding SubtitleLocales, Converter={StaticResource UiListToStringConverter}}"
TextWrapping="Wrap" />
</Grid>
</ToolTip.Tip>
</Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom">
<Button HorizontalAlignment="Right" VerticalAlignment="Bottom" Content="Trailer" Margin=" 0 0 5 0"
Command="{Binding $parent[UserControl].((vm:UpcomingPageViewModel)DataContext).OpenTrailer}"
CommandParameter="{Binding}">
</Button>
<StackPanel IsVisible="{Binding HasCrID}">
<Button HorizontalAlignment="Right" VerticalAlignment="Bottom"
IsVisible="{Binding !IsInHistory}"
Command="{Binding $parent[UserControl].((vm:UpcomingPageViewModel)DataContext).AddToHistory}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="Library" FontSize="20" />
<controls:SymbolIcon Symbol="Add" FontSize="20" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</StackPanel>
</Expander.Content>
</Expander>
<StackPanel Grid.Column="0" Orientation="Vertical" HorizontalAlignment="Center"
Width="185"
Height="315"
@ -188,65 +329,6 @@
</StackPanel>
<Expander Grid.Column="0" Grid.ColumnSpan="2" ExpandDirection="Right">
<Expander.Styles>
<Style Selector="Expander:not(:expanded) /template/ ToggleButton#ExpanderHeader">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="Expander:expanded /template/ ToggleButton#ExpanderHeader">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
<Style Selector="ToggleButton:pointerover /template/ Border#ExpandCollapseChevronBorder">
<Setter Property="Background" Value="Transparent" />
</Style>
<Style Selector="ToggleButton:not(:checked) /template/ TextBlock#ExpandCollapseChevron">
<Setter Property="Foreground" Value="Transparent" />
</Style>
<Style Selector="ToggleButton:checked /template/ TextBlock#ExpandCollapseChevron">
<Setter Property="Foreground" Value="Transparent" />
</Style>
</Expander.Styles>
<Expander.Header>
<Border Width="117" Height="315" />
</Expander.Header>
<Expander.Content>
<StackPanel>
<ScrollViewer MaxHeight="265" MinHeight="265" PointerWheelChanged="ScrollViewer_PointerWheelChanged" Margin="0 0 0 5">
<TextBlock HorizontalAlignment="Center" TextAlignment="Center"
Text="{Binding Description}"
TextWrapping="Wrap"
Width="185"
FontSize="16"
Margin="5">
</TextBlock>
</ScrollViewer>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom">
<Button HorizontalAlignment="Right" VerticalAlignment="Bottom" Content="Trailer" Margin=" 0 0 5 0"
Command="{Binding $parent[UserControl].((vm:UpcomingPageViewModel)DataContext).OpenTrailer}"
CommandParameter="{Binding}">
</Button>
<StackPanel IsVisible="{Binding HasCrID}">
<Button HorizontalAlignment="Right" VerticalAlignment="Bottom"
IsVisible="{Binding !IsInHistory}"
Command="{Binding $parent[UserControl].((vm:UpcomingPageViewModel)DataContext).AddToHistory}"
CommandParameter="{Binding}">
<StackPanel Orientation="Horizontal">
<controls:SymbolIcon Symbol="Library" FontSize="20" />
<controls:SymbolIcon Symbol="Add" FontSize="20" />
</StackPanel>
</Button>
</StackPanel>
</StackPanel>
</StackPanel>
</Expander.Content>
</Expander>
</Grid>
</DataTemplate>

View file

@ -0,0 +1,83 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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>
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<Grid.ColumnDefinitions>
<ColumnDefinition Width="Auto" />
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<StackPanel Grid.Row="0" Grid.Column="0" Grid.ColumnSpan="3" Background="{DynamicResource ControlAltFillColorQuarternary}"></StackPanel>
<StackPanel Grid.Row="0" Grid.Column="1">
<DockPanel HorizontalAlignment="Center" VerticalAlignment="Center">
<Image Source="/Assets/app_icon.ico"
Margin="2.5"
DockPanel.Dock="Left"
Height="65"
RenderOptions.BitmapInterpolationMode="HighQuality" />
<StackPanel Spacing="0" Margin="12 0" VerticalAlignment="Center">
<TextBlock Text="Crunchy-Downloader" />
<TextBlock Text="{Binding CurrentVersion}" />
<TextBlock Text="https://github.com/Crunchy-DL/Crunchy-Downloader"
Foreground="{DynamicResource TextFillColorSecondaryBrush}" />
</StackPanel>
</DockPanel>
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
<Button Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
VerticalAlignment="Center"
Command="{Binding StartUpdate}"
IsEnabled="{Binding UpdateAvailable}">
<StackPanel Orientation="Vertical" HorizontalAlignment="Center">
<controls:SymbolIcon Symbol="Download" FontSize="32" />
<TextBlock Text="Update" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12"></TextBlock>
</StackPanel>
</Button>
</StackPanel>
<ScrollViewer Grid.Row="1" Grid.Column="0" Grid.ColumnSpan="3" HorizontalAlignment="Center" IsVisible="{Binding !Updating}" MaxWidth="700"
Margin="10"
VerticalScrollBarVisibility="Auto"
HorizontalScrollBarVisibility="Disabled">
<avalonia:HtmlLabel Name="HtmlContent"
Text="{Binding ChangelogText}"
Margin="10"
VerticalAlignment="Stretch" />
</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>
</UserControl>

View file

@ -0,0 +1,9 @@
using Avalonia.Controls;
namespace CRD.Views;
public partial class UpdateView : UserControl{
public UpdateView(){
InitializeComponent();
}
}

View file

@ -1,16 +0,0 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
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:utils="clr-namespace:CRD.ViewModels.Utils"
x:DataType="utils:ContentDialogUpdateViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.Utils.ContentDialogUpdateView">
<StackPanel Spacing="10" MinWidth="400">
<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>
</UserControl>

View file

@ -1,9 +0,0 @@
using Avalonia.Controls;
namespace CRD.Views.Utils;
public partial class ContentDialogUpdateView : UserControl{
public ContentDialogUpdateView(){
InitializeComponent();
}
}

View file

@ -14,7 +14,7 @@
</Design.DataContext>
<ScrollViewer Padding="20 20 20 0" >
<ScrollViewer Padding="20 20 20 0">
<StackPanel Spacing="8">
<controls:SettingsExpander Header="History"
@ -38,19 +38,19 @@
<CheckBox IsChecked="{Binding HistoryIncludeCrArtists}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="History Add Specials" Description="Add specials to the queue if they weren't downloaded before">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistoryAddSpecials}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="History Missing/New Count from Sonarr" Description="The missing count (number in the orange corner) will count the episodes missing from sonarr">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistoryCountSonarr}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="History Skip Sonarr Unmonitored" Description="Skips unmonitored sonarr episodes when counting Missing/New">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistorySkipUnmonitored}"> </CheckBox>
@ -145,6 +145,96 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedPlaySound}"
Text="{Binding DownloadFinishedSoundPath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding OpenImageFileDialogAsyncInternalFinishedSound}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select Finished Sound" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedPlaySound}"
Command="{Binding ClearFinishedSoundPath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Remove Finished Sound Path" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel>
<!-- <Grid HorizontalAlignment="Right" Margin="0 5 0 0"> -->
<!-- <Grid.ColumnDefinitions> -->
<!-- <ColumnDefinition Width="Auto" /> -->
<!-- <ColumnDefinition Width="150" /> -->
<!-- </Grid.ColumnDefinitions> -->
<!-- -->
<!-- <Grid.RowDefinitions> -->
<!-- <RowDefinition Height="Auto" /> -->
<!-- <RowDefinition Height="Auto" /> -->
<!-- </Grid.RowDefinitions> -->
<!-- -->
<!-- <TextBlock Text="Opacity" -->
<!-- FontSize="15" -->
<!-- Opacity="0.8" -->
<!-- VerticalAlignment="Center" -->
<!-- HorizontalAlignment="Right" -->
<!-- Margin="0 0 5 10" -->
<!-- Grid.Row="0" Grid.Column="0" /> -->
<!-- <controls:NumberBox Minimum="0" Maximum="1" -->
<!-- SmallChange="0.05" -->
<!-- LargeChange="0.1" -->
<!-- SimpleNumberFormat="F2" -->
<!-- Value="{Binding BackgroundImageOpacity}" -->
<!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" -->
<!-- Margin="0 0 0 10" -->
<!-- Grid.Row="0" Grid.Column="1" /> -->
<!-- -->
<!-- <TextBlock Text="Blur Radius" -->
<!-- FontSize="15" -->
<!-- Opacity="0.8" -->
<!-- VerticalAlignment="Center" -->
<!-- HorizontalAlignment="Right" -->
<!-- Margin="0 0 5 0" -->
<!-- Grid.Row="1" Grid.Column="0" /> -->
<!-- <controls:NumberBox Minimum="0" Maximum="40" -->
<!-- SmallChange="1" -->
<!-- LargeChange="5" -->
<!-- SimpleNumberFormat="F0" -->
<!-- Value="{Binding BackgroundImageBlurRadius}" -->
<!-- SpinButtonPlacementMode="Inline" -->
<!-- HorizontalAlignment="Stretch" -->
<!-- Grid.Row="1" Grid.Column="1" /> -->
<!-- </Grid> -->
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpander.Footer>
</controls:SettingsExpander.Footer>
</controls:SettingsExpander>
@ -201,13 +291,13 @@
<CheckBox IsChecked="{Binding ProxyEnabled}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Use Socks5 Proxy">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding ProxySocks}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Host">
<controls:SettingsExpanderItem.Footer>
@ -225,7 +315,7 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Username">
<controls:SettingsExpanderItem.Footer>
<TextBox HorizontalAlignment="Left" MinWidth="250"
@ -233,7 +323,7 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Password">
<controls:SettingsExpanderItem.Footer>
<TextBox HorizontalAlignment="Left" MinWidth="250"