Compare commits

...

3 commits

Author SHA1 Message Date
Elwador
199ff9f96c Chg - Updated images 2026-03-04 18:17:53 +01:00
Elwador
985fd9c00f Added **tray icon** [#393](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/393).
Added **ability to switch between account profiles** [#372](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/372).
Added option to **execute a file when the download queue finishes** [#392](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/392).
Added **auto history refresh / auto add to queue** [#394](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/394).
Changed **font loading** to also include fonts from the local fonts folder that are not available on Crunchyroll [#371](https://github.com/Crunchy-DL/Crunchy-Downloader/issues/371).
Updated packages to latest versions
Fixed **history not being saved** after it was updated via the calendar
Fixed **Downloaded toggle in history** being slow for large seasons
2026-03-04 18:17:28 +01:00
Elwador
973c45ce5c - Added option to **update history from the calendar**
- Added **search for currently shown series** in the history view
- Changed **authentication tokens**
- Fixed **download info updates** where the resolution was shown incorrectly
2026-01-31 19:11:58 +01:00
68 changed files with 2424 additions and 808 deletions

View file

@ -4,12 +4,18 @@ using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Markup.Xaml;
using CRD.ViewModels;
using MainWindow = CRD.Views.MainWindow;
using System.Linq;
using Avalonia.Controls;
using Avalonia.Platform;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
namespace CRD;
public partial class App : Application{
public class App : Application{
private TrayIcon? trayIcon;
private bool exitRequested;
public override void Initialize(){
AvaloniaXamlLoader.Load(this);
}
@ -21,11 +27,21 @@ public partial class App : Application{
var manager = ProgramManager.Instance;
if (!isHeadless){
desktop.MainWindow = new MainWindow{
desktop.ShutdownMode = ShutdownMode.OnExplicitShutdown;
var mainWindow = new MainWindow{
DataContext = new MainWindowViewModel(manager),
};
desktop.MainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
mainWindow.Opened += (_, _) => { manager.SetBackgroundImage(); };
desktop.Exit += (_, _) => { manager.StopBackgroundTasks(); };
if (!CrunchyrollManager.Instance.CrunOptions.StartMinimizedToTray){
desktop.MainWindow = mainWindow;
}
SetupTrayIcon(desktop, mainWindow, manager);
SetupMinimizeToTray(desktop,mainWindow,manager);
}
@ -35,5 +51,80 @@ public partial class App : Application{
base.OnFrameworkInitializationCompleted();
}
private void SetupTrayIcon(IClassicDesktopStyleApplicationLifetime desktop, Window mainWindow, ProgramManager programManager){
trayIcon = new TrayIcon{
ToolTipText = "CRD",
Icon = new WindowIcon(AssetLoader.Open(new Uri("avares://CRD/Assets/app_icon.ico"))),
IsVisible = CrunchyrollManager.Instance.CrunOptions.TrayIconEnabled,
};
var menu = new NativeMenu();
var exitItem = new NativeMenuItem("Exit");
exitItem.Click += (_, _) => {
exitRequested = true;
trayIcon?.Dispose();
desktop.Shutdown();
};
menu.Items.Add(exitItem);
trayIcon.Menu = menu;
trayIcon.Clicked += (_, _) => ShowFromTray(desktop, mainWindow);
TrayIcon.SetIcons(this, new TrayIcons{ trayIcon });
}
private void SetupMinimizeToTray(IClassicDesktopStyleApplicationLifetime desktop, Window window , ProgramManager programManager){
window.Closing += (_, e) => {
if (exitRequested)
return;
if (CrunchyrollManager.Instance.CrunOptions is{ MinimizeToTrayOnClose: true, TrayIconEnabled: true }){
HideToTray(window);
e.Cancel = true;
return;
}
exitRequested = true;
trayIcon?.Dispose();
desktop.Shutdown();
};
window.GetObservable(Window.WindowStateProperty).Subscribe(state => {
if (CrunchyrollManager.Instance.CrunOptions is{ TrayIconEnabled: true, MinimizeToTray: true } && state == WindowState.Minimized)
HideToTray(window);
});
}
private static void HideToTray(Window window){
window.ShowInTaskbar = false;
window.Hide();
}
private void ShowFromTray(IClassicDesktopStyleApplicationLifetime desktop, Window mainWindow){
desktop.MainWindow ??= mainWindow;
RestoreFromTray(mainWindow);
}
private static void RestoreFromTray(Window window){
window.ShowInTaskbar = true;
window.Show();
if (window.WindowState == WindowState.Minimized)
window.WindowState = WindowState.Normal;
window.Activate();
}
public void SetTrayIconVisible(bool enabled){
trayIcon?.IsVisible = enabled;
if (!enabled && ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){
if (desktop.MainWindow is{ } w)
RestoreFromTray(w);
}
}
}

View file

@ -10,6 +10,7 @@ using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Views;
@ -234,8 +235,17 @@ public class CalendarManager{
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data;
var newEpisodes = newEpisodesBase.Data ?? [];
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){
Console.Error.WriteLine("Failed to update History from calendar");
}
}
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false;

View file

@ -1,9 +1,15 @@
using System;
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
@ -15,9 +21,12 @@ using ReactiveUI;
namespace CRD.Downloader.Crunchyroll;
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
public CrToken? Token;
public CrProfile Profile = new();
public Subscription? Subscription{ get; set; }
public CrMultiProfile MultiProfile = new();
public CrunchyrollEndpoints EndpointEnum = CrunchyrollEndpoints.Unknown;
public CrAuthSettings AuthSettings = authSettings;
@ -32,7 +41,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
PreferredContentSubtitleLanguage = crunInstance.DefaultLocale,
HasPremium = false,
};
}
private string GetTokenFilePath(){
@ -49,9 +57,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
case "console/ps5":
case "console/xbox_one":
return CfgManager.PathCrToken.Replace(".json", "_console.json");
case "---":
return CfgManager.PathCrToken.Replace(".json", "_guest.json");
default:
return CfgManager.PathCrToken;
}
}
@ -65,12 +74,14 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
}
public void SetETPCookie(string refreshToken){
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken),cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"),cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken), cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"), cookieStore);
}
public async Task AuthAnonymous(){
string uuid = Guid.NewGuid().ToString();
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
Subscription = new Subscription();
var formData = new Dictionary<string, string>{
{ "grant_type", "client_id" },
@ -163,10 +174,10 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
} else{
if (response.ResponseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - because of invalid login credentials", ToastType.Error, 5));
} 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") ||
} 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, 5));
} else{
@ -179,7 +190,64 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetProfile();
await GetMultiProfile();
}
}
public async Task ChangeProfile(string profileId){
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous();
}
if (Profile.Username == "???"){
return;
}
if (string.IsNullOrEmpty(profileId) || Token?.refresh_token == null){
return;
}
string uuid = string.IsNullOrEmpty(Token.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
SetETPCookie(Token.refresh_token);
var formData = new Dictionary<string, string>{
{ "grant_type", "refresh_token_profile_id" },
{ "profile_id", profileId },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
if (Token?.refresh_token != null) SetETPCookie(Token.refresh_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
}
await GetMultiProfile();
} else{
Console.Error.WriteLine("Refresh Token Auth Failed");
}
}
@ -199,42 +267,69 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (profileTemp != null){
Profile = profileTemp;
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
await GetSubscription();
}
}
}
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
private async Task GetSubscription(){
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
if (responseSubs.IsOk){
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Profile.Subscription = subsc;
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
var remaining = expiration - DateTime.Now;
Profile.HasPremium = true;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.Subscription.NextRenewalDate = expiration;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Profile.Subscription != null){
Profile.Subscription.IsActive = remaining > TimeSpan.Zero;
Profile.Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
if (responseSubs.IsOk){
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Subscription = subsc;
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
var remaining = expiration - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = expiration;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
}
}
private async Task GetMultiProfile(){
if (Token?.access_token == null){
Console.Error.WriteLine("Missing Access Token");
return;
}
var request = HttpClientReq.CreateRequestMessage(ApiUrls.MultiProfile, HttpMethod.Get, true, Token?.access_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
MultiProfile = Helpers.Deserialize<CrMultiProfile>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? new CrMultiProfile();
var selectedProfile = MultiProfile.Profiles.FirstOrDefault( e => e.IsSelected);
if (selectedProfile != null) Profile = selectedProfile;
await GetSubscription();
}
}
}
}
}
@ -279,33 +374,41 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
var response = await HttpClientReq.Instance.SendHttpRequest(request);
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") ||
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, 5));
Console.Error.WriteLine($"Failed to login - Cloudflare error try to change to BetaAPI in settings");
}
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetProfile();
await GetMultiProfile();
}
} else{
Console.Error.WriteLine("Token Auth Failed");
await AuthAnonymous();
MainWindow.Instance.ShowError("Login failed. Please check the log for more details.");
}
}
public async Task RefreshToken(bool needsToken){
if (EndpointEnum == CrunchyrollEndpoints.Guest){
if (Token != null && !(DateTime.Now > Token.expires)){
return;
}
await AuthAnonymousFoxy();
return;
}
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
await AuthAnonymous();

View file

@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
@ -73,92 +74,102 @@ public class CrEpisode(){
public async Task<CrunchyRollEpisodeData> EpisodeData(CrunchyEpisode dlEpisode, bool updateHistory = false){
bool serieshasversions = true;
// Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
CrunchyRollEpisodeData episode = new CrunchyRollEpisodeData();
var episode = new CrunchyRollEpisodeData();
if (crunInstance.CrunOptions.History && updateHistory){
await crunInstance.History.UpdateWithEpisodeList([dlEpisode]);
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == dlEpisode.SeriesId);
var historySeries = crunInstance.HistoryList
.FirstOrDefault(series => series.SeriesId == dlEpisode.SeriesId);
if (historySeries != null){
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(false);
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
crunInstance.History.MatchHistorySeriesWithSonarr(false);
await crunInstance.History.MatchHistoryEpisodesWithSonarr(false, historySeries);
CfgManager.UpdateHistoryFile();
}
}
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier) ? dlEpisode.Identifier.Split('|')[1] : $"S{dlEpisode.SeasonNumber}";
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}";
episode.EpisodeAndLanguages = new EpisodeAndLanguage{
Items = new List<CrunchyEpisode>(),
Langs = new List<LanguageItem>()
};
// initial key
var seasonIdentifier = !string.IsNullOrEmpty(dlEpisode.Identifier)
? dlEpisode.Identifier.Split('|')[1]
: $"S{dlEpisode.SeasonNumber}";
episode.Key = $"{seasonIdentifier}E{dlEpisode.Episode ?? (dlEpisode.EpisodeNumber + "")}";
episode.EpisodeAndLanguages = new EpisodeAndLanguage();
// Build Variants
if (dlEpisode.Versions != null){
foreach (var version in dlEpisode.Versions){
// Ensure there is only one of the same language
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != version.AudioLocale)){
// Push to arrays if there are no duplicates of the same language
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? Languages.DEFAULT_lang);
}
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)
?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
}
} else{
// Episode didn't have versions, mark it as such to be logged.
serieshasversions = false;
// Ensure there is only one of the same language
if (episode.EpisodeAndLanguages.Langs.All(a => a.CrLocale != dlEpisode.AudioLocale)){
// Push to arrays if there are no duplicates of the same language
episode.EpisodeAndLanguages.Items.Add(dlEpisode);
episode.EpisodeAndLanguages.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale) ?? Languages.DEFAULT_lang);
}
var lang = Array.Find(Languages.languages, a => a.CrLocale == dlEpisode.AudioLocale)
?? Languages.DEFAULT_lang;
episode.EpisodeAndLanguages.AddUnique(dlEpisode, lang);
}
if (episode.EpisodeAndLanguages.Variants.Count == 0)
return episode;
int specialIndex = 1;
int epIndex = 1;
var baseEp = episode.EpisodeAndLanguages.Variants[0].Item;
var isSpecial = baseEp.IsSpecialEpisode();
var isSpecial = !Regex.IsMatch(episode.EpisodeAndLanguages.Items[0].Episode ?? string.Empty, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special).
string newKey;
if (isSpecial && !string.IsNullOrEmpty(episode.EpisodeAndLanguages.Items[0].Episode)){
newKey = episode.EpisodeAndLanguages.Items[0].Episode ?? "SP" + (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id);
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = baseEp.Episode;
} else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + episode.EpisodeAndLanguages.Items[0].Id) : episode.EpisodeAndLanguages.Items[0].Episode ?? epIndex + "")}";
var epPart = baseEp.Episode ?? (baseEp.EpisodeNumber?.ToString() ?? "1");
newKey = isSpecial
? $"SP{epPart} {baseEp.Id}"
: $"E{epPart}";
}
episode.Key = newKey;
var seasonTitle = episode.EpisodeAndLanguages.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
?? Regex.Replace(episode.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
var seasonTitle =
episode.EpisodeAndLanguages.Variants
.Select(v => v.Item.SeasonTitle)
.FirstOrDefault(t => !DownloadQueueItemFactory.HasDubSuffix(t))
?? DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle);
var title = episode.EpisodeAndLanguages.Items[0].Title;
var seasonNumber = Helpers.ExtractNumberAfterS(episode.EpisodeAndLanguages.Items[0].Identifier) ?? episode.EpisodeAndLanguages.Items[0].SeasonNumber.ToString();
var title = baseEp.Title;
var seasonNumber = baseEp.GetSeasonNum();
var languages = episode.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{episode.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆
var languages = episode.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
Console.WriteLine($"[{episode.Key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
if (!serieshasversions){
if (!serieshasversions)
Console.WriteLine("Couldn\'t find versions on episode, added languages with language array.");
}
return episode;
}
public CrunchyEpMeta EpisodeMeta(CrunchyRollEpisodeData episodeP, List<string> dubLang){
// var ret = new Dictionary<string, CrunchyEpMeta>();
CrunchyEpMeta? retMeta = null;
var retMeta = new CrunchyEpMeta();
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var hslang = crunInstance.CrunOptions.Hslang;
var selectedDubs = dubLang
.Where(d => episodeP.EpisodeAndLanguages.Variants.Any(v => v.Lang.CrLocale == d))
.ToList();
for (int index = 0; index < episodeP.EpisodeAndLanguages.Items.Count; index++){
var item = episodeP.EpisodeAndLanguages.Items[index];
foreach (var v in episodeP.EpisodeAndLanguages.Variants){
var item = v.Item;
var lang = v.Lang;
if (!dubLang.Contains(episodeP.EpisodeAndLanguages.Langs[index].CrLocale))
if (!dubLang.Contains(lang.CrLocale))
continue;
item.HideSeasonTitle = true;
@ -173,67 +184,54 @@ public class CrEpisode(){
item.SeriesTitle = "NO_TITLE";
}
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var images = (item.Images?.Thumbnail ?? [new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
var epMeta = new CrunchyEpMeta();
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
epMeta.SeriesTitle = episodeP.EpisodeAndLanguages.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle))?.SeriesTitle ??
Regex.Replace(episodeP.EpisodeAndLanguages.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
epMeta.SeasonTitle = episodeP.EpisodeAndLanguages.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle))?.SeasonTitle ??
Regex.Replace(episodeP.EpisodeAndLanguages.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
epMeta.EpisodeNumber = item.Episode;
epMeta.EpisodeTitle = item.Title;
epMeta.SeasonId = item.SeasonId;
epMeta.Season = Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber + "";
epMeta.SeriesId = item.SeriesId;
epMeta.AbsolutEpisodeNumberE = epNum;
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty;
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source ?? string.Empty;
epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false,
Done = false,
Error = false,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0
};
epMeta.AvailableSubs = item.SubtitleLocales;
epMeta.Description = item.Description;
epMeta.Hslang = CrunchyrollManager.Instance.CrunOptions.Hslang;
if (episodeP.EpisodeAndLanguages.Langs.Count > 0){
epMeta.SelectedDubs = dubLang
.Where(language => episodeP.EpisodeAndLanguages.Langs.Any(epLang => epLang.CrLocale == language))
.ToList();
}
var epMetaData = epMeta.Data[0];
if (!string.IsNullOrEmpty(item.StreamsLink)){
epMetaData.Playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){
item.Playback = item.StreamsLink;
}
}
if (retMeta.Data is{ Count: > 0 }){
epMetaData.Lang = episodeP.EpisodeAndLanguages.Langs[index];
retMeta.Data.Add(epMetaData);
} else{
epMetaData.Lang = episodeP.EpisodeAndLanguages.Langs[index];
epMeta.Data[0] = epMetaData;
retMeta = epMeta;
}
// show ep
item.SeqId = epNum;
if (retMeta == null){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeriesTitle));
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
episodeP.EpisodeAndLanguages.Variants.Select(x => (string?)x.Item.SeasonTitle));
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
retMeta = DownloadQueueItemFactory.CreateShell(
service: StreamingService.Crunchyroll,
seriesTitle: seriesTitle,
seasonTitle: seasonTitle,
episodeNumber: item.Episode,
episodeTitle: item.GetEpisodeTitle(),
description: item.Description,
seriesId: item.SeriesId,
seasonId: item.SeasonId,
season: item.GetSeasonNum(),
absolutEpisodeNumberE: epNum,
image: img,
imageBig: imgBig,
hslang: hslang,
availableSubs: item.SubtitleLocales,
selectedDubs: selectedDubs
);
}
var playback = item.Playback;
if (!string.IsNullOrEmpty(item.StreamsLink)){
playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback))
item.Playback = item.StreamsLink;
}
retMeta.Data.Add(DownloadQueueItemFactory.CreateVariant(
mediaId: item.Id,
lang: lang,
playback: playback,
versions: item.Versions,
isSubbed: item.IsSubbed,
isDubbed: item.IsDubbed
));
}
return retMeta;
return retMeta ?? new CrunchyEpMeta();
}
public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){

View file

@ -6,6 +6,7 @@ using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
@ -17,32 +18,44 @@ namespace CRD.Downloader.Crunchyroll;
public class CrSeries{
private readonly CrunchyrollManager crunInstance = CrunchyrollManager.Instance;
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? but, bool? all, List<string>? e){
public Dictionary<string, CrunchyEpMeta> ItemSelectMultiDub(Dictionary<string, EpisodeAndLanguage> eps, List<string> dubLang, bool? all, List<string>? e){
var ret = new Dictionary<string, CrunchyEpMeta>();
var hasPremium = crunInstance.CrAuthEndpoint1.Profile.HasPremium;
foreach (var kvp in eps){
var key = kvp.Key;
var episode = kvp.Value;
var hslang = crunInstance.CrunOptions.Hslang;
for (int index = 0; index < episode.Items.Count; index++){
var item = episode.Items[index];
bool ShouldInclude(string epNum) =>
all is true || (e != null && e.Contains(epNum));
if (item.IsPremiumOnly && !crunInstance.CrAuthEndpoint1.Profile.HasPremium){
MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription", ToastType.Error, 3));
foreach (var (key, episode) in eps){
var epNum = key.StartsWith('E') ? key[1..] : key;
foreach (var v in episode.Variants){
var item = v.Item;
var lang = v.Lang;
item.SeqId = epNum;
if (item.IsPremiumOnly && !hasPremium){
MessageBus.Current.SendMessage(new ToastMessage(
"Episode is a premium episode make sure that you are signed in with an account that has an active premium subscription",
ToastType.Error, 3));
continue;
}
// history override
var effectiveDubs = dubLang;
if (crunInstance.CrunOptions.History){
var dubLangList = crunInstance.History.GetDubList(item.SeriesId, item.SeasonId);
if (dubLangList.Count > 0){
dubLang = dubLangList;
}
if (dubLangList.Count > 0)
effectiveDubs = dubLangList;
}
if (!dubLang.Contains(episode.Langs[index].CrLocale))
if (!effectiveDubs.Contains(lang.CrLocale))
continue;
// season title fallbacks (same behavior)
item.HideSeasonTitle = true;
if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){
item.SeasonTitle = item.SeriesTitle;
@ -55,66 +68,65 @@ public class CrSeries{
item.SeriesTitle = "NO_TITLE";
}
var epNum = key.StartsWith('E') ? key[1..] : key;
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
// selection gate
if (!ShouldInclude(epNum))
continue;
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
// Create base queue item once per "key"
if (!ret.TryGetValue(key, out var qItem)){
var seriesTitle = DownloadQueueItemFactory.CanonicalTitle(
episode.Variants.Select(x => (string?)x.Item.SeriesTitle));
var epMeta = new CrunchyEpMeta();
epMeta.Data = new List<CrunchyEpMetaData>{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } };
epMeta.SeriesTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle))?.SeriesTitle ?? Regex.Replace(episode.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd();
epMeta.SeasonTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle))?.SeasonTitle ?? Regex.Replace(episode.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
epMeta.EpisodeNumber = item.Episode;
epMeta.EpisodeTitle = item.Title;
epMeta.SeasonId = item.SeasonId;
epMeta.Season = Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber + "";
epMeta.SeriesId = item.SeriesId;
epMeta.AbsolutEpisodeNumberE = epNum;
epMeta.Image = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty;
epMeta.ImageBig = images.FirstOrDefault()?.LastOrDefault()?.Source ?? string.Empty;
epMeta.DownloadProgress = new DownloadProgress(){
IsDownloading = false,
Done = false,
Percent = 0,
Time = 0,
DownloadSpeedBytes = 0
};
epMeta.Hslang = CrunchyrollManager.Instance.CrunOptions.Hslang;
epMeta.Description = item.Description;
epMeta.AvailableSubs = item.SubtitleLocales;
if (episode.Langs.Count > 0){
epMeta.SelectedDubs = dubLang
.Where(language => episode.Langs.Any(epLang => epLang.CrLocale == language))
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
episode.Variants.Select(x => (string?)x.Item.SeasonTitle));
var (img, imgBig) = DownloadQueueItemFactory.GetThumbSmallBig(item.Images);
var selectedDubs = effectiveDubs
.Where(d => episode.Variants.Any(x => x.Lang.CrLocale == d))
.ToList();
qItem = DownloadQueueItemFactory.CreateShell(
service: StreamingService.Crunchyroll,
seriesTitle: seriesTitle,
seasonTitle: seasonTitle,
episodeNumber: item.Episode,
episodeTitle: item.Title,
description: item.Description,
seriesId: item.SeriesId,
seasonId: item.SeasonId,
season: Helpers.ExtractNumberAfterS(item.Identifier) ?? item.SeasonNumber.ToString(),
absolutEpisodeNumberE: epNum,
image: img,
imageBig: imgBig,
hslang: hslang,
availableSubs: item.SubtitleLocales,
selectedDubs: selectedDubs
);
ret.Add(key, qItem);
}
var epMetaData = epMeta.Data[0];
// playback preference
var playback = item.Playback;
if (!string.IsNullOrEmpty(item.StreamsLink)){
epMetaData.Playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback)){
playback = item.StreamsLink;
if (string.IsNullOrEmpty(item.Playback))
item.Playback = item.StreamsLink;
}
}
if (all is true || e != null && e.Contains(epNum)){
if (ret.TryGetValue(key, out var epMe)){
epMetaData.Lang = episode.Langs[index];
epMe.Data.Add(epMetaData);
} else{
epMetaData.Lang = episode.Langs[index];
epMeta.Data[0] = epMetaData;
ret.Add(key, epMeta);
}
}
// show ep
item.SeqId = epNum;
// Add variant
ret[key].Data.Add(DownloadQueueItemFactory.CreateVariant(
mediaId: item.Id,
lang: lang,
playback: playback,
versions: item.Versions,
isSubbed: item.IsSubbed,
isDubbed: item.IsDubbed
));
}
}
return ret;
}
@ -124,64 +136,58 @@ public class CrSeries{
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
if (parsedSeries == null || parsedSeries.Data == null){
if (parsedSeries?.Data == null){
Console.Error.WriteLine("Parse Data Invalid");
return null;
}
// var result = ParseSeriesResult(parsedSeries);
Dictionary<string, EpisodeAndLanguage> episodes = new Dictionary<string, EpisodeAndLanguage>();
var episodes = new Dictionary<string, EpisodeAndLanguage>();
if (crunInstance.CrunOptions.History){
if (crunInstance.CrunOptions.History)
_ = crunInstance.History.CrUpdateSeries(id, "");
}
var cachedSeasonId = "";
var seasonData = new CrunchyEpisodeList();
foreach (var s in parsedSeries.Data){
if (data?.S != null && s.Id != data.S) continue;
if (data?.S != null && s.Id != data.S)
continue;
int fallbackIndex = 0;
if (cachedSeasonId != s.Id){
seasonData = await GetSeasonDataById(s.Id, forcedLocale ? crLocale : "");
cachedSeasonId = s.Id;
}
if (seasonData.Data != null){
foreach (var episode in seasonData.Data){
// Prepare the episode array
EpisodeAndLanguage item;
if (seasonData.Data == null)
continue;
foreach (var episode in seasonData.Data){
string episodeNum =
(episode.Episode != string.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}"))
?? string.Empty;
string episodeNum = (episode.Episode != String.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}")) ?? string.Empty;
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier)
? s.Identifier.Split('|')[1]
: $"S{episode.SeasonNumber}";
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}";
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
var episodeKey = $"{seasonIdentifier}E{episodeNum}";
if (!episodes.ContainsKey(episodeKey)){
item = new EpisodeAndLanguage{
Items = new List<CrunchyEpisode>(),
Langs = new List<LanguageItem>()
};
episodes[episodeKey] = item;
} else{
item = episodes[episodeKey];
}
if (episode.Versions != null){
foreach (var version in episode.Versions){
if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){
item.Items.Add(episode);
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem());
}
}
} else{
serieshasversions = false;
if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){
item.Items.Add(episode);
item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem());
}
if (!episodes.TryGetValue(episodeKey, out var item)){
item = new EpisodeAndLanguage(); // must have Variants
episodes[episodeKey] = item;
}
if (episode.Versions != null){
foreach (var version in episode.Versions){
var lang = Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang); // must enforce uniqueness by CrLocale
}
} else{
serieshasversions = false;
var lang = Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale) ?? new LanguageItem();
item.AddUnique(episode, lang);
}
}
}
@ -198,22 +204,25 @@ public class CrSeries{
int specialIndex = 1;
int epIndex = 1;
var keys = new List<string>(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating.
var keys = new List<string>(episodes.Keys);
foreach (var key in keys){
EpisodeAndLanguage item = episodes[key];
var episode = item.Items[0].Episode;
var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+(\.\d+)?$"); // Checking if the episode is not a number (i.e., special).
// var newKey = $"{(isSpecial ? 'S' : 'E')}{(isSpecial ? specialIndex : epIndex).ToString()}";
var item = episodes[key];
if (item.Variants.Count == 0)
continue;
var baseEp = item.Variants[0].Item;
var epStr = baseEp.Episode;
var isSpecial = epStr != null && !Regex.IsMatch(epStr, @"^\d+(\.\d+)?$");
string newKey;
if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){
newKey = $"SP{specialIndex}_" + item.Items[0].Episode;// ?? "SP" + (specialIndex + " " + item.Items[0].Id);
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = $"SP{specialIndex}_" + baseEp.Episode;
} else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}";
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
}
episodes.Remove(key);
int counter = 1;
@ -225,63 +234,95 @@ public class CrSeries{
episodes.Add(newKey, item);
if (isSpecial){
specialIndex++;
} else{
epIndex++;
}
if (isSpecial) specialIndex++;
else epIndex++;
}
var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList();
var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList();
var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
// Combining and sorting episodes with normal first, then specials.
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
foreach (var kvp in sortedEpisodes){
var key = kvp.Key;
var item = kvp.Value;
var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)"))?.SeasonTitle
?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd();
if (item.Variants.Count == 0)
continue;
var title = item.Items[0].Title;
var seasonNumber = Helpers.ExtractNumberAfterS(item.Items[0].Identifier) ?? item.Items[0].SeasonNumber.ToString();
var baseEp = item.Variants[0].Item;
var languages = item.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index)?.Name ?? "Unknown"}").ToArray(); //☆
var seasonTitle = DownloadQueueItemFactory.CanonicalTitle(
item.Variants.Select(string? (v) => v.Item.SeasonTitle)
);
var title = baseEp.Title;
var seasonNumber = Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString();
var languages = item.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
.ToArray();
Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]");
}
if (!serieshasversions){
if (!serieshasversions)
Console.WriteLine("Couldn\'t find versions on some episodes, added languages with language array.");
}
CrunchySeriesList crunchySeriesList = new CrunchySeriesList();
crunchySeriesList.Data = sortedEpisodes;
var crunchySeriesList = new CrunchySeriesList{
Data = sortedEpisodes
};
crunchySeriesList.List = sortedEpisodes.Select(kvp => {
var key = kvp.Key;
var value = kvp.Value;
var images = (value.Items.FirstOrDefault()?.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
var seconds = (int)Math.Floor((value.Items.FirstOrDefault()?.DurationMs ?? 0) / 1000.0);
var langList = value.Langs.Select(a => a.CrLocale).ToList();
if (value.Variants.Count == 0){
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = new List<string>(),
Name = string.Empty,
Season = string.Empty,
SeriesTitle = string.Empty,
SeasonTitle = string.Empty,
EpisodeNum = key,
Id = string.Empty,
Img = string.Empty,
Description = string.Empty,
EpisodeType = EpisodeType.Episode,
Time = "0:00"
};
}
var baseEp = value.Variants[0].Item;
var thumbRow = baseEp.Images.Thumbnail.FirstOrDefault();
var img = thumbRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
var seconds = (int)Math.Floor((baseEp.DurationMs) / 1000.0);
var langList = value.Variants
.Select(v => v.Lang.CrLocale)
.Distinct()
.ToList();
Languages.SortListByLangList(langList);
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
Lang = langList,
Name = value.Items.FirstOrDefault()?.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(value.Items.FirstOrDefault()?.Identifier?? string.Empty) ?? value.Items.FirstOrDefault()?.SeasonNumber.ToString()) ?? string.Empty,
SeriesTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeriesTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(),
SeasonTitle = Regex.Replace(value.Items.FirstOrDefault()?.SeasonTitle?? string.Empty, @"\(\w+ Dub\)", "").TrimEnd(),
EpisodeNum = key.StartsWith("SP") ? key : value.Items.FirstOrDefault()?.EpisodeNumber?.ToString() ?? value.Items.FirstOrDefault()?.Episode ?? "?",
Id = value.Items.FirstOrDefault()?.SeasonId ?? string.Empty,
Img = images.FirstOrDefault()?.FirstOrDefault()?.Source ?? string.Empty,
Description = value.Items.FirstOrDefault()?.Description ?? string.Empty,
Name = baseEp.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString()) ?? string.Empty,
SeriesTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeriesTitle),
SeasonTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle),
EpisodeNum = key.StartsWith("SP")
? key
: (baseEp.EpisodeNumber?.ToString() ?? baseEp.Episode ?? "?"),
Id = baseEp.SeasonId ?? string.Empty,
Img = img,
Description = baseEp.Description ?? string.Empty,
EpisodeType = EpisodeType.Episode,
Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds.
Time = $"{seconds / 60}:{seconds % 60:D2}"
};
}).ToList();
@ -333,7 +374,7 @@ public class CrSeries{
Console.Error.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}");
} else{
episodeList = Helpers.Deserialize<CrunchyEpisodeList>(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ??
new CrunchyEpisodeList(){ Data =[], Total = 0, Meta = new Meta() };
new CrunchyEpisodeList(){ Data = [], Total = 0, Meta = new Meta() };
}
if (episodeList.Total < 1){
@ -377,8 +418,8 @@ public class CrSeries{
public async Task<CrSeriesBase?> SeriesById(string id, string? crLocale, bool forced = false){
await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
query["preferred_audio_language"] = "ja-JP";
@ -411,7 +452,7 @@ public class CrSeries{
public async Task<CrSearchSeriesBase?> Search(string searchString, string? crLocale, bool forced = false){
await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
if (!string.IsNullOrEmpty(crLocale)){
@ -456,7 +497,7 @@ public class CrSeries{
public async Task<CrBrowseSeriesBase?> GetAllSeries(string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true);
CrBrowseSeriesBase complete = new CrBrowseSeriesBase();
complete.Data =[];
complete.Data = [];
var i = 0;
@ -495,7 +536,7 @@ public class CrSeries{
return complete;
}
public async Task<CrBrowseSeriesBase?> GetSeasonalSeries(string season, string year, string? crLocale){
await crunInstance.CrAuthGuest.RefreshToken(true);
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
@ -503,7 +544,7 @@ public class CrSeries{
if (!string.IsNullOrEmpty(crLocale)){
query["locale"] = crLocale;
}
query["seasonal_tag"] = season.ToLower() + "-" + year;
query["n"] = "100";
@ -520,5 +561,4 @@ public class CrSeries{
return series;
}
}

View file

@ -151,6 +151,9 @@ public class CrunchyrollManager{
};
options.History = true;
options.HistoryAutoRefreshMode = HistoryRefreshMode.FastNewReleases;
options.HistoryAutoRefreshIntervalMinutes = 0;
CfgManager.UpdateSettingsFromFile(options, CfgManager.PathCrDownloadOptions);
@ -581,23 +584,36 @@ public class CrunchyrollManager{
if (options.MarkAsWatched && data.Data is{ Count: > 0 }){
_ = CrEpisode.MarkAsWatched(data.Data.First().MediaId);
}
if (QueueManager.Instance.Queue.Count == 0 || QueueManager.Instance.Queue.All(e => e.DownloadProgress.Done)){
QueueManager.Instance.ResetDownloads();
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer();
player.Play(audioPath);
if (!QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
if (CrunOptions.DownloadFinishedPlaySound){
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
var player = new AudioPlayer();
player.Play(audioPath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to play sound: " + exception);
}
if (CrunOptions.DownloadFinishedExecute){
try{
var filePath = CrunOptions.DownloadFinishedExecutePath;
if (!string.IsNullOrEmpty(filePath)){
Helpers.ExecuteFile(filePath);
}
} catch (Exception exception){
Console.Error.WriteLine("Failed to execute file: " + exception);
}
}
if (CrunOptions.ShutdownWhenQueueEmpty){
Helpers.ShutdownComputer();
}
}
return true;
}
@ -1551,8 +1567,10 @@ public class CrunchyrollManager{
string qualityConsoleLog = sb.ToString();
Console.WriteLine(qualityConsoleLog);
data.AvailableQualities = qualityConsoleLog;
if (!options.DlVideoOnce || string.IsNullOrEmpty(data.AvailableQualities)){
data.AvailableQualities = qualityConsoleLog;
}
Console.WriteLine("Stream URL:" + chosenVideoSegments.segments[0].uri.Split(new[]{ ",.urlset" }, StringSplitOptions.None)[0]);

View file

@ -0,0 +1,89 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using CRD.Utils;
using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class DownloadQueueItemFactory{
private static readonly Regex DubSuffix = new(@"\(\w+ Dub\)", RegexOptions.Compiled);
public static bool HasDubSuffix(string? s)
=> !string.IsNullOrWhiteSpace(s) && DubSuffix.IsMatch(s);
public static string StripDubSuffix(string? s)
=> string.IsNullOrWhiteSpace(s) ? "" : DubSuffix.Replace(s, "").TrimEnd();
public static string CanonicalTitle(IEnumerable<string?> candidates){
var noDub = candidates.FirstOrDefault(t => !HasDubSuffix(t));
return !string.IsNullOrWhiteSpace(noDub)
? noDub!
: StripDubSuffix(candidates.FirstOrDefault());
}
public static (string small, string big) GetThumbSmallBig(Images? images){
var firstRow = images?.Thumbnail?.FirstOrDefault();
var small = firstRow?.FirstOrDefault()?.Source ?? "/notFound.jpg";
var big = firstRow?.LastOrDefault()?.Source ?? small;
return (small, big);
}
public static CrunchyEpMeta CreateShell(
StreamingService service,
string? seriesTitle,
string? seasonTitle,
string? episodeNumber,
string? episodeTitle,
string? description,
string? seriesId,
string? seasonId,
string? season,
string? absolutEpisodeNumberE,
string? image,
string? imageBig,
string hslang,
List<string>? availableSubs = null,
List<string>? selectedDubs = null,
bool music = false){
return new CrunchyEpMeta(){
SeriesTitle = seriesTitle,
SeasonTitle = seasonTitle,
EpisodeNumber = episodeNumber,
EpisodeTitle = episodeTitle,
Description = description,
SeriesId = seriesId,
SeasonId = seasonId,
Season = season,
AbsolutEpisodeNumberE = absolutEpisodeNumberE,
Image = image,
ImageBig = imageBig,
Hslang = hslang,
AvailableSubs = availableSubs,
SelectedDubs = selectedDubs,
Music = music
};
}
public static CrunchyEpMetaData CreateVariant(
string mediaId,
LanguageItem? lang,
string? playback,
List<EpisodeVersion>? versions,
bool isSubbed,
bool isDubbed,
bool isAudioRoleDescription = false){
return new CrunchyEpMetaData{
MediaId = mediaId,
Lang = lang,
Playback = playback,
Versions = versions,
IsSubbed = isSubbed,
IsDubbed = isDubbed,
IsAudioRoleDescription = isAudioRoleDescription
};
}
}

View file

@ -0,0 +1,122 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CRD.Utils;
using CRD.Utils.Structs;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class EpisodeMapper{
public static CrunchyEpisode ToCrunchyEpisode(this CrBrowseEpisode src){
if (src == null) throw new ArgumentNullException(nameof(src));
var meta = src.EpisodeMetadata ?? new CrBrowseEpisodeMetaData();
return new CrunchyEpisode{
Id = src.Id ?? string.Empty,
Slug = src.Slug ?? string.Empty,
SlugTitle = src.SlugTitle ?? string.Empty,
Title = src.Title ?? string.Empty,
Description = src.Description ?? src.PromoDescription ?? string.Empty,
MediaType = src.Type,
ChannelId = src.ChannelId,
StreamsLink = src.StreamsLink,
Images = src.Images ?? new Images(),
SeoTitle = src.PromoTitle ?? string.Empty,
SeoDescription = src.PromoDescription ?? string.Empty,
ProductionEpisodeId = src.ExternalId ?? string.Empty,
ListingId = src.LinkedResourceKey ?? string.Empty,
SeriesId = meta.SeriesId ?? string.Empty,
SeasonId = meta.SeasonId ?? string.Empty,
SeriesTitle = meta.SeriesTitle ?? string.Empty,
SeriesSlugTitle = meta.SeriesSlugTitle ?? string.Empty,
SeasonTitle = meta.SeasonTitle ?? string.Empty,
SeasonSlugTitle = meta.SeasonSlugTitle ?? string.Empty,
SeasonNumber = SafeInt(meta.SeasonNumber),
SequenceNumber = (float)meta.SequenceNumber,
Episode = meta.Episode,
EpisodeNumber = meta.EpisodeCount,
DurationMs = meta.DurationMs,
Identifier = meta.Identifier ?? string.Empty,
AvailabilityNotes = meta.AvailabilityNotes ?? string.Empty,
EligibleRegion = meta.EligibleRegion ?? string.Empty,
AvailabilityStarts = meta.AvailabilityStarts,
AvailabilityEnds = meta.AvailabilityEnds,
PremiumAvailableDate = meta.PremiumAvailableDate,
FreeAvailableDate = meta.FreeAvailableDate,
AvailableDate = meta.AvailableDate,
PremiumDate = meta.PremiumDate,
UploadDate = meta.UploadDate,
EpisodeAirDate = meta.EpisodeAirDate,
IsDubbed = meta.IsDubbed,
IsSubbed = meta.IsSubbed,
IsMature = meta.IsMature,
IsClip = meta.IsClip,
IsPremiumOnly = meta.IsPremiumOnly,
MatureBlocked = meta.MatureBlocked,
AvailableOffline = meta.AvailableOffline,
ClosedCaptionsAvailable = meta.ClosedCaptionsAvailable,
MaturityRatings = meta.MaturityRatings ?? new List<string>(),
AudioLocale = (meta.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
SubtitleLocales = (meta.SubtitleLocales ?? new List<Locale>())
.Select(l => l.GetEnumMemberValue())
.Where(s => !string.IsNullOrWhiteSpace(s))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList(),
ExtendedMaturityRating = ToStringKeyDict(meta.ExtendedMaturityRating),
Versions = meta.versions?.Select(ToEpisodeVersion).ToList()
};
}
private static EpisodeVersion ToEpisodeVersion(CrBrowseEpisodeVersion v){
return new EpisodeVersion{
AudioLocale = (v.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue(),
Guid = v.Guid ?? string.Empty,
Original = v.Original,
Variant = v.Variant ?? string.Empty,
SeasonGuid = v.SeasonGuid ?? string.Empty,
MediaGuid = v.MediaGuid,
IsPremiumOnly = v.IsPremiumOnly,
roles = Array.Empty<string>()
};
}
private static int SafeInt(double value){
if (double.IsNaN(value) || double.IsInfinity(value)) return 0;
return (int)Math.Round(value, MidpointRounding.AwayFromZero);
}
private static Dictionary<string, object> ToStringKeyDict(Dictionary<object, object>? dict){
var result = new Dictionary<string, object>(StringComparer.OrdinalIgnoreCase);
if (dict == null) return result;
foreach (var kv in dict){
var key = kv.Key?.ToString();
if (string.IsNullOrWhiteSpace(key)) continue;
result[key] = kv.Value ?? new object();
}
return result;
}
}

View file

@ -4,6 +4,7 @@ using System.Globalization;
using System.Linq;
using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Sonarr;
@ -38,7 +39,7 @@ public class History{
}
} else{
var matchingSeason = historySeries.Seasons.FirstOrDefault(historySeason => historySeason.SeasonId == seasonId);
if (matchingSeason != null){
foreach (var historyEpisode in matchingSeason.EpisodesList){
historyEpisode.IsEpisodeAvailableOnStreamingService = false;
@ -129,6 +130,129 @@ public class History{
}
}
public async Task UpdateWithEpisode(List<CrBrowseEpisode> episodes){
var historyIndex = crunInstance.HistoryList
.Where(h => !string.IsNullOrWhiteSpace(h.SeriesId))
.ToDictionary(
h => h.SeriesId!,
h => (h.Seasons)
.Where(s => !string.IsNullOrWhiteSpace(s.SeasonId))
.ToDictionary(
s => s.SeasonId ?? "UNKNOWN",
s => (s.EpisodesList)
.Select(ep => ep.EpisodeId)
.Where(id => !string.IsNullOrWhiteSpace(id))
.ToHashSet(StringComparer.Ordinal),
StringComparer.Ordinal
),
StringComparer.Ordinal
);
episodes = episodes
.Where(e => !string.IsNullOrWhiteSpace(e.EpisodeMetadata?.SeriesId) &&
historyIndex.ContainsKey(e.EpisodeMetadata!.SeriesId!))
.ToList();
foreach (var seriesGroup in episodes.GroupBy(e => e.EpisodeMetadata?.SeriesId ?? "UNKNOWN_SERIES")){
var seriesId = seriesGroup.Key;
var originalEntries = seriesGroup
.Select(e => new{ OriginalId = TryGetOriginalId(e), SeasonId = TryGetOriginalSeasonId(e) })
.Where(x => !string.IsNullOrWhiteSpace(x.OriginalId))
.GroupBy(x => x.OriginalId!, StringComparer.Ordinal)
.Select(g => new{
OriginalId = g.Key,
SeasonId = g.Select(x => x.SeasonId).FirstOrDefault(s => !string.IsNullOrWhiteSpace(s))
})
.ToList();
var hasAnyOriginalInfo = originalEntries.Count > 0;
var allOriginalsInHistory =
hasAnyOriginalInfo
&& originalEntries.All(x => IsOriginalInHistory(historyIndex, seriesId, x.SeasonId, x.OriginalId));
var originalItems = seriesGroup.Where(IsOriginalItem).ToList();
if (originalItems.Count > 0){
if (allOriginalsInHistory){
var sT = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} {sT} - all ORIGINAL episodes already in history.");
continue;
}
var convertedList = originalItems.Select(crBrowseEpisode => crBrowseEpisode.ToCrunchyEpisode()).ToList();
await crunInstance.History.UpdateWithSeasonData(convertedList.ToList<IHistorySource>());
continue;
}
var seriesTitle = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
if (allOriginalsInHistory){
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} - originals implied by Versions already in history.");
continue;
}
Console.WriteLine($"[WARN] No original ITEM found for SeriesId={seriesId} {seriesTitle}");
if (HasAllSeriesEpisodesInHistory(historyIndex, seriesId, seriesGroup)){
Console.WriteLine($"[History] Skip (already in history): {seriesId}");
} else{
await CrUpdateSeries(seriesId, null);
Console.WriteLine($"[History] Updating (full series): {seriesId}");
}
}
return;
}
private string? TryGetOriginalId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.Guid))
?.Guid;
private string? TryGetOriginalSeasonId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.SeasonGuid))
?.SeasonGuid
?? e.EpisodeMetadata?.SeasonId;
private bool IsOriginalItem(CrBrowseEpisode e){
var originalId = TryGetOriginalId(e);
return !string.IsNullOrWhiteSpace(originalId)
&& !string.IsNullOrWhiteSpace(e.Id)
&& string.Equals(e.Id, originalId, StringComparison.Ordinal);
}
private bool IsOriginalInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, string? seasonId, string originalEpisodeId){
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
if (!string.IsNullOrWhiteSpace(seasonId))
return seasons.TryGetValue(seasonId, out var eps) && eps.Contains(originalEpisodeId);
return seasons.Values.Any(eps => eps.Contains(originalEpisodeId));
}
private bool HasAllSeriesEpisodesInHistory(Dictionary<string, Dictionary<string, HashSet<string?>>> historyIndex, string seriesId, IEnumerable<CrBrowseEpisode> seriesEpisodes){
if (!historyIndex.TryGetValue(seriesId, out var seasons)) return false;
var allHistoryEpisodeIds = seasons.Values
.SelectMany(set => set)
.ToHashSet(StringComparer.Ordinal);
foreach (var e in seriesEpisodes){
if (string.IsNullOrWhiteSpace(e.Id)) return false;
if (!allHistoryEpisodeIds.Contains(e.Id)) return false;
}
return true;
}
/// <summary>
/// This method updates the History with a list of episodes. The episodes have to be from the same season.
/// </summary>
@ -206,7 +330,7 @@ public class History{
historySeries = new HistorySeries{
SeriesTitle = firstEpisode.GetSeriesTitle(),
SeriesId = firstEpisode.GetSeriesId(),
Seasons =[],
Seasons = [],
HistorySeriesAddDate = DateTime.Now,
SeriesType = firstEpisode.GetSeriesType(),
SeriesStreamingService = StreamingService.Crunchyroll
@ -302,8 +426,8 @@ public class History{
var downloadDirPath = "";
var videoQuality = "";
List<string> dublist =[];
List<string> sublist =[];
List<string> dublist = [];
List<string> sublist = [];
if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -353,7 +477,7 @@ public class History{
public List<string> GetDubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> dublist =[];
List<string> dublist = [];
if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
@ -372,7 +496,7 @@ public class History{
public (List<string> sublist, string videoQuality) GetSubList(string? seriesId, string? seasonId){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
List<string> sublist =[];
List<string> sublist = [];
var videoQuality = "";
if (historySeries != null){
@ -430,8 +554,8 @@ public class History{
SeriesId = artisteData.Id,
SeriesTitle = artisteData.Name ?? "",
ThumbnailImageUrl = artisteData.Images.PosterTall.FirstOrDefault(e => e.Height == 360)?.Source ?? "",
HistorySeriesAvailableDubLang =[],
HistorySeriesAvailableSoftSubs =[]
HistorySeriesAvailableDubLang = [],
HistorySeriesAvailableSoftSubs = []
};
historySeries.SeriesDescription = cachedSeries.SeriesDescription;
@ -563,7 +687,7 @@ public class History{
SeasonTitle = firstEpisode.GetSeasonTitle(),
SeasonId = firstEpisode.GetSeasonId(),
SeasonNum = firstEpisode.GetSeasonNum(),
EpisodesList =[],
EpisodesList = [],
SpecialSeason = firstEpisode.IsSpecialSeason()
};
@ -631,7 +755,7 @@ public class History{
historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
List<HistoryEpisode> allHistoryEpisodes =[];
List<HistoryEpisode> allHistoryEpisodes = [];
foreach (var historySeriesSeason in historySeries.Seasons){
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
@ -659,7 +783,7 @@ public class History{
.ToList();
}
List<HistoryEpisode> failedEpisodes =[];
List<HistoryEpisode> failedEpisodes = [];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
@ -12,10 +13,12 @@ using Avalonia.Styling;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Utils.Updater;
using FluentAvalonia.Styling;
using ProtoBuf.Meta;
namespace CRD.Downloader;
@ -75,10 +78,12 @@ public partial class ProgramManager : ObservableObject{
#endregion
private readonly PeriodicWorkRunner checkForNewEpisodesRunner;
public IStorageProvider StorageProvider;
public ProgramManager(){
checkForNewEpisodesRunner = new PeriodicWorkRunner(async ct => { await CheckForDownloadsAsync(ct); });
_faTheme = Application.Current?.Styles[0] as FluentAvaloniaTheme;
foreach (var arg in Environment.GetCommandLineArgs()){
@ -106,7 +111,7 @@ public partial class ProgramManager : ObservableObject{
}
}
Init();
_ = Init();
CleanUpOldUpdater();
}
@ -172,12 +177,53 @@ public partial class ProgramManager : ObservableObject{
await Task.WhenAll(tasks);
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress.Done != true)){
while (QueueManager.Instance.Queue.Any(e => e.DownloadProgress is{ Done: false, Error: false })){
Console.WriteLine("Waiting for downloads to complete...");
await Task.Delay(2000);
await Task.Delay(2000);
}
}
private async Task CheckForDownloadsAsync(CancellationToken ct){
var crunchyManager = CrunchyrollManager.Instance;
var crunOptions = crunchyManager.CrunOptions;
if (!crunOptions.History){
return;
}
switch (crunOptions.HistoryAutoRefreshMode){
case HistoryRefreshMode.DefaultAll:
await RefreshHistory(FilterType.All);
break;
case HistoryRefreshMode.DefaultActive:
await RefreshHistory(FilterType.Active);
break;
case HistoryRefreshMode.FastNewReleases:
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data ?? [];
try{
await crunchyManager.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){
Console.Error.WriteLine("Failed to update History");
}
}
break;
default:
return;
}
var tasks = crunchyManager.HistoryList
.Select(item => item.AddNewMissingToDownloads(true));
await Task.WhenAll(tasks);
}
public void SetBackgroundImage(){
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath)){
Helpers.SetBackgroundImage(CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath, CrunchyrollManager.Instance.CrunOptions.BackgroundImageOpacity,
@ -186,33 +232,41 @@ public partial class ProgramManager : ObservableObject{
}
private async Task Init(){
CrunchyrollManager.Instance.InitOptions();
try{
CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
OpacityButton = UpdateAvailable ? 1.0 : 0.4;
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);
}
if (_faTheme != null && Application.Current != null){
if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){
_faTheme.PreferSystemTheme = true;
} else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
}
if (_faTheme != null && Application.Current != null){
if (CrunchyrollManager.Instance.CrunOptions.Theme == "System"){
_faTheme.PreferSystemTheme = true;
} else if (CrunchyrollManager.Instance.CrunOptions.Theme == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
}
}
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
StartRunners(true);
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
NavigationLock = false;
}
await CrunchyrollManager.Instance.Init();
FinishedLoading = true;
await WorkOffArgsTasks();
}
@ -238,8 +292,7 @@ public partial class ProgramManager : ObservableObject{
}
}
}
private void CleanUpOldUpdater(){
var executableExtension = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) ? ".exe" : string.Empty;
@ -256,4 +309,17 @@ public partial class ProgramManager : ObservableObject{
Console.WriteLine("No old updater file found to delete.");
}
}
public DateTime GetLastRefreshTime(){
return checkForNewEpisodesRunner.LastRunTime;
}
public void StartRunners(bool runImmediately = false){
checkForNewEpisodesRunner.StartOrRestartMinutes(CrunchyrollManager.Instance.CrunOptions.HistoryAutoRefreshIntervalMinutes,runImmediately);
}
public void StopBackgroundTasks(){
checkForNewEpisodesRunner.Stop();
}
}

View file

@ -139,8 +139,8 @@ public partial class QueueManager : ObservableObject{
(HistoryEpisode? historyEpisode, List<string> dublist, List<string> sublist, string downloadDirPath, string videoQuality) historyEpisode = (null, [], [], "", "");
if (CrunchyrollManager.Instance.CrunOptions.History){
var episode = sList.EpisodeAndLanguages.Items.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(episode.SeriesId, episode.SeasonId, episode.Id);
var variant = sList.EpisodeAndLanguages.Variants.First();
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(variant.Item.SeriesId, variant.Item.SeasonId, variant.Item.Id);
if (historyEpisode.dublist.Count > 0){
dubLang = historyEpisode.dublist;
}
@ -238,8 +238,9 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Added Episode to Queue but couldn't find all selected dubs");
Console.Error.WriteLine("Added Episode to Queue but couldn't find all selected dubs - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray();
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.CrLocale ?? "Unknown"}")
.ToArray();
Console.Error.WriteLine(
$"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]");
@ -252,8 +253,9 @@ public partial class QueueManager : ObservableObject{
Console.WriteLine("Episode couldn't be added to Queue");
Console.Error.WriteLine("Episode couldn't be added to Queue - Available dubs/subs: ");
var languages = sList.EpisodeAndLanguages.Items.Select((a, index) =>
$"{(a.IsPremiumOnly ? "+ " : "")}{sList.EpisodeAndLanguages.Langs.ElementAtOrDefault(index)?.CrLocale ?? "Unknown"}").ToArray();
var languages = sList.EpisodeAndLanguages.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.CrLocale ?? "Unknown"}")
.ToArray();
Console.Error.WriteLine($"{selected.SeasonTitle} - Season {selected.Season} - {selected.EpisodeTitle} dubs - [{string.Join(", ", languages)}] subs - [{string.Join(", ", selected.AvailableSubs ??[])}]");
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode to the queue with current dub settings", ToastType.Error, 2));
@ -374,7 +376,7 @@ public partial class QueueManager : ObservableObject{
public async Task CrAddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.But, data.AllEpisodes, data.E);
var selected = CrunchyrollManager.Instance.CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.AllEpisodes, data.E);
var failed = false;
var partialAdd = false;

View file

@ -6,29 +6,19 @@ using ReactiveUI.Avalonia;
namespace CRD;
sealed class Program{
// Initialization code. Don't use any Avalonia, third-party APIs or any
// SynchronizationContext-reliant code before AppMain is called: things aren't initialized
// yet and stuff might break.
[STAThread]
public static void Main(string[] args){
var isHeadless = args.Contains("--headless");
BuildAvaloniaApp(isHeadless).StartWithClassicDesktopLifetime(args);
}
// Avalonia configuration, don't remove; also used by visual designer.
// public static AppBuilder BuildAvaloniaApp()
// => AppBuilder.Configure<App>()
// .UsePlatformDetect()
// .WithInterFont()
// .LogToTrace();
public static AppBuilder BuildAvaloniaApp(bool isHeadless){
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace()
.UseReactiveUI() ;
.UseReactiveUI(_ => { });
if (isHeadless){
Console.WriteLine("Running in headless mode...");

View file

@ -276,6 +276,12 @@ public enum EpisodeDownloadMode{
OnlySubs,
}
public enum HistoryRefreshMode{
DefaultAll = 0,
DefaultActive = 1,
FastNewReleases = 50
}
public enum SonarrCoverType{
Banner,
FanArt,

View file

@ -7,6 +7,7 @@ using System.Linq;
using System.Net.Http;
using System.Runtime.InteropServices;
using System.Runtime.Serialization;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@ -360,158 +361,201 @@ public class Helpers{
}
}
private static string GetQualityOption(VideoPreset preset){
private static IEnumerable<string> GetQualityOption(VideoPreset preset){
return preset.Codec switch{
"h264_nvenc" or "hevc_nvenc" => $"-cq {preset.Crf}", // For NVENC
"h264_qsv" or "hevc_qsv" => $"-global_quality {preset.Crf}", // For Intel QSV
"h264_amf" or "hevc_amf" => $"-qp {preset.Crf}", // For AMD VCE
_ => $"-crf {preset.Crf}", // For software codecs like libx264/libx265
"h264_nvenc" or "hevc_nvenc" =>["-cq", preset.Crf.ToString()],
"h264_qsv" or "hevc_qsv" =>["-global_quality", preset.Crf.ToString()],
"h264_amf" or "hevc_amf" =>["-qp", preset.Crf.ToString()],
_ =>["-crf", preset.Crf.ToString()]
};
}
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(string inputFilePath, VideoPreset preset, CrunchyEpMeta? data = null){
public static async Task<(bool IsOk, int ErrorCode)> RunFFmpegWithPresetAsync(
string inputFilePath,
VideoPreset preset,
CrunchyEpMeta? data = null){
try{
string outputExtension = Path.GetExtension(inputFilePath);
string directory = Path.GetDirectoryName(inputFilePath);
string fileNameWithoutExtension = Path.GetFileNameWithoutExtension(inputFilePath);
string tempOutputFilePath = Path.Combine(directory, $"{fileNameWithoutExtension}_output{outputExtension}");
string ext = Path.GetExtension(inputFilePath);
string dir = Path.GetDirectoryName(inputFilePath)!;
string name = Path.GetFileNameWithoutExtension(inputFilePath);
string additionalParams = string.Join(" ", preset.AdditionalParameters.Select(param => {
var splitIndex = param.IndexOf(' ');
if (splitIndex > 0){
var prefix = param[..splitIndex];
var value = param[(splitIndex + 1)..];
if (value.Contains(' ') && !(value.StartsWith("\"") && value.EndsWith("\""))){
value = $"\"{value}\"";
}
return $"{prefix} {value}";
}
return param;
}));
string qualityOption = GetQualityOption(preset);
string tempOutput = Path.Combine(dir, $"{name}_output{ext}");
TimeSpan? totalDuration = await GetMediaDurationAsync(CfgManager.PathFFMPEG, inputFilePath);
if (totalDuration == null){
Console.Error.WriteLine("Unable to retrieve input file duration.");
} else{
Console.WriteLine($"Total Duration: {totalDuration}");
var args = new List<string>{
"-nostdin",
"-hide_banner",
"-loglevel", "error",
"-i", inputFilePath,
};
if (!string.IsNullOrWhiteSpace(preset.Codec)){
args.Add("-c:v");
args.Add(preset.Codec);
}
args.AddRange(GetQualityOption(preset));
string ffmpegCommand = $"-loglevel info -i \"{inputFilePath}\" -c:v {preset.Codec} {qualityOption} -vf \"scale={preset.Resolution},fps={preset.FrameRate}\" {additionalParams} \"{tempOutputFilePath}\"";
using (var process = new Process()){
process.StartInfo.FileName = CfgManager.PathFFMPEG;
process.StartInfo.Arguments = ffmpegCommand;
process.StartInfo.RedirectStandardOutput = true;
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.EnableRaisingEvents = true;
args.Add("-vf");
args.Add($"scale={preset.Resolution},fps={preset.FrameRate}");
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.WriteLine(e.Data);
}
};
foreach (var param in preset.AdditionalParameters){
args.AddRange(SplitArguments(param));
}
process.ErrorDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine($"{e.Data}");
if (data != null && totalDuration != null){
ParseProgress(e.Data, totalDuration.Value, data);
}
}
};
args.Add(tempOutput);
process.Start();
process.BeginOutputReadLine();
process.BeginErrorReadLine();
using var reg = data?.Cts.Token.Register(() => {
string commandString = BuildCommandString(CfgManager.PathFFMPEG, args);
int exitCode;
try{
exitCode = await RunFFmpegAsync(
CfgManager.PathFFMPEG,
args,
data?.Cts.Token ?? CancellationToken.None,
onStdErr: line => { Console.Error.WriteLine(line); },
onStdOut: Console.WriteLine
);
} catch (OperationCanceledException){
if (File.Exists(tempOutput)){
try{
if (!process.HasExited)
process.Kill(true);
File.Delete(tempOutput);
} catch{
// ignored
}
});
try{
await process.WaitForExitAsync(data.Cts.Token);
} catch (OperationCanceledException){
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
// ignored
}
}
return (IsOk: false, ErrorCode: -2);
}
bool isSuccess = process.ExitCode == 0;
if (isSuccess){
// Delete the original input file
File.Delete(inputFilePath);
// Rename the output file to the original name
File.Move(tempOutputFilePath, inputFilePath);
} else{
// If something went wrong, delete the temporary output file
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
/* ignore */
}
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine($"Command: {ffmpegCommand}");
}
return (IsOk: isSuccess, ErrorCode: process.ExitCode);
Console.Error.WriteLine("FFMPEG task was canceled");
return (false, -2);
}
bool success = exitCode == 0;
if (success){
File.Delete(inputFilePath);
File.Move(tempOutput, inputFilePath);
} else{
if (File.Exists(tempOutput)){
File.Delete(tempOutput);
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine("Command:");
Console.Error.WriteLine(commandString);
}
return (success, exitCode);
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}");
return (IsOk: false, ErrorCode: -1);
Console.Error.WriteLine(ex);
return (false, -1);
}
}
private static void ParseProgress(string progressString, TimeSpan totalDuration, CrunchyEpMeta data){
try{
if (progressString.Contains("time=")){
var timeIndex = progressString.IndexOf("time=") + 5;
var timeString = progressString.Substring(timeIndex, 11);
private static IEnumerable<string> SplitArguments(string commandLine){
var args = new List<string>();
var current = new StringBuilder();
bool inQuotes = false;
if (TimeSpan.TryParse(timeString, out var currentTime)){
int progress = (int)(currentTime.TotalSeconds / totalDuration.TotalSeconds * 100);
Console.WriteLine($"Progress: {progress:F2}%");
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = progress,
Time = 0,
DownloadSpeedBytes = 0,
Doing = "Encoding"
};
QueueManager.Instance.Queue.Refresh();
}
foreach (char c in commandLine){
if (c == '"'){
inQuotes = !inQuotes;
continue;
}
if (char.IsWhiteSpace(c) && !inQuotes){
if (current.Length > 0){
args.Add(current.ToString());
current.Clear();
}
} else{
current.Append(c);
}
} catch (Exception e){
Console.Error.WriteLine("Failed to calculate encoding progess");
Console.Error.WriteLine(e.Message);
}
if (current.Length > 0)
args.Add(current.ToString());
return args;
}
private static string BuildCommandString(string exe, IEnumerable<string> args){
static string Quote(string s){
if (string.IsNullOrWhiteSpace(s))
return "\"\"";
return s.Contains(' ') || s.Contains('"')
? $"\"{s.Replace("\"", "\\\"")}\""
: s;
}
return exe + " " + string.Join(" ", args.Select(Quote));
}
public static async Task<int> RunFFmpegAsync(
string ffmpegPath,
IEnumerable<string> args,
CancellationToken token,
Action<string>? onStdErr = null,
Action<string>? onStdOut = null){
using var process = new Process();
process.StartInfo = new ProcessStartInfo{
FileName = ffmpegPath,
RedirectStandardOutput = true,
RedirectStandardError = true,
RedirectStandardInput = true,
UseShellExecute = false,
CreateNoWindow = true
};
foreach (var arg in args)
process.StartInfo.ArgumentList.Add(arg);
process.Start();
// capture streams instead of process
var stdout = process.StandardOutput;
var stderr = process.StandardError;
async Task ReadStreamAsync(StreamReader reader, Action<string>? callback){
while (await reader.ReadLineAsync(token) is{ } line)
callback?.Invoke(line);
}
var stdoutTask = ReadStreamAsync(stdout, onStdOut);
var stderrTask = ReadStreamAsync(stderr, onStdErr);
var proc = process;
await using var reg = token.Register(() => {
try{
proc.Kill(true);
} catch{
// ignored
}
});
try{
await process.WaitForExitAsync(token);
} catch (OperationCanceledException){
try{
if (!process.HasExited)
process.Kill(true);
} catch{
// ignored
}
throw;
}
await Task.WhenAll(stdoutTask, stderrTask);
return process.ExitCode;
}
public static async Task<TimeSpan?> GetMediaDurationAsync(string ffmpegPath, string inputFilePath){
try{
using (var process = new Process()){
@ -855,7 +899,7 @@ public class Helpers{
bool mergeVideo){
if (target == null) throw new ArgumentNullException(nameof(target));
if (source == null) throw new ArgumentNullException(nameof(source));
var serverSet = new HashSet<string>(target.servers);
void AddServer(string s){
@ -866,14 +910,14 @@ public class Helpers{
foreach (var kvp in source){
var key = kvp.Key;
var src = kvp.Value;
if (!src.servers.Contains(key))
src.servers.Add(key);
AddServer(key);
foreach (var s in src.servers)
AddServer(s);
if (mergeAudio && src.audio != null){
target.audio ??= [];
target.audio.AddRange(src.audio);
@ -911,6 +955,8 @@ public class Helpers{
if (result == ContentDialogResult.Primary){
timer.Stop();
}
} catch (Exception e){
Console.Error.WriteLine(e);
} finally{
ShutdownLock.Release();
}
@ -967,4 +1013,18 @@ public class Helpers{
Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}");
}
}
public static bool ExecuteFile(string filePath){
try{
Process.Start(new ProcessStartInfo{
FileName = filePath,
UseShellExecute = true
});
return true;
} catch (Exception ex){
Console.Error.WriteLine($"Execution failed: {ex.Message}");
return false;
}
}
}

View file

@ -296,7 +296,7 @@ public static class ApiUrls{
public static string Auth => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/auth/v1/token";
public static string Profile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/profile";
public static string Profiles => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
public static string MultiProfile => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/accounts/v1/me/multiprofile";
public static string CmsToken => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/index/v2";
public static string Search => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/search";
public static string Browse => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/discover/browse";

View file

@ -2,37 +2,40 @@
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Views;
using SixLabors.Fonts;
namespace CRD.Utils.Muxing;
public class FontsManager{
#region Singelton
private static FontsManager? instance;
private static readonly object padlock = new object();
private static readonly Lock Padlock = new Lock();
public static FontsManager Instance{
get{
if (instance == null){
lock (padlock){
if (instance == null){
instance = new FontsManager();
if (field == null){
lock (Padlock){
if (field == null){
field = new FontsManager();
}
}
}
return instance;
return field;
}
}
#endregion
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
public Dictionary<string, string> Fonts{ get; private set; } = new(StringComparer.OrdinalIgnoreCase){
{ "Adobe Arabic", "AdobeArabic-Bold.otf" },
{ "Andale Mono", "andalemo.ttf" },
{ "Arial", "arial.ttf" },
@ -103,9 +106,14 @@ public class FontsManager{
{ "Webdings", "webdings.ttf" }
};
private string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/";
private readonly FontIndex index = new();
private void EnsureIndex(string fontsDir){
index.Rebuild(fontsDir);
}
public async Task GetFontsAsync(){
Console.WriteLine("Downloading fonts...");
@ -115,134 +123,334 @@ 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!");
} else{
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0){
File.Delete(fontLoc);
}
continue;
}
try{
if (!Directory.Exists(fontFolder)){
Directory.CreateDirectory(fontFolder);
}
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {e.Message}");
}
var fontFolder = Path.GetDirectoryName(fontLoc);
if (File.Exists(fontLoc) && new FileInfo(fontLoc).Length == 0)
File.Delete(fontLoc);
var fontUrl = root + font;
try{
if (!Directory.Exists(fontFolder))
Directory.CreateDirectory(fontFolder!);
} catch (Exception e){
Console.WriteLine($"Failed to create directory: {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}");
var fontUrl = root + font;
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}");
}
}
Console.WriteLine("All required fonts downloaded!");
}
public static List<string> ExtractFontsFromAss(string ass){
var lines = ass.Replace("\r", "").Split('\n');
var styles = new List<string>();
if (string.IsNullOrWhiteSpace(ass))
return new List<string>();
ass = ass.Replace("\r", "");
var lines = ass.Split('\n');
var fonts = new List<string>();
foreach (var line in lines){
if (line.StartsWith("Style: ")){
var parts = line.Split(',');
if (parts.Length > 1)
styles.Add(parts[1].Trim());
if (line.StartsWith("Style: ", StringComparison.OrdinalIgnoreCase)){
var parts = line.Substring(7).Split(',');
if (parts.Length > 1){
var fontName = parts[1].Trim();
fonts.Add(NormalizeFontKey(fontName));
}
}
}
var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)");
foreach (Match match in fontMatches){
if (match.Groups.Count > 1)
styles.Add(match.Groups[1].Value);
if (match.Groups.Count > 1){
var fontName = match.Groups[1].Value.Trim();
fonts.Add(NormalizeFontKey(fontName));
}
}
return styles.Distinct().ToList(); // Using Linq to remove duplicates
return fonts
.Where(x => !string.IsNullOrWhiteSpace(x))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList){
Dictionary<string, string> filteredDictionary = new Dictionary<string, string>();
public Dictionary<string, string> GetDictFromKeyList(List<string> keysList, bool keepUnknown = true){
Dictionary<string, string> filteredDictionary = new(StringComparer.OrdinalIgnoreCase);
foreach (string key in keysList){
if (Fonts.TryGetValue(key, out var font)){
filteredDictionary.Add(key, font);
var k = NormalizeFontKey(key);
if (Fonts.TryGetValue(k, out var fontFile)){
filteredDictionary[k] = fontFile;
} else if (keepUnknown){
filteredDictionary[k] = k;
}
}
return filteredDictionary;
}
public static string GetFontMimeType(string fontFile){
if (Regex.IsMatch(fontFile, @"\.otf$"))
public static string GetFontMimeType(string fontFileOrPath){
var ext = Path.GetExtension(fontFileOrPath);
if (ext.Equals(".otf", StringComparison.OrdinalIgnoreCase))
return "application/vnd.ms-opentype";
else if (Regex.IsMatch(fontFile, @"\.ttf$"))
if (ext.Equals(".ttf", StringComparison.OrdinalIgnoreCase))
return "application/x-truetype-font";
else
return "application/octet-stream";
if (ext.Equals(".ttc", StringComparison.OrdinalIgnoreCase) || ext.Equals(".otc", StringComparison.OrdinalIgnoreCase))
return "application/x-truetype-font";
if (ext.Equals(".woff", StringComparison.OrdinalIgnoreCase))
return "font/woff";
if (ext.Equals(".woff2", StringComparison.OrdinalIgnoreCase))
return "font/woff2";
return "application/octet-stream";
}
public List<ParsedFont> MakeFontsList(string fontsDir, List<SubtitleFonts> subs){
Dictionary<string, string> fontsNameList = new Dictionary<string, string>();
List<string> subsList = new List<string>();
List<ParsedFont> fontsList = new List<ParsedFont>();
bool isNstr = true;
EnsureIndex(fontsDir);
var required = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
var subsLocales = new List<string>();
var fontsList = new List<ParsedFont>();
var missing = new List<string>();
foreach (var s in subs){
foreach (var keyValuePair in s.Fonts){
if (!fontsNameList.ContainsKey(keyValuePair.Key)){
fontsNameList.Add(keyValuePair.Key, keyValuePair.Value);
subsLocales.Add(s.Language.Locale);
foreach (var kv in s.Fonts)
required.Add(NormalizeFontKey(kv.Key));
}
if (subsLocales.Count > 0)
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsLocales), subsLocales.Count);
if (required.Count > 0)
Console.WriteLine("Required fonts: {0} (Total: {1})", string.Join(", ", required), required.Count);
foreach (var requested in required){
if (TryResolveFontPath(requested, fontsDir, out var resolvedPath, out var exact)){
if (!File.Exists(resolvedPath) || new FileInfo(resolvedPath).Length == 0){
missing.Add(requested);
continue;
}
}
subsList.Add(s.Language.Locale);
}
var attachName = MakeUniqueAttachmentName(resolvedPath, fontsList);
if (subsList.Count > 0){
Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count);
isNstr = false;
}
fontsList.Add(new ParsedFont{
Name = attachName,
Path = resolvedPath,
Mime = GetFontMimeType(resolvedPath)
});
if (fontsNameList.Count > 0){
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 fontFile)){
string fontPath = Path.Combine(fontsDir, fontFile);
string mime = GetFontMimeType(fontFile);
if (File.Exists(fontPath) && new FileInfo(fontPath).Length != 0){
fontsList.Add(new ParsedFont{ Name = fontFile, Path = fontPath, Mime = mime });
}
if (!exact) Console.WriteLine($"Soft-resolved '{requested}' -> '{Path.GetFileName(resolvedPath)}'");
} else{
missingFonts.Add(f.Key);
missing.Add(requested);
}
}
if (missingFonts.Count > 0){
MainWindow.Instance.ShowError($"Missing Fonts: \n{string.Join(", ", fontsNameList)}");
}
if (missing.Count > 0)
MainWindow.Instance.ShowError($"Missing Fonts:\n{string.Join(", ", missing)}");
return fontsList;
}
private bool TryResolveFontPath(string requestedName, string fontsDir, out string resolvedPath, out bool isExactMatch){
resolvedPath = string.Empty;
isExactMatch = true;
var req = NormalizeFontKey(requestedName);
if (index.TryResolve(req, out resolvedPath))
return true;
if (Fonts.TryGetValue(req, out var crFile)){
var p = Path.Combine(fontsDir, crFile);
if (File.Exists(p)){
resolvedPath = p;
return true;
}
}
var family = StripStyleSuffix(req);
if (!family.Equals(req, StringComparison.OrdinalIgnoreCase)){
isExactMatch = false;
if (index.TryResolve(family, out resolvedPath))
return true;
if (Fonts.TryGetValue(family, out var crFamilyFile)){
var p = Path.Combine(fontsDir, crFamilyFile);
if (File.Exists(p)){
resolvedPath = p;
return true;
}
}
}
return false;
}
private static string StripStyleSuffix(string name){
var n = name;
n = Regex.Replace(n, @"\s+(Bold\s+Italic|Bold\s+Oblique|Black\s+Italic|Black|Bold|Italic|Oblique|Regular)$",
"", RegexOptions.IgnoreCase).Trim();
return n;
}
public static string NormalizeFontKey(string s){
if (string.IsNullOrWhiteSpace(s))
return string.Empty;
s = s.Trim().Trim('"');
if (s.StartsWith("@"))
s = s.Substring(1);
s = Regex.Replace(s, @"(?<=[a-z])([A-Z])", " $1");
s = s.Replace('_', ' ').Replace('-', ' ');
s = Regex.Replace(s, @"\s+", " ").Trim();
s = Regex.Replace(s, @"\s+Regular$", "", RegexOptions.IgnoreCase);
return s;
}
private static string MakeUniqueAttachmentName(string path, List<ParsedFont> existing){
var baseName = Path.GetFileName(path);
if (existing.All(e => !baseName.Equals(e.Name, StringComparison.OrdinalIgnoreCase)))
return baseName;
var hash = Convert.ToHexString(SHA1.HashData(Encoding.UTF8.GetBytes(path)))
.Substring(0, 8)
.ToLowerInvariant();
return $"{hash}-{baseName}";
}
private sealed class FontIndex{
private readonly Dictionary<string, Candidate> map = new(StringComparer.OrdinalIgnoreCase);
public void Rebuild(string fontsDir){
map.Clear();
if (!Directory.Exists(fontsDir)) return;
foreach (var path in Directory.EnumerateFiles(fontsDir, "*.*", SearchOption.AllDirectories)){
var ext = Path.GetExtension(path).ToLowerInvariant();
if (ext is not (".ttf" or ".otf" or ".ttc" or ".otc" or ".woff" or ".woff2"))
continue;
foreach (var desc in LoadDescriptions(path)){
foreach (var alias in BuildAliases(desc)){
Add(alias, path);
}
}
}
}
public bool TryResolve(string fontName, out string path){
path = string.Empty;
if (string.IsNullOrWhiteSpace(fontName)) return false;
var key = NormalizeFontKey(fontName);
if (map.TryGetValue(fontName, out var c1)){
path = c1.Path;
return true;
}
if (map.TryGetValue(key, out var c2)){
path = c2.Path;
return true;
}
return false;
}
private void Add(string alias, string path){
if (string.IsNullOrWhiteSpace(alias)) return;
var a1 = alias.Trim();
var a2 = NormalizeFontKey(a1);
Upsert(a1, path);
Upsert(a2, path);
}
private void Upsert(string key, string path){
if (string.IsNullOrWhiteSpace(key)) return;
var cand = new Candidate(path, GetScore(path));
if (map.TryGetValue(key, out var existing)){
if (cand.Score > existing.Score)
map[key] = cand;
} else{
map[key] = cand;
}
}
private static int GetScore(string path){
var ext = Path.GetExtension(path).ToLowerInvariant();
return ext switch{
".ttf" => 100,
".otf" => 95,
".ttc" => 90,
".otc" => 85,
".woff" => 40,
".woff2" => 35,
_ => 0
};
}
private static IEnumerable<FontDescription> LoadDescriptions(string fontPath){
var ext = Path.GetExtension(fontPath).ToLowerInvariant();
if (ext is ".ttc" or ".otc")
return FontDescription.LoadFontCollectionDescriptions(fontPath);
return new[]{ FontDescription.LoadDescription(fontPath) };
}
private static IEnumerable<string> BuildAliases(FontDescription d){
var family = d.FontFamilyInvariantCulture.Trim();
var sub = d.FontSubFamilyNameInvariantCulture.Trim(); // Regular/Bold/Italic
var full = d.FontNameInvariantCulture.Trim(); // "Family Subfamily"
if (!string.IsNullOrWhiteSpace(family)) yield return family;
if (!string.IsNullOrWhiteSpace(full)) yield return full;
if (!string.IsNullOrWhiteSpace(family) &&
!string.IsNullOrWhiteSpace(sub) &&
!sub.Equals("Regular", StringComparison.OrdinalIgnoreCase)){
yield return $"{family} {sub}";
}
}
private readonly record struct Candidate(string Path, int Score);
}
}
public class SubtitleFonts{
public LanguageItem Language{ get; set; }
public Dictionary<string, string> Fonts{ get; set; }
public LanguageItem Language{ get; set; } = new();
public Dictionary<string, string> Fonts{ get; set; } = new();
}

View file

@ -0,0 +1,83 @@
using System;
using System.Threading;
using System.Threading.Tasks;
namespace CRD.Utils;
public class PeriodicWorkRunner(Func<CancellationToken, Task> work) : IDisposable{
private CancellationTokenSource? cts;
private Task? loopTask;
private TimeSpan currentInterval;
public DateTime LastRunTime = DateTime.MinValue;
public void StartOrRestart(TimeSpan interval, bool runImmediately = false, bool force = false){
if (interval <= TimeSpan.Zero){
Stop();
currentInterval = Timeout.InfiniteTimeSpan;
return;
}
if (!force && interval == currentInterval){
return;
}
currentInterval = interval;
Stop();
cts = new CancellationTokenSource();
loopTask = RunLoopAsync(interval, runImmediately, cts.Token);
}
public void StartOrRestartMinutes(int minutes, bool runImmediately = false, bool force = false)
=> StartOrRestart(TimeSpan.FromMinutes(minutes), runImmediately);
public void Stop(){
if (cts is null) return;
try{
cts.Cancel();
} finally{
cts.Dispose();
cts = null;
}
}
private async Task RunLoopAsync(TimeSpan interval, bool runImmediately, CancellationToken token){
if (runImmediately){
await SafeRunWork(token).ConfigureAwait(false);
}
using var timer = new PeriodicTimer(interval);
try{
while (await timer.WaitForNextTickAsync(token).ConfigureAwait(false)){
await SafeRunWork(token).ConfigureAwait(false);
}
} catch (OperationCanceledException){
}
}
private int running = 0;
private async Task SafeRunWork(CancellationToken token){
if (Interlocked.Exchange(ref running, 1) == 1){
Console.Error.WriteLine("Task is already running!");
return;
}
try{
await work(token).ConfigureAwait(false);
LastRunTime = DateTime.Now;
} catch (OperationCanceledException) when (token.IsCancellationRequested){
} catch (Exception ex){
Console.Error.WriteLine(ex);
} finally{
Interlocked.Exchange(ref running, 0);
}
}
public void Dispose() => Stop();
}

View file

@ -56,6 +56,11 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; }
[JsonProperty("download_finished_execute")]
public bool DownloadFinishedExecute{ get; set; }
[JsonProperty("download_finished_execute_path")]
public string? DownloadFinishedExecutePath{ get; set; }
[JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; }
@ -92,6 +97,13 @@ public class CrDownloadOptions{
[JsonProperty("history_count_sonarr")]
public bool HistoryCountSonarr{ get; set; }
[JsonProperty("history_auto_refresh_interval_minutes")]
public int HistoryAutoRefreshIntervalMinutes{ get; set; }
[JsonProperty("history_auto_refresh_mode")]
public HistoryRefreshMode HistoryAutoRefreshMode{ get; set; }
[JsonProperty("sonarr_properties")]
public SonarrProperties? SonarrProperties{ get; set; }
@ -141,6 +153,17 @@ public class CrDownloadOptions{
[JsonProperty("flare_solverr_properties")]
public FlareSolverrProperties? FlareSolverrProperties{ get; set; }
[JsonProperty("tray_icon_enabled")]
public bool TrayIconEnabled{ get; set; }
[JsonProperty("tray_start_minimized")]
public bool StartMinimizedToTray{ get; set; }
[JsonProperty("tray_on_minimize")]
public bool MinimizeToTray{ get; set; }
[JsonProperty("tray_on_close")]
public bool MinimizeToTrayOnClose{ get; set; }
#endregion
@ -310,6 +333,9 @@ public class CrDownloadOptions{
[JsonProperty("calendar_show_upcoming_episodes")]
public bool CalendarShowUpcomingEpisodes{ get; set; }
[JsonProperty("calendar_update_history")]
public bool UpdateHistoryFromCalendar{ get; set; }
[JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; }

View file

@ -4,7 +4,20 @@ using Newtonsoft.Json;
namespace CRD.Utils.Structs.Crunchyroll;
public class CrMultiProfile{
[JsonProperty("tier_max_profiles")]
public int? TierMaxProfiles{ get; set; }
[JsonProperty("max_profiles")]
public int? MaxProfiles{ get; set; }
[JsonProperty("profiles")]
public List<CrProfile> Profiles{ get; set; } = [];
}
public class CrProfile{
public string? Avatar{ get; set; }
public string? Email{ get; set; }
public string? Username{ get; set; }
@ -20,8 +33,14 @@ public class CrProfile{
[JsonProperty("preferred_content_subtitle_language")]
public string? PreferredContentSubtitleLanguage{ get; set; }
[JsonIgnore]
public Subscription? Subscription{ get; set; }
[JsonProperty("can_switch")]
public bool CanSwitch{ get; set; }
[JsonProperty("is_selected")]
public bool IsSelected{ get; set; }
[JsonProperty("is_pin_protected")]
public bool IsPinProtected{ get; set; }
[JsonIgnore]
public bool HasPremium{ get; set; }

View file

@ -190,7 +190,7 @@ public class CrBrowseEpisodeVersion{
public Locale? AudioLocale{ get; set; }
public string? Guid{ get; set; }
public bool? Original{ get; set; }
public bool Original{ get; set; }
public string? Variant{ get; set; }
[JsonProperty("season_guid")]
@ -200,6 +200,6 @@ public class CrBrowseEpisodeVersion{
public string? MediaGuid{ get; set; }
[JsonProperty("is_premium_only")]
public bool? IsPremiumOnly{ get; set; }
public bool IsPremiumOnly{ get; set; }
}

View file

@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Structs.History;
using CRD.Views;
@ -12,6 +14,23 @@ public class AuthData{
public string Password{ get; set; }
}
public partial class AccountProfile : ObservableObject{
[ObservableProperty]
private string _profileName = "";
[ObservableProperty]
private string _avatarUrl = "";
[ObservableProperty]
private Bitmap? _profileImage;
[ObservableProperty]
private bool _canBeSelected;
public string? ProfileId{ get; set; }
}
public class CrAuthSettings{
public string Endpoint{ get; set; }
public string Authorization{ get; set; }
@ -51,9 +70,18 @@ public class LanguageItem{
public string Language{ get; set; }
}
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
public class EpisodeAndLanguage{
public List<CrunchyEpisode> Items{ get; set; }
public List<LanguageItem> Langs{ get; set; }
public List<EpisodeVariant> Variants{ get; set; } = new();
public bool AddUnique(CrunchyEpisode item, LanguageItem lang){
if (Variants.Any(v => v.Lang.CrLocale == lang.CrLocale))
return false;
Variants.Add(new EpisodeVariant(item, lang));
return true;
}
}
public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){
@ -131,6 +159,11 @@ public class StringItemWithDisplayName{
public string value{ get; set; }
}
public class RefreshModeOption{
public string DisplayName{ get; set; }
public HistoryRefreshMode value{ get; set; }
}
public class WindowSettings{
public double Width{ get; set; }
public double Height{ get; set; }

View file

@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Media.Imaging;
using CRD.Downloader;
@ -140,10 +141,16 @@ public class HistoryEpisode : INotifyPropertyChanged{
}
public async Task DownloadEpisodeDefault(){
await DownloadEpisode();
await DownloadEpisode(EpisodeDownloadMode.Default,"",false);
}
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode, string overrideDownloadPath,bool chekQueueForId){
if (chekQueueForId && QueueManager.Instance.Queue.Any(item => item.Data.Any(epmeta => epmeta.MediaId == EpisodeId))){
Console.Error.WriteLine($"Episode already in queue! E{EpisodeSeasonNum}-{EpisodeTitle}");
return;
}
switch (EpisodeType){
case EpisodeType.MusicVideo:
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);

View file

@ -64,16 +64,16 @@ public class HistorySeries : INotifyPropertyChanged{
public string HistorySeriesVideoQualityOverride{ get; set; } = "";
[JsonProperty("history_series_available_soft_subs")]
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } =[];
public List<string> HistorySeriesAvailableSoftSubs{ get; set; } = [];
[JsonProperty("history_series_available_dub_lang")]
public List<string> HistorySeriesAvailableDubLang{ get; set; } =[];
public List<string> HistorySeriesAvailableDubLang{ get; set; } = [];
[JsonProperty("history_series_soft_subs_override")]
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } =[];
public ObservableCollection<string> HistorySeriesSoftSubsOverride{ get; set; } = [];
[JsonProperty("history_series_dub_lang_override")]
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } =[];
public ObservableCollection<string> HistorySeriesDubLangOverride{ get; set; } = [];
public event PropertyChangedEventHandler? PropertyChanged;
@ -329,7 +329,7 @@ public class HistorySeries : INotifyPropertyChanged{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData)));
}
public async Task AddNewMissingToDownloads(){
public async Task AddNewMissingToDownloads(bool chekQueueForId = false){
bool foundWatched = false;
var options = CrunchyrollManager.Instance.CrunOptions;
@ -355,7 +355,7 @@ public class HistorySeries : INotifyPropertyChanged{
}
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
await ep.DownloadEpisode();
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId);
}
}
}
@ -372,14 +372,14 @@ public class HistorySeries : INotifyPropertyChanged{
if (ep.SpecialEpisode){
if (historyAddSpecials && ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, false)){
await ep.DownloadEpisode();
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId);
}
continue;
}
if (ShouldCountEpisode(ep, sonarrEnabled && useSonarrCounting, countMissing, foundWatched)){
await ep.DownloadEpisode();
await ep.DownloadEpisode(EpisodeDownloadMode.Default, "", chekQueueForId);
} else{
foundWatched = true;
if (!historyAddSpecials && !countMissing){
@ -467,54 +467,61 @@ public class HistorySeries : INotifyPropertyChanged{
}
public void UpdateSeriesFolderPath(){
var season = Seasons.FirstOrDefault(season => !string.IsNullOrEmpty(season.SeasonDownloadPath));
// Reset state first
SeriesFolderPath = string.Empty;
SeriesFolderPathExists = false;
var season = Seasons.FirstOrDefault(s => !string.IsNullOrEmpty(s.SeasonDownloadPath));
// Series path
if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){
SeriesFolderPath = SeriesDownloadPath;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
if (season is{ SeasonDownloadPath: not null }){
// Season path
if (!string.IsNullOrEmpty(season?.SeasonDownloadPath)){
try{
var seasonPath = season.SeasonDownloadPath;
var directoryInfo = new DirectoryInfo(seasonPath);
var directoryInfo = new DirectoryInfo(season.SeasonDownloadPath);
if (!string.IsNullOrEmpty(directoryInfo.Parent?.FullName)){
string parentFolderPath = directoryInfo.Parent?.FullName ?? string.Empty;
var parentFolder = directoryInfo.Parent?.FullName;
if (Directory.Exists(parentFolderPath)){
SeriesFolderPath = parentFolderPath;
SeriesFolderPathExists = true;
}
if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){
SeriesFolderPath = parentFolder;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
} catch (Exception e){
Console.Error.WriteLine($"An error occurred while opening the folder: {e.Message}");
Console.Error.WriteLine($"Error resolving season folder: {e.Message}");
}
} else{
string customPath;
}
if (string.IsNullOrEmpty(SeriesTitle))
return;
// Auto generated path
if (string.IsNullOrEmpty(SeriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
return;
}
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle))
return;
if (string.IsNullOrEmpty(seriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
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);
}
string basePath =
!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.DownloadDirPath)
? CrunchyrollManager.Instance.CrunOptions.DownloadDirPath
: CfgManager.PathVIDEOS_DIR;
// Check if custom path exists
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
}
var customPath = Path.Combine(basePath, seriesTitle);
if (Directory.Exists(customPath)){
SeriesFolderPath = customPath;
SeriesFolderPathExists = true;
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));

View file

@ -127,8 +127,7 @@ public class EpisodeHighlightTextBlock : TextBlock{
streamingService == StreamingService.Crunchyroll ? new HashSet<string>(CrunchyrollManager.Instance.CrunOptions.DlSubs) :
new HashSet<string>();
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) &&
subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []);
var higlight = dubSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableDubLang ?? []) && (subSet.IsSubsetOf(Episode?.HistoryEpisodeAvailableSoftSubs ?? []) || subSet.Contains("all"));
if (higlight){
Foreground = Brushes.Orange;

View file

@ -155,7 +155,7 @@ public class HighlightingTextBlock : TextBlock{
foreach (var item in Items){
var run = new Run(item);
if (highlightSet.Contains(item)){
if (highlightSet.Contains(item) || highlightSet.Contains("all")){
run.Foreground = Brushes.Orange;
// run.FontWeight = FontWeight.Bold;
}

View file

@ -7,6 +7,9 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using CRD.ViewModels.Utils;
using CRD.Views.Utils;
using FluentAvalonia.UI.Controls;
using Newtonsoft.Json;
@ -22,6 +25,9 @@ public partial class AccountPageViewModel : ViewModelBase{
[ObservableProperty]
private string _loginLogoutText = "";
[ObservableProperty]
private bool _hasMultiProfile;
[ObservableProperty]
private string _remainingTime = "";
@ -50,8 +56,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RemainingTime = "Subscription maybe ended";
}
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription, Formatting.Indented));
}
} else{
RemainingTime = $"{(IsCancelled ? "Subscription ending in: " : "Subscription refreshing in: ")}{remaining:dd\\:hh\\:mm\\:ss}";
@ -59,13 +65,18 @@ public partial class AccountPageViewModel : ViewModelBase{
}
public void UpdatetProfile(){
ProfileName = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username ?? CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.ProfileName ?? "???"; // Default or fetched user name
LoginLogoutText = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Username == "???" ? "Login" : "Logout"; // Default state
var firstEndpoint = CrunchyrollManager.Instance.CrAuthEndpoint1;
var firstEndpointProfile = firstEndpoint.Profile;
HasMultiProfile = firstEndpoint.MultiProfile.Profiles.Count > 1;
ProfileName = firstEndpointProfile.ProfileName ?? firstEndpointProfile.Username ?? "???"; // Default or fetched user name
LoginLogoutText = firstEndpointProfile.Username == "???" ? "Login" : "Logout"; // Default state
LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" +
(string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Avatar));
(string.IsNullOrEmpty(firstEndpointProfile.Avatar) ? "crbrand_avatars_logo_marks_mangagirl_taupe.png" : firstEndpointProfile.Avatar));
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription;
var subscriptions = CrunchyrollManager.Instance.CrAuthEndpoint1.Subscription;
if (subscriptions != null){
if (subscriptions.SubscriptionProducts is{ Count: >= 1 }){
@ -84,8 +95,8 @@ public partial class AccountPageViewModel : ViewModelBase{
UnknownEndDate = true;
}
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription?.NextRenewalDate != null && !UnknownEndDate){
_targetTime = CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription.NextRenewalDate;
if (!UnknownEndDate){
_targetTime = subscriptions.NextRenewalDate;
_timer = new DispatcherTimer{
Interval = TimeSpan.FromSeconds(1)
};
@ -101,8 +112,8 @@ public partial class AccountPageViewModel : ViewModelBase{
RaisePropertyChanged(nameof(RemainingTime));
if (CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(CrunchyrollManager.Instance.CrAuthEndpoint1.Profile.Subscription, Formatting.Indented));
if (subscriptions != null){
Console.Error.WriteLine(JsonConvert.SerializeObject(subscriptions, Formatting.Indented));
}
}
@ -137,6 +148,41 @@ public partial class AccountPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public async Task OpenMultiProfileDialog(){
var multiProfile = CrunchyrollManager.Instance.CrAuthEndpoint1.MultiProfile;
var profiels = multiProfile.Profiles.Select(multiProfileProfile => new AccountProfile{
AvatarUrl = string.IsNullOrEmpty(multiProfileProfile.Avatar) ? "" : ("https://static.crunchyroll.com/assets/avatar/170x170/" + multiProfileProfile.Avatar),
ProfileName = multiProfileProfile.Username ?? multiProfileProfile.ProfileName ?? "???", CanBeSelected = multiProfileProfile is{ IsSelected: false, CanSwitch: true, IsPinProtected: false },
ProfileId = multiProfileProfile.ProfileId,
}).ToList();
var dialog = new CustomContentDialog(){
Name = "CRD Select Profile",
Title = "Select Profile",
IsPrimaryButtonEnabled = false,
CloseButtonText = "Close",
FullSizeDesired = true,
};
var viewModel = new ContentDialogMultiProfileSelectViewModel(dialog, profiels);
dialog.Content = new ContentDialogMultiProfileSelectView(){
DataContext = viewModel
};
var dialogResult = await dialog.ShowAsync();
if (dialogResult == ContentDialogResult.Primary){
var selectedProfile = viewModel.SelectedItem;
await CrunchyrollManager.Instance.CrAuthEndpoint1.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
await CrunchyrollManager.Instance.CrAuthEndpoint2.ChangeProfile(selectedProfile.ProfileId ?? string.Empty);
UpdatetProfile();
}
}
public async void LoadProfileImage(string imageUrl){
try{
ProfileImage = await Helpers.LoadImage(imageUrl);

View file

@ -31,6 +31,9 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _showUpcomingEpisodes;
[ObservableProperty]
private bool _updateHistoryFromCalendar;
[ObservableProperty]
private bool _hideDubs;
@ -74,6 +77,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar;
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes;
UpdateHistoryFromCalendar = CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar;
ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0];
@ -289,4 +293,14 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings();
}
}
partial void OnUpdateHistoryFromCalendarChanged(bool value){
if (loading){
return;
}
CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar = value;
CfgManager.WriteCrSettings();
}
}

View file

@ -44,17 +44,17 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem? _selectedView;
public ObservableCollection<ComboBoxItem> ViewsList{ get; } =[];
public ObservableCollection<ComboBoxItem> ViewsList{ get; } = [];
[ObservableProperty]
private SortingListElement? _selectedSorting;
public ObservableCollection<SortingListElement> SortingList{ get; } =[];
public ObservableCollection<SortingListElement> SortingList{ get; } = [];
[ObservableProperty]
private FilterListElement? _selectedFilter;
public ObservableCollection<FilterListElement> FilterList{ get; } =[];
public ObservableCollection<FilterListElement> FilterList{ get; } = [];
[ObservableProperty]
private double _posterWidth;
@ -115,7 +115,16 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private static string _progressText;
[ObservableProperty]
private string _searchInput;
[ObservableProperty]
private bool _isSearchOpen;
[ObservableProperty]
public bool _isSearchActiveClosed;
#region Table Mode
[ObservableProperty]
@ -125,8 +134,8 @@ public partial class HistoryPageViewModel : ViewModelBase{
public Symbol _selectedDownloadIcon = Symbol.ClosedCaption;
#endregion
public Vector LastScrollOffset { get; set; } = Vector.Zero;
public Vector LastScrollOffset{ get; set; } = Vector.Zero;
public HistoryPageViewModel(){
ProgramManager = ProgramManager.Instance;
@ -324,11 +333,32 @@ public partial class HistoryPageViewModel : ViewModelBase{
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
}
if (!string.IsNullOrWhiteSpace(SearchInput)){
var tokens = SearchInput
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
filteredItems.RemoveAll(item => {
var title = item.SeriesTitle ?? string.Empty;
return tokens.Any(t => title.IndexOf(t, StringComparison.OrdinalIgnoreCase) < 0);
});
}
FilteredItems.Clear();
FilteredItems.AddRange(filteredItems);
}
partial void OnSearchInputChanged(string value){
ApplyFilter();
}
partial void OnIsSearchOpenChanged(bool value){
IsSearchActiveClosed = !string.IsNullOrEmpty(SearchInput) && !IsSearchOpen;
}
partial void OnScaleValueChanged(double value){
double t = (ScaleValue - 0.5) / (1 - 0.5);
@ -374,6 +404,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public void ClearSearchCommand(){
SearchInput = "";
}
[RelayCommand]
public void NavToSeries(){
@ -419,7 +453,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
.SelectMany(item => item.Seasons)
.SelectMany(season => season.EpisodesList)
.Where(historyEpisode => !string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) && !historyEpisode.SonarrHasFile)
.Select(historyEpisode => historyEpisode.DownloadEpisode())
.Select(historyEpisode => historyEpisode.DownloadEpisodeDefault())
);
}
@ -515,7 +549,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode();
await episode.DownloadEpisodeDefault();
}
}
@ -528,7 +562,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{
foreach (var episode in missingEpisodes){
await episode.DownloadEpisode();
await episode.DownloadEpisodeDefault();
}
}
}
@ -536,16 +570,16 @@ public partial class HistoryPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisode();
await episode.DownloadEpisodeDefault();
}
}
[RelayCommand]
public async Task DownloadEpisodeOnlyOptions(HistoryEpisode episode){
var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode);
await episode.DownloadEpisode(downloadMode,"",false);
}
}
@ -555,7 +589,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode);
await episode.DownloadEpisode(downloadMode,"",false);
}
}
}
@ -567,9 +601,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
foreach (var historyEpisode in seriesArgs.Season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
seriesArgs.Season.UpdateDownloaded(historyEpisode.EpisodeId);
historyEpisode.ToggleWasDownloaded();
}
}
seriesArgs.Season.UpdateDownloaded();
}
seriesArgs.Series?.UpdateNewEpisodes();
@ -606,7 +641,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
public void ToggleInactive(){
CfgManager.UpdateHistoryFile();
}
partial void OnSelectedDownloadModeChanged(EpisodeDownloadMode value){
SelectedDownloadIcon = SelectedDownloadMode switch{
EpisodeDownloadMode.OnlyVideo => Symbol.Video,

View file

@ -108,6 +108,20 @@ public partial class SeriesPageViewModel : ViewModelBase{
SelectedSeries.UpdateSeriesFolderPath();
}
[RelayCommand]
public void ClearFolderPathCommand(HistorySeason? season){
if (season != null){
season.SeasonDownloadPath = string.Empty;
} else{
SelectedSeries.SeriesDownloadPath = string.Empty;
}
CfgManager.UpdateHistoryFile();
SelectedSeries.UpdateSeriesFolderPath();
}
[RelayCommand]
public async Task OpenFeaturedMusicDialog(){
@ -213,7 +227,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonAll(HistorySeason season){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode();
await episode.DownloadEpisodeDefault();
}
}
@ -226,7 +240,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"There are no missing episodes", ToastType.Error, 3));
} else{
foreach (var episode in missingEpisodes){
await episode.DownloadEpisode();
await episode.DownloadEpisodeDefault();
}
}
}
@ -236,7 +250,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
var downloadMode = SelectedDownloadMode;
if (downloadMode != EpisodeDownloadMode.Default){
await episode.DownloadEpisode(downloadMode);
await episode.DownloadEpisode(downloadMode,"",false);
}
}
@ -246,7 +260,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
if (downloadMode != EpisodeDownloadMode.Default){
foreach (var episode in season.EpisodesList){
await episode.DownloadEpisode(downloadMode);
await episode.DownloadEpisode(downloadMode,"",false);
}
}
}
@ -254,7 +268,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand]
public async Task DownloadSeasonMissingSonarr(HistorySeason season){
foreach (var episode in season.EpisodesList.Where(episode => !episode.SonarrHasFile)){
await episode.DownloadEpisode();
await episode.DownloadEpisodeDefault();
}
}
@ -262,11 +276,11 @@ public partial class SeriesPageViewModel : ViewModelBase{
public void ToggleDownloadedMark(HistorySeason season){
bool allDownloaded = season.EpisodesList.All(ep => ep.WasDownloaded);
foreach (var historyEpisode in season.EpisodesList){
if (historyEpisode.WasDownloaded == allDownloaded){
season.UpdateDownloaded(historyEpisode.EpisodeId);
}
foreach (var historyEpisode in season.EpisodesList.Where(historyEpisode => historyEpisode.WasDownloaded == allDownloaded)){
historyEpisode.ToggleWasDownloaded();
}
season.UpdateDownloaded();
}
[RelayCommand]

View file

@ -81,7 +81,7 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{
[RelayCommand]
public void DownloadEpisode(HistoryEpisode episode){
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath);
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath,false);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){

View file

@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.UI;
using FluentAvalonia.UI.Controls;
namespace CRD.ViewModels.Utils;
public partial class ContentDialogMultiProfileSelectViewModel: ViewModelBase{
private readonly CustomContentDialog dialog;
[ObservableProperty]
private AccountProfile _selectedItem;
[ObservableProperty]
private ObservableCollection<AccountProfile> _profileList = [];
public ContentDialogMultiProfileSelectViewModel(CustomContentDialog contentDialog, List<AccountProfile> profiles){
ArgumentNullException.ThrowIfNull(contentDialog);
dialog = contentDialog;
dialog.Closed += DialogOnClosed;
dialog.PrimaryButtonClick += SaveButton;
try{
_ = LoadProfiles(profiles);
} catch (Exception e){
Console.WriteLine(e);
}
}
private async Task LoadProfiles(List<AccountProfile> profiles){
foreach (var accountProfile in profiles){
accountProfile.ProfileImage = await Helpers.LoadImage(accountProfile.AvatarUrl);
ProfileList.Add(accountProfile);
}
}
partial void OnSelectedItemChanged(AccountProfile value){
dialog.Hide(ContentDialogResult.Primary);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
dialog.PrimaryButtonClick -= SaveButton;
}
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){
dialog.Closed -= DialogOnClosed;
}
}

View file

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.ComponentModel;
using System.Globalization;
using System.IO;
using System.Linq;
using System.Net.Http;
@ -19,6 +20,7 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Sonarr;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
using FluentAvalonia.Styling;
@ -50,6 +52,24 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _historyCountSonarr;
[ObservableProperty]
private double? _historyAutoRefreshIntervalMinutes;
[ObservableProperty]
private HistoryRefreshMode _historyAutoRefreshMode;
[ObservableProperty]
private string _historyAutoRefreshModeHint;
[ObservableProperty]
private string _historyAutoRefreshLastRunTime;
public ObservableCollection<RefreshModeOption> HistoryAutoRefreshModes{ get; } = new(){
new RefreshModeOption(){ DisplayName = "Default All", value = HistoryRefreshMode.DefaultAll },
new RefreshModeOption(){ DisplayName = "Default Active", value = HistoryRefreshMode.DefaultActive },
new RefreshModeOption(){ DisplayName = "Fast New Releases", value = HistoryRefreshMode.FastNewReleases },
};
[ObservableProperty]
private double? _simultaneousDownloads;
@ -74,6 +94,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private double? _retryDelay;
[ObservableProperty]
private bool _trayIconEnabled;
[ObservableProperty]
private bool _startMinimizedToTray;
[ObservableProperty]
private bool _minimizeToTray;
[ObservableProperty]
private bool _minimizeToTrayOnClose;
[ObservableProperty]
private ComboBoxItem _selectedHistoryLang;
@ -231,6 +263,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private string _downloadFinishedSoundPath;
[ObservableProperty]
private bool _downloadFinishedExecute;
[ObservableProperty]
private string _downloadFinishedExecutePath;
[ObservableProperty]
private string _currentIp = "";
@ -263,6 +301,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
DownloadFinishedSoundPath = options.DownloadFinishedSoundPath ?? string.Empty;
DownloadFinishedPlaySound = options.DownloadFinishedPlaySound;
DownloadFinishedExecutePath = options.DownloadFinishedExecutePath ?? string.Empty;
DownloadFinishedExecute = options.DownloadFinishedExecute;
DownloadDirPath = string.IsNullOrEmpty(options.DownloadDirPath) ? CfgManager.PathVIDEOS_DIR : options.DownloadDirPath;
TempDownloadDirPath = string.IsNullOrEmpty(options.DownloadTempDirPath) ? CfgManager.PathTEMP_DIR : options.DownloadTempDirPath;
@ -300,6 +341,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
HistoryAddSpecials = options.HistoryAddSpecials;
HistorySkipUnmonitored = options.HistorySkipUnmonitored;
HistoryCountSonarr = options.HistoryCountSonarr;
HistoryAutoRefreshIntervalMinutes = options.HistoryAutoRefreshIntervalMinutes;
HistoryAutoRefreshMode = options.HistoryAutoRefreshMode;
HistoryAutoRefreshLastRunTime = ProgramManager.Instance.GetLastRefreshTime() == DateTime.MinValue ? "Never" : ProgramManager.Instance.GetLastRefreshTime().ToString("g", CultureInfo.CurrentCulture);
DownloadSpeed = options.DownloadSpeedLimit;
DownloadSpeedInBits = options.DownloadSpeedInBits;
DownloadMethodeNew = options.DownloadMethodeNew;
@ -310,6 +354,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
LogMode = options.LogMode;
TrayIconEnabled = options.TrayIconEnabled;
StartMinimizedToTray = options.StartMinimizedToTray;
MinimizeToTray = options.MinimizeToTray;
MinimizeToTrayOnClose = options.MinimizeToTrayOnClose;
ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
CurrentAppTheme = theme ?? AppThemes[0];
@ -331,6 +380,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
var settings = CrunchyrollManager.Instance.CrunOptions;
settings.DownloadFinishedPlaySound = DownloadFinishedPlaySound;
settings.DownloadFinishedExecute = DownloadFinishedExecute;
settings.DownloadMethodeNew = DownloadMethodeNew;
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
@ -347,6 +398,8 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistorySkipUnmonitored = HistorySkipUnmonitored;
settings.HistoryCountSonarr = HistoryCountSonarr;
settings.HistoryAutoRefreshIntervalMinutes =Math.Clamp((int)(HistoryAutoRefreshIntervalMinutes ?? 0), 0, 1000000000) ;
settings.HistoryAutoRefreshMode = HistoryAutoRefreshMode;
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.DownloadSpeedInBits = DownloadSpeedInBits;
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
@ -404,6 +457,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
settings.FlareSolverrProperties = propsFlareSolverr;
settings.TrayIconEnabled = TrayIconEnabled;
settings.StartMinimizedToTray = StartMinimizedToTray;
settings.MinimizeToTray = MinimizeToTray;
settings.MinimizeToTrayOnClose = MinimizeToTrayOnClose;
settings.LogMode = LogMode;
@ -429,7 +487,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadDirPath = path;
DownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathVIDEOS_DIR : path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadDirPath ?? string.Empty,
defaultPath: CfgManager.PathVIDEOS_DIR
);
}
@ -441,7 +499,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath = path;
TempDownloadDirPath = string.IsNullOrEmpty(path) ? CfgManager.PathTEMP_DIR : path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadTempDirPath ?? string.Empty,
defaultPath: CfgManager.PathTEMP_DIR
);
}
@ -490,7 +548,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
BackgroundImagePath = path;
Helpers.SetBackgroundImage(path, BackgroundImageOpacity, BackgroundImageBlurRadius);
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.BackgroundImagePath ?? string.Empty,
defaultPath: string.Empty
);
}
@ -519,11 +577,39 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath = path;
DownloadFinishedSoundPath = path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath,
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedSoundPath ?? string.Empty,
defaultPath: string.Empty
);
}
#endregion
#region Download Finished Execute File
[RelayCommand]
public void ClearFinishedExectuePath(){
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = string.Empty;
DownloadFinishedExecutePath = string.Empty;
}
[RelayCommand]
public async Task OpenFileDialogAsyncInternalFinishedExecute(){
await OpenFileDialogAsyncInternal(
title: "Select File",
fileTypes: new List<FilePickerFileType>{
new("All Files"){
Patterns = new[]{ "*.*" }
}
},
pathSetter: (path) => {
CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath = path;
DownloadFinishedExecutePath = path;
},
pathGetter: () => CrunchyrollManager.Instance.CrunOptions.DownloadFinishedExecutePath ?? string.Empty,
defaultPath: string.Empty
);
}
#endregion
private async Task OpenFileDialogAsyncInternal(
@ -559,10 +645,10 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
_faTheme.PreferSystemTheme = true;
} else if (value?.Content?.ToString() == "Dark"){
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Dark;
Application.Current?.RequestedThemeVariant = ThemeVariant.Dark;
} else{
_faTheme.PreferSystemTheme = false;
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
Application.Current?.RequestedThemeVariant = ThemeVariant.Light;
}
UpdateSettings();
@ -604,6 +690,11 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
_faTheme.CustomAccentColor = color;
UpdateSettings();
}
partial void OnTrayIconEnabledChanged(bool value){
((App)Application.Current!).SetTrayIconVisible(value);
UpdateSettings();
}
protected override void OnPropertyChanged(PropertyChangedEventArgs e){
base.OnPropertyChanged(e);
@ -613,11 +704,22 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
or nameof(ListBoxColor)
or nameof(CurrentAppTheme)
or nameof(UseCustomAccent)
or nameof(TrayIconEnabled)
or nameof(LogMode)){
return;
}
UpdateSettings();
HistoryAutoRefreshModeHint = HistoryAutoRefreshMode switch{
HistoryRefreshMode.DefaultAll =>
"Refreshes the full history using the default method and includes all entries",
HistoryRefreshMode.DefaultActive =>
"Refreshes the history using the default method and includes only active entries",
HistoryRefreshMode.FastNewReleases =>
"Uses the faster refresh method, similar to the custom calendar, focusing on newly released items",
_ => ""
};
if (e.PropertyName is nameof(History)){
if (CrunchyrollManager.Instance.CrunOptions.History){
@ -658,7 +760,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
[RelayCommand]
public async void CheckIp(){
public async Task CheckIp(){
var result = await HttpClientReq.Instance.SendHttpRequest(HttpClientReq.CreateRequestMessage("https://icanhazip.com", HttpMethod.Get, false));
Console.Error.WriteLine("Your IP: " + result.ResponseContent);
if (result.IsOk){

View file

@ -3,6 +3,7 @@
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:controls="clr-namespace:FluentAvalonia.UI.Controls;assembly=FluentAvalonia"
x:DataType="vm:AccountPageViewModel"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="CRD.Views.AccountPageView">
@ -13,13 +14,33 @@
<StackPanel VerticalAlignment="Top" HorizontalAlignment="Center">
<!-- Profile Image -->
<Image Width="170" Height="170" Margin="20"
Source="{Binding ProfileImage}">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170" />
</Image.Clip>
</Image>
<Grid Width="170" Height="170" Margin="20"
HorizontalAlignment="Center"
VerticalAlignment="Top">
<!-- Profile Image -->
<Image Source="{Binding ProfileImage}">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170" />
</Image.Clip>
</Image>
<!-- Switch Button (Overlay Bottom Right) -->
<Button Width="42"
Height="42"
BorderThickness="0"
CornerRadius="21"
HorizontalAlignment="Right"
VerticalAlignment="Bottom"
Margin="0,0,4,4"
Command="{Binding OpenMultiProfileDialogCommand}"
IsVisible="{Binding HasMultiProfile}"
ToolTip.Tip="Switch Profile">
<controls:SymbolIcon Symbol="Switch" FontSize="30"/>
</Button>
</Grid>
<!-- Profile Name -->
<TextBlock Text="{Binding ProfileName}" HorizontalAlignment="Center" TextAlignment="Center" FontSize="20" Margin="10" />

View file

@ -99,6 +99,10 @@
<CheckBox IsChecked="{Binding ShowUpcomingEpisodes}"
Content="Show Upcoming episodes" Margin="5 5 0 0">
</CheckBox>
<CheckBox IsChecked="{Binding UpdateHistoryFromCalendar}"
Content="Update History from Calendar" Margin="5 5 0 0">
</CheckBox>
</StackPanel>
</controls:SettingsExpander.Footer>
@ -185,7 +189,8 @@
<Grid HorizontalAlignment="Center">
<Grid>
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="../Assets/coming_soon_ep.jpg" />
<Image HorizontalAlignment="Center" MaxHeight="150" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" IsVisible="{Binding !AnilistEpisode}" Source="{Binding ImageBitmap}" />
<Image HorizontalAlignment="Center" IsVisible="{Binding AnilistEpisode}" MaxHeight="150" Source="{Binding ImageBitmap}" />
</Grid>

View file

@ -13,7 +13,7 @@
Unloaded="OnUnloaded"
Loaded="Control_OnLoaded">
<UserControl.Resources>
<UserControl.Resources>
<ui:UiIntToVisibilityConverter x:Key="UiIntToVisibilityConverter" />
<ui:UiSonarrIdToVisibilityConverter x:Key="UiSonarrIdToVisibilityConverter" />
<ui:UiListToStringConverter x:Key="UiListToStringConverter" />
@ -70,6 +70,48 @@
</StackPanel>
</ToggleButton>
<StackPanel>
<ToggleButton x:Name="DropdownButtonSearch" Width="70" Height="70" Background="Transparent" BorderThickness="0" Margin="5 0"
VerticalAlignment="Center"
IsChecked="{Binding IsSearchOpen, Mode=TwoWay}"
IsEnabled="{Binding !ProgramManager.FetchingData}">
<Grid>
<StackPanel Orientation="Vertical">
<controls:SymbolIcon Symbol="Zoom" FontSize="32" />
<TextBlock Text="Search" HorizontalAlignment="Center" FontSize="12"></TextBlock>
</StackPanel>
<Ellipse Width="10" Height="10"
HorizontalAlignment="Right"
VerticalAlignment="Top"
Margin="0,0,0,0"
Fill="Orange"
IsHitTestVisible="False"
IsVisible="{Binding IsSearchActiveClosed}" />
</Grid>
</ToggleButton>
<Popup IsLightDismissEnabled="True"
IsOpen="{Binding IsSearchOpen, Mode=TwoWay}"
Placement="BottomEdgeAlignedRight"
PlacementTarget="{Binding ElementName=DropdownButtonSearch}">
<Border BorderThickness="1" Background="{DynamicResource ComboBoxDropDownBackground}">
<StackPanel Orientation="Horizontal" Margin="10">
<TextBox x:Name="SearchBar" Width="160"
Watermark="Search"
Text="{Binding SearchInput, UpdateSourceTrigger=PropertyChanged}" />
<Button Content="✕" Margin="6,0,0,0"
Command="{Binding ClearSearchCommand}" />
</StackPanel>
</Border>
</Popup>
</StackPanel>
<Rectangle Width="1" Height="50" Fill="Gray" Margin="10,0" />
<StackPanel Margin="10,0">
@ -115,6 +157,7 @@
<!-- <ToggleButton IsChecked="{Binding EditMode}" Margin="10 0" IsEnabled="{Binding !FetchingData}">Edit</ToggleButton> -->
</StackPanel>
<StackPanel Grid.Row="0" Grid.Column="2" Orientation="Horizontal">
<Slider VerticalAlignment="Center" Minimum="0.5" Maximum="1" Width="100"

View file

@ -5,6 +5,7 @@ using System.Linq;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Markup.Xaml;
using Avalonia.Threading;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
@ -46,6 +47,8 @@ public partial class MainWindow : AppWindow{
#endregion
private object selectedNavVieItem;
private ToastNotification? toast;
private const int TitleBarHeightAdjustment = 31;
@ -70,6 +73,7 @@ public partial class MainWindow : AppWindow{
PositionChanged += OnPositionChanged;
SizeChanged += OnSizeChanged;
toast = this.FindControl<ToastNotification>("Toast");
//select first element as default
var nv = this.FindControl<NavigationView>("NavView");
@ -150,55 +154,48 @@ public partial class MainWindow : AppWindow{
public void ShowToast(string message, ToastType type, int durationInSeconds = 5){
var toastControl = this.FindControl<ToastNotification>("Toast");
toastControl?.Show(message, type, durationInSeconds);
Dispatcher.UIThread.Post(() => toast?.Show(message, type, durationInSeconds));
}
private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){
if (sender is NavigationView navView){
var selectedItem = navView.SelectedItem as NavigationViewItem;
if (selectedItem != null){
switch (selectedItem.Tag){
case "DownloadQueue":
navView.Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "AddDownload":
navView.Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Calendar":
navView.Content = Activator.CreateInstance(typeof(CalendarPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "History":
navView.Content = Activator.CreateInstance(typeof(HistoryPageViewModel));
navigationStack.Clear();
navigationStack.Push(navView.Content);
selectedNavVieItem = selectedItem;
break;
case "Seasons":
navView.Content = Activator.CreateInstance(typeof(UpcomingPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Account":
navView.Content = Activator.CreateInstance(typeof(AccountPageViewModel));
selectedNavVieItem = selectedItem;
break;
case "Settings":
var viewModel = (SettingsPageViewModel)Activator.CreateInstance(typeof(SettingsPageViewModel));
navView.Content = viewModel;
selectedNavVieItem = selectedItem;
break;
case "Update":
navView.Content = Activator.CreateInstance(typeof(UpdateViewModel));
selectedNavVieItem = selectedItem;
break;
default:
// (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel));
break;
}
if (sender is NavigationView{ SelectedItem: NavigationViewItem selectedItem } navView){
switch (selectedItem.Tag){
case "DownloadQueue":
navView.Content = Activator.CreateInstance<DownloadsPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "AddDownload":
navView.Content = Activator.CreateInstance<AddDownloadPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "Calendar":
navView.Content = Activator.CreateInstance<CalendarPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "History":
navView.Content = Activator.CreateInstance<HistoryPageViewModel>();
navigationStack.Clear();
navigationStack.Push(navView.Content);
selectedNavVieItem = selectedItem;
break;
case "Seasons":
navView.Content = Activator.CreateInstance<UpcomingPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "Account":
navView.Content = Activator.CreateInstance<AccountPageViewModel>();
selectedNavVieItem = selectedItem;
break;
case "Settings":
var viewModel = Activator.CreateInstance<SettingsPageViewModel>();
navView.Content = viewModel;
selectedNavVieItem = selectedItem;
break;
case "Update":
navView.Content = Activator.CreateInstance<UpdateViewModel>();
selectedNavVieItem = selectedItem;
break;
}
}
}
@ -209,7 +206,7 @@ public partial class MainWindow : AppWindow{
if (settings != null){
var screens = Screens.All;
if (settings.ScreenIndex >= 0 && settings.ScreenIndex < screens.Count){
var screen = screens[settings.ScreenIndex];
// var screen = screens[settings.ScreenIndex];
// Restore the position first
Position = new PixelPoint(settings.PosX, settings.PosY);

View file

@ -161,6 +161,13 @@
<Button Margin="0 0 5 10" FontStyle="Italic"
VerticalAlignment="Center"
Command="{Binding OpenFolderDialogAsync}">
<Button.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Clear Path"
Command="{Binding ClearFolderPathCommand}" />
</MenuFlyout>
</Button.ContextFlyout>
<ToolTip.Tip>
<TextBlock Text="{Binding SelectedSeries.SeriesDownloadPath,
Converter={StaticResource EmptyToDefault},
@ -646,6 +653,13 @@
VerticalAlignment="Center"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).OpenFolderDialogAsync}"
CommandParameter="{Binding .}">
<Button.ContextFlyout>
<MenuFlyout>
<MenuItem Header="Clear Path"
Command="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).ClearFolderPathCommand}"
CommandParameter="{Binding .}"/>
</MenuFlyout>
</Button.ContextFlyout>
<ToolTip.Tip>
<TextBlock Text="{Binding SeasonDownloadPath,
Converter={StaticResource EmptyToDefault},

View file

@ -1,5 +1,6 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using CRD.Downloader;
using CRD.Utils.Sonarr;
using CRD.ViewModels;
@ -13,6 +14,7 @@ public partial class SettingsPageView : UserControl{
private void OnUnloaded(object? sender, RoutedEventArgs e){
if (DataContext is SettingsPageViewModel viewModel){
SonarrClient.Instance.RefreshSonarr();
ProgramManager.Instance.StartRunners();
}
}

View file

@ -0,0 +1,78 @@
<ui:CustomContentDialog xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:CRD.ViewModels.Utils"
xmlns:structs="clr-namespace:CRD.Utils.Structs"
xmlns:ui="clr-namespace:CRD.Utils.UI"
x:DataType="vm:ContentDialogMultiProfileSelectViewModel"
x:Class="CRD.Views.Utils.ContentDialogMultiProfileSelectView">
<Grid HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid.RowDefinitions>
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="Auto" />
<RowDefinition Height="*" />
</Grid.RowDefinitions>
<ScrollViewer Grid.Row="3"
HorizontalScrollBarVisibility="Auto"
VerticalScrollBarVisibility="Disabled">
<ListBox ItemsSource="{Binding ProfileList}"
SelectedItem="{Binding SelectedItem}"
Background="Transparent"
BorderThickness="0">
<!-- Horizontal layout -->
<ListBox.ItemsPanel>
<ItemsPanelTemplate>
<StackPanel Orientation="Horizontal"/>
</ItemsPanelTemplate>
</ListBox.ItemsPanel>
<ListBox.Styles>
<Style Selector="ListBoxItem" x:DataType="structs:AccountProfile">
<Setter Property="IsEnabled">
<Setter.Value>
<Binding Path="CanBeSelected"/>
</Setter.Value>
</Setter>
</Style>
<Style Selector="ListBoxItem:disabled">
<Setter Property="Opacity" Value="0.4"/>
</Style>
</ListBox.Styles>
<ListBox.ItemTemplate >
<DataTemplate>
<StackPanel Width="220"
Margin="0,0,12,0"
IsEnabled="{Binding CanBeSelected}">
<Grid Width="170"
Height="170"
Margin="20"
HorizontalAlignment="Center">
<Image Source="{Binding ProfileImage}">
<Image.Clip>
<EllipseGeometry Rect="0,0,170,170"/>
</Image.Clip>
</Image>
</Grid>
<TextBlock Text="{Binding ProfileName}"
HorizontalAlignment="Center"
FontSize="20"
Margin="10"/>
</StackPanel>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
</ScrollViewer>
</Grid>
</ui:CustomContentDialog>

View file

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

View file

@ -62,6 +62,43 @@
<CheckBox IsChecked="{Binding HistorySkipUnmonitored}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Auto History Refresh" Description="Automatically refresh your history at a set interval">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="8" Width="520">
<StackPanel Spacing="4">
<TextBlock Text="Refresh interval (minutes)" />
<DockPanel LastChildFill="True">
<controls:NumberBox Minimum="0"
Maximum="1000000000"
Value="{Binding HistoryAutoRefreshIntervalMinutes}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</DockPanel>
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
Text="Set to 0 to disable automatic refresh." />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Text="Refresh mode" />
<ComboBox ItemsSource="{Binding HistoryAutoRefreshModes}"
DisplayMemberBinding="{Binding DisplayName}"
SelectedValueBinding="{Binding value}"
SelectedValue="{Binding HistoryAutoRefreshMode}"/>
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap"
Text="{Binding HistoryAutoRefreshModeHint}" />
</StackPanel>
<StackPanel Spacing="4">
<TextBlock Opacity="0.7" FontSize="12" TextWrapping="Wrap" Text="{Binding HistoryAutoRefreshLastRunTime,StringFormat='Last refresh: {0}'}" />
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
@ -241,51 +278,50 @@
<CheckBox IsChecked="{Binding DownloadFinishedPlaySound}"> </CheckBox>
</StackPanel>
</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> -->
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Execute on completion" Description="Enable to run a selected file after all downloads complete">
<controls:SettingsExpanderItem.Footer>
<StackPanel Spacing="10">
<StackPanel Orientation="Horizontal" Spacing="10" HorizontalAlignment="Right">
<TextBlock IsVisible="{Binding DownloadFinishedExecute}"
Text="{Binding DownloadFinishedExecutePath, Mode=OneWay}"
FontSize="15"
Opacity="0.8"
TextWrapping="NoWrap"
TextAlignment="Center"
VerticalAlignment="Center" />
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding OpenFileDialogAsyncInternalFinishedExecute}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Select file to execute when downloads finish" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Folder" FontSize="18" />
</StackPanel>
</Button>
<Button IsVisible="{Binding DownloadFinishedExecute}"
Command="{Binding ClearFinishedExectuePath}"
VerticalAlignment="Center"
FontStyle="Italic">
<ToolTip.Tip>
<TextBlock Text="Clear selected file" FontSize="15" />
</ToolTip.Tip>
<StackPanel Orientation="Horizontal" Spacing="5">
<controls:SymbolIcon Symbol="Clear" FontSize="18" />
</StackPanel>
</Button>
<CheckBox IsChecked="{Binding DownloadFinishedExecute}"> </CheckBox>
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
@ -423,6 +459,38 @@
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
<controls:SettingsExpander Header="General Settings"
IconSource="Bullets"
Description="Adjust General settings"
IsExpanded="False">
<controls:SettingsExpanderItem Content="Show tray icon">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding TrayIconEnabled}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Start minimized to tray" Description="Launches in the tray without opening a window">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding StartMinimizedToTray}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Minimize to tray" Description="Minimizing hides the window and removes it from the taskbar">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding MinimizeToTray}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding TrayIconEnabled}" Content="Close to tray" Description="Clicking X hides the app in the tray instead of exiting">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding MinimizeToTrayOnClose}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
<controls:SettingsExpander Header="App Appearance"

BIN
images/Calendar_Custom_Settings.png (Stored with Git LFS)

Binary file not shown.

BIN
images/History_Overview.png (Stored with Git LFS)

Binary file not shown.

BIN
images/History_Overview_Table.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings.png (Stored with Git LFS)

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

BIN
images/Settings_Download.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_Download_CR.png (Stored with Git LFS)

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

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

Binary file not shown.

BIN
images/Settings_History.png (Stored with Git LFS)

Binary file not shown.

BIN
images/Settings_Muxing.png (Stored with Git LFS)

Binary file not shown.

Binary file not shown.

BIN
images/Settings_Softsubs.png (Stored with Git LFS)

Binary file not shown.