Compare commits

...

4 commits

Author SHA1 Message Date
Elwador
c7687c80e8 - Added **FlareSolverr support** for the default simulcast calendar
- Added **upcoming episodes** to the default simulcast calendar
- Adjusted **default simulcast calendar** to filter dubs correctly
- Removed **FFmpeg and MKVMerge** from the GitHub package
- Adjusted **custom calendar episode fetching**
- Fixed **download error** when audio and video were served from different servers
- Fixed **crash** when a searched series had no episodes
2026-01-10 02:23:41 +01:00
Elwador
c5660a87e7 - Added **fallback for hardsub** to use **no-hardsub video** if enabled
- Added **video/audio toggle for endpoints** to control which media type is used from each endpoint
- **Updated packages** to the latest versions
- **Updated Android phone token**
2025-12-01 11:47:30 +01:00
Elwador
dc570bf420 - Added **toggle to also download description audio** for selected dubs
- Added **automatic history backups** retained for up to 5 days
- Improved **season tab** to display series more effectively
- Improved **history saving** for increased data safety
- Removed **"None" option** from the hardsub selection popup
2025-11-06 20:44:03 +01:00
Elwador
8a5c51900b
Update README.md 2025-09-15 08:46:38 +02:00
40 changed files with 1972 additions and 709 deletions

View file

@ -8,6 +8,7 @@ using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
@ -67,6 +68,10 @@ public class CalendarManager{
return forDate;
}
if (CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes){
await LoadAnilistUpcoming();
}
var request = calendarLanguage.ContainsKey(CrunchyrollManager.Instance.CrunOptions.SelectedCalendarLanguage ?? "en-us")
? HttpClientReq.CreateRequestMessage($"{calendarLanguage[CrunchyrollManager.Instance.CrunOptions.SelectedCalendarLanguage ?? "en-us"]}?filter=premium&date={weeksMondayDate}", HttpMethod.Get, false)
: HttpClientReq.CreateRequestMessage($"{calendarLanguage["en-us"]}?filter=premium&date={weeksMondayDate}", HttpMethod.Get, false);
@ -75,19 +80,26 @@ public class CalendarManager{
request.Headers.Accept.ParseAdd("text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,*/*;q=0.8");
request.Headers.AcceptEncoding.ParseAdd("gzip, deflate, br");
var response = await HttpClientReq.Instance.SendHttpRequest(request);
(bool IsOk, string ResponseContent, string error) response;
if (!HttpClientReq.Instance.useFlareSolverr){
response = await HttpClientReq.Instance.SendHttpRequest(request);
} else{
response = await HttpClientReq.Instance.SendFlareSolverrHttpRequest(request);
}
if (!response.IsOk){
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("Blocked by Cloudflare. Use the custom calendar.", ToastType.Error, 5));
Console.Error.WriteLine($"Blocked by Cloudflare. Use the custom calendar.");
} else{
Console.Error.WriteLine($"Calendar request failed");
}
return new CalendarWeek();
}
@ -164,6 +176,24 @@ public class CalendarManager{
Console.Error.WriteLine("No days found in the HTML document.");
}
if (CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes){
foreach (var calendarDay in week.CalendarDays){
if (calendarDay.DateTime.Date >= DateTime.Now.Date){
if (ProgramManager.Instance.AnilistUpcoming.ContainsKey(calendarDay.DateTime.ToString("yyyy-MM-dd"))){
var list = ProgramManager.Instance.AnilistUpcoming[calendarDay.DateTime.ToString("yyyy-MM-dd")];
foreach (var calendarEpisode in list
.Where(e => calendarDay.DateTime.Date.Day == e.DateTime.Date.Day)
.Where(e => calendarDay.CalendarEpisodes.All(ele =>
ele.CrSeriesID != e.CrSeriesID &&
!CrSimulcastCalendarFilter.IsMatch(ele.SeasonName, e.SeasonName, similarityThreshold: 0.5)))){
calendarDay.CalendarEpisodes.Add(calendarEpisode);
}
}
}
}
}
calendar[weeksMondayDate] = week;
@ -172,14 +202,14 @@ public class CalendarManager{
public async Task<CalendarWeek> BuildCustomCalendar(DateTime calTargetDate, bool forceUpdate){
if (CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes){
await LoadAnilistUpcoming();
}
if (!forceUpdate && calendar.TryGetValue("C" + calTargetDate.ToString("yyyy-MM-dd"), out var forDate)){
return forDate;
}
if (CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes){
await LoadAnilistUpcoming();
}
CalendarWeek week = new CalendarWeek();
week.CalendarDays = new List<CalendarDay>();
@ -201,13 +231,14 @@ public class CalendarManager{
var firstDayOfWeek = week.CalendarDays.First().DateTime;
week.FirstDayOfWeek = firstDayOfWeek;
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes("", 200, firstDayOfWeek, true);
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data;
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false;
DateTime episodeAirDate = crBrowseEpisode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc
? crBrowseEpisode.EpisodeMetadata.EpisodeAirDate.ToLocalTime()
: crBrowseEpisode.EpisodeMetadata.EpisodeAirDate;
@ -221,49 +252,35 @@ public class CalendarManager{
DateTime targetDate;
if (CrunchyrollManager.Instance.CrunOptions.CalendarFilterByAirDate){
targetDate = episodeAirDate;
if (targetDate >= oneYearFromNow){
DateTime freeAvailableStart = crBrowseEpisode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
? crBrowseEpisode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
: crBrowseEpisode.EpisodeMetadata.FreeAvailableDate;
targetDate = premiumAvailableStart;
if (freeAvailableStart <= oneYearFromNow){
targetDate = freeAvailableStart;
} else{
targetDate = premiumAvailableStart;
}
}
} else{
targetDate = premiumAvailableStart;
if (targetDate >= oneYearFromNow){
DateTime freeAvailableStart = crBrowseEpisode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
? crBrowseEpisode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
: crBrowseEpisode.EpisodeMetadata.FreeAvailableDate;
if (targetDate >= oneYearFromNow){
DateTime freeAvailableStart = crBrowseEpisode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
? crBrowseEpisode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
: crBrowseEpisode.EpisodeMetadata.FreeAvailableDate;
if (freeAvailableStart <= oneYearFromNow){
targetDate = freeAvailableStart;
} else{
targetDate = episodeAirDate;
}
if (freeAvailableStart <= oneYearFromNow){
targetDate = freeAvailableStart;
} else{
targetDate = episodeAirDate;
}
}
var dubFilter = CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter;
if (CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs && crBrowseEpisode.EpisodeMetadata.SeasonTitle != null &&
(crBrowseEpisode.EpisodeMetadata.SeasonTitle.EndsWith("Dub)") || crBrowseEpisode.EpisodeMetadata.SeasonTitle.EndsWith("Audio)")) &&
(string.IsNullOrEmpty(dubFilter) || dubFilter == "none" || (crBrowseEpisode.EpisodeMetadata.AudioLocale != null && crBrowseEpisode.EpisodeMetadata.AudioLocale.GetEnumMemberValue() != dubFilter))){
//|| crBrowseEpisode.EpisodeMetadata.AudioLocale != Locale.JaJp
continue;
filtered = true;
}
if (!string.IsNullOrEmpty(dubFilter) && dubFilter != "none"){
if (crBrowseEpisode.EpisodeMetadata.AudioLocale != null && crBrowseEpisode.EpisodeMetadata.AudioLocale.GetEnumMemberValue() != dubFilter){
continue;
filtered = true;
}
}
@ -274,6 +291,12 @@ public class CalendarManager{
if (calendarDay != null){
CalendarEpisode calEpisode = new CalendarEpisode();
string? seasonTitle = string.IsNullOrEmpty(crBrowseEpisode.EpisodeMetadata.SeasonTitle)
? crBrowseEpisode.EpisodeMetadata.SeriesTitle
: Regex.IsMatch(crBrowseEpisode.EpisodeMetadata.SeasonTitle, @"^Season\s+\d+$", RegexOptions.IgnoreCase)
? $"{crBrowseEpisode.EpisodeMetadata.SeriesTitle} {crBrowseEpisode.EpisodeMetadata.SeasonTitle}"
: crBrowseEpisode.EpisodeMetadata.SeasonTitle;
calEpisode.DateTime = targetDate;
calEpisode.HasPassed = DateTime.Now > targetDate;
calEpisode.EpisodeName = crBrowseEpisode.Title;
@ -282,12 +305,14 @@ public class CalendarManager{
calEpisode.ThumbnailUrl = crBrowseEpisode.Images.Thumbnail?.FirstOrDefault()?.FirstOrDefault()?.Source ?? ""; //https://www.crunchyroll.com/i/coming_soon_beta_thumb.jpg
calEpisode.IsPremiumOnly = crBrowseEpisode.EpisodeMetadata.IsPremiumOnly;
calEpisode.IsPremiere = crBrowseEpisode.EpisodeMetadata.Episode == "1";
calEpisode.SeasonName = crBrowseEpisode.EpisodeMetadata.SeasonTitle;
calEpisode.SeasonName = seasonTitle;
calEpisode.EpisodeNumber = crBrowseEpisode.EpisodeMetadata.Episode;
calEpisode.CrSeriesID = crBrowseEpisode.EpisodeMetadata.SeriesId;
calEpisode.FilteredOut = filtered;
calEpisode.AudioLocale = crBrowseEpisode.EpisodeMetadata.AudioLocale;
var existingEpisode = calendarDay.CalendarEpisodes
.FirstOrDefault(e => e.SeasonName == calEpisode.SeasonName);
.FirstOrDefault(e => e.SeasonName == calEpisode.SeasonName && e.AudioLocale == calEpisode.AudioLocale);
if (existingEpisode != null){
if (!int.TryParse(existingEpisode.EpisodeNumber, out _)){
@ -330,8 +355,9 @@ public class CalendarManager{
if (ProgramManager.Instance.AnilistUpcoming.ContainsKey(calendarDay.DateTime.ToString("yyyy-MM-dd"))){
var list = ProgramManager.Instance.AnilistUpcoming[calendarDay.DateTime.ToString("yyyy-MM-dd")];
foreach (var calendarEpisode in list.Where(calendarEpisode => calendarDay.DateTime.Date.Day == calendarEpisode.DateTime.Date.Day)
.Where(calendarEpisode => calendarDay.CalendarEpisodes.All(ele => ele.CrSeriesID != calendarEpisode.CrSeriesID && ele.SeasonName != calendarEpisode.SeasonName))){
foreach (var calendarEpisode in list.Where(calendarEpisodeAnilist => calendarDay.DateTime.Date.Day == calendarEpisodeAnilist.DateTime.Date.Day)
.Where(calendarEpisodeAnilist =>
calendarDay.CalendarEpisodes.All(ele => ele.CrSeriesID != calendarEpisodeAnilist.CrSeriesID && ele.SeasonName != calendarEpisodeAnilist.SeasonName))){
calendarDay.CalendarEpisodes.Add(calendarEpisode);
}
}
@ -342,6 +368,7 @@ public class CalendarManager{
foreach (var weekCalendarDay in week.CalendarDays){
if (weekCalendarDay.CalendarEpisodes.Count > 0)
weekCalendarDay.CalendarEpisodes = weekCalendarDay.CalendarEpisodes
.Where(e => !e.FilteredOut)
.OrderBy(e => e.AnilistEpisode) // False first, then true
.ThenBy(e => e.DateTime)
.ThenBy(e => e.SeasonName)
@ -417,7 +444,7 @@ public class CalendarManager{
aniListResponse ??= currentResponse;
if (aniListResponse != currentResponse){
aniListResponse.Data?.Page?.AiringSchedules?.AddRange(currentResponse.Data?.Page?.AiringSchedules ??[]);
aniListResponse.Data?.Page?.AiringSchedules?.AddRange(currentResponse.Data?.Page?.AiringSchedules ?? []);
}
hasNextPage = currentResponse.Data?.Page?.PageInfo?.HasNextPage ?? false;
@ -426,12 +453,12 @@ public class CalendarManager{
} while (hasNextPage && currentPage < 20);
var list = aniListResponse.Data?.Page?.AiringSchedules ??[];
var list = aniListResponse.Data?.Page?.AiringSchedules ?? [];
list = list.Where(ele => ele.Media?.ExternalLinks != null && ele.Media.ExternalLinks.Any(external =>
string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase))).ToList();
List<CalendarEpisode> calendarEpisodes =[];
List<CalendarEpisode> calendarEpisodes = [];
foreach (var anilistEle in list){
var calEp = new CalendarEpisode();
@ -531,7 +558,7 @@ public class CalendarManager{
oldestRelease.Second,
calEp.DateTime.Kind
);
if ((adjustedDate - oldestRelease).TotalDays is < 6 and > 1){
adjustedDate = oldestRelease.AddDays(7);
}

View file

@ -43,11 +43,11 @@ public class CrEpisode(){
}
if (epsidoe is{ Total: 1, Data: not null } &&
(epsidoe.Data.First().Versions ??[])
(epsidoe.Data.First().Versions ?? [])
.GroupBy(v => v.AudioLocale)
.Any(g => g.Count() > 1)){
Console.Error.WriteLine("Episode has Duplicate Audio Locales");
var list = (epsidoe.Data.First().Versions ??[]).GroupBy(v => v.AudioLocale).Where(g => g.Count() > 1).ToList();
var list = (epsidoe.Data.First().Versions ?? []).GroupBy(v => v.AudioLocale).Where(g => g.Count() > 1).ToList();
//guid for episode id
foreach (var episodeVersionse in list){
foreach (var version in episodeVersionse){
@ -173,7 +173,7 @@ public class CrEpisode(){
}
var epNum = episodeP.Key.StartsWith('E') ? episodeP.Key[1..] : episodeP.Key;
var images = (item.Images?.Thumbnail ??[new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
var images = (item.Images?.Thumbnail ?? [new List<Image>{ new(){ Source = "/notFound.jpg" } }]);
Regex dubPattern = new Regex(@"\(\w+ Dub\)");
@ -237,60 +237,45 @@ public class CrEpisode(){
public async Task<CrBrowseEpisodeBase?> GetNewEpisodes(string? crLocale, int requestAmount, DateTime? firstWeekDay = null, bool forcedLang = false){
await crunInstance.CrAuthEndpoint1.RefreshToken(true);
CrBrowseEpisodeBase? complete = new CrBrowseEpisodeBase();
complete.Data =[];
var i = 0;
do{
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
if (string.IsNullOrEmpty(crLocale)){
crLocale = "en-US";
}
if (!string.IsNullOrEmpty(crLocale)){
query["locale"] = crLocale;
if (forcedLang){
query["force_locale"] = crLocale;
}
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
if (!string.IsNullOrEmpty(crLocale)){
query["locale"] = crLocale;
if (forcedLang){
query["force_locale"] = crLocale;
}
}
query["start"] = i + "";
query["n"] = "50";
query["sort_by"] = "newly_added";
query["type"] = "episode";
query["n"] = requestAmount + "";
query["sort_by"] = "newly_added";
query["type"] = "episode";
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Browse}", HttpMethod.Get, true, crunInstance.CrAuthEndpoint1.Token?.access_token, query);
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Browse}", HttpMethod.Get, true, crunInstance.CrAuthEndpoint1.Token?.access_token, query);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
return null;
}
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
return null;
}
CrBrowseEpisodeBase? series = Helpers.Deserialize<CrBrowseEpisodeBase>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
CrBrowseEpisodeBase? series = Helpers.Deserialize<CrBrowseEpisodeBase>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
if (series != null){
complete.Total = series.Total;
if (series.Data != null){
complete.Data.AddRange(series.Data);
if (firstWeekDay != null){
if (firstWeekDay.Value.Date <= series.Data.Last().LastPublic && i + 50 == requestAmount){
requestAmount += 50;
}
}
}
} else{
break;
}
series?.Data?.Sort((a, b) =>
b.EpisodeMetadata.PremiumAvailableDate.CompareTo(a.EpisodeMetadata.PremiumAvailableDate));
i += 50;
} while (i < requestAmount && requestAmount < 500);
return complete;
return series;
}
public async Task MarkAsWatched(string episodeId){
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Content}/discover/{crunInstance.CrAuthEndpoint1.Token?.account_id}/mark_as_watched/{episodeId}", HttpMethod.Post, true, crunInstance.CrAuthEndpoint1.Token?.access_token, null);
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Content}/discover/{crunInstance.CrAuthEndpoint1.Token?.account_id}/mark_as_watched/{episodeId}", HttpMethod.Post, true,
crunInstance.CrAuthEndpoint1.Token?.access_token, null);
var response = await HttpClientReq.Instance.SendHttpRequest(request);

View file

@ -338,7 +338,7 @@ public class CrSeries{
}
if (episodeList.Total < 1){
Console.Error.WriteLine("Season is empty!");
Console.Error.WriteLine($"Season is empty! Uri: {episodeRequest.RequestUri}");
}
return episodeList;

File diff suppressed because it is too large Load diff

View file

@ -0,0 +1,147 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
namespace CRD.Downloader.Crunchyroll.Utils;
public class CrSimulcastCalendarFilter{
private static readonly Regex SeasonLangSuffix =
new Regex(@"\bSeason\s+\d+\s*\((?<tag>.*)\)\s*$",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant);
private static readonly string[] NonLanguageTags ={
"uncut", "simulcast", "sub", "subbed"
};
private static readonly string[] LanguageHints ={
"deutsch", "german",
"español", "espanol", "spanish", "américa latina", "america latina", "latin america",
"português", "portugues", "portuguese", "brasil", "brazil",
"français", "francais", "french",
"italiano", "italian",
"english",
"рус", "russian",
"한국", "korean",
"中文", "普通话", "mandarin",
"ไทย", "thai",
"türk", "turk", "turkish",
"polski", "polish",
"nederlands", "dutch"
};
public static bool IsDubOrAltLanguageSeason(string? seasonName){
if (string.IsNullOrWhiteSpace(seasonName))
return false;
// Explicit "Dub" anywhere
if (seasonName.Contains("dub", StringComparison.OrdinalIgnoreCase))
return true;
// "Season N ( ... )" suffix
var m = SeasonLangSuffix.Match(seasonName);
if (!m.Success)
return false;
var tag = m.Groups["tag"].Value.Trim();
if (tag.Length == 0)
return false;
foreach (var nl in NonLanguageTags)
if (tag.Contains(nl, StringComparison.OrdinalIgnoreCase))
return false;
// Non-ASCII in the tag (e.g., 中文, Español, Português)
if (tag.Any(c => c > 127))
return true;
// Otherwise look for known language hints
foreach (var hint in LanguageHints)
if (tag.Contains(hint, StringComparison.OrdinalIgnoreCase))
return true;
return false;
}
#region Name Match to upcoming
private static readonly Regex TrailingParenGroups =
new Regex(@"\s*(\([^)]*\))\s*$", RegexOptions.Compiled);
public static bool IsMatch(string? a, string? b, double similarityThreshold = 0.85){
if (string.IsNullOrWhiteSpace(a) || string.IsNullOrWhiteSpace(b))
return false;
var na = Normalize(a);
var nb = Normalize(b);
if (string.Equals(na, nb, StringComparison.OrdinalIgnoreCase))
return true;
if (na.Length >= 8 && nb.Length >= 8 &&
(na.Contains(nb, StringComparison.OrdinalIgnoreCase) ||
nb.Contains(na, StringComparison.OrdinalIgnoreCase)))
return true;
return Similarity(na, nb) >= similarityThreshold;
}
private static string Normalize(string s){
s = s.Trim();
while (TrailingParenGroups.IsMatch(s))
s = TrailingParenGroups.Replace(s, "").TrimEnd();
s = s.Normalize(NormalizationForm.FormD);
var sb = new StringBuilder(s.Length);
foreach (var ch in s){
var uc = CharUnicodeInfo.GetUnicodeCategory(ch);
if (uc != UnicodeCategory.NonSpacingMark)
sb.Append(ch);
}
s = sb.ToString().Normalize(NormalizationForm.FormC);
var cleaned = new StringBuilder(s.Length);
foreach (var ch in s)
cleaned.Append(char.IsLetterOrDigit(ch) ? ch : ' ');
return Regex.Replace(cleaned.ToString(), @"\s+", " ").Trim().ToLowerInvariant();
}
private static double Similarity(string a, string b){
if (a.Length == 0 && b.Length == 0) return 1.0;
int dist = LevenshteinDistance(a, b);
int maxLen = Math.Max(a.Length, b.Length);
return 1.0 - (double)dist / maxLen;
}
private static int LevenshteinDistance(string a, string b){
if (a.Length == 0) return b.Length;
if (b.Length == 0) return a.Length;
var prev = new int[b.Length + 1];
var curr = new int[b.Length + 1];
for (int j = 0; j <= b.Length; j++)
prev[j] = j;
for (int i = 1; i <= a.Length; i++){
curr[0] = i;
for (int j = 1; j <= b.Length; j++){
int cost = a[i - 1] == b[j - 1] ? 0 : 1;
curr[j] = Math.Min(
Math.Min(curr[j - 1] + 1, prev[j] + 1),
prev[j - 1] + cost
);
}
(prev, curr) = (curr, prev);
}
return prev[b.Length];
}
#endregion
}

View file

@ -0,0 +1,193 @@
using System;
using System.Collections.Generic;
using System.Text.RegularExpressions;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll;
namespace CRD.Downloader.Crunchyroll.Utils;
public static class SubtitleUtils{
private static readonly Dictionary<string, string> StyleTemplates = new(){
{ "de-DE", "Style: {name},Arial,23,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,{align},0000,0000,0020,1" },
{ "ar-SA", "Style: {name},Adobe Arabic,26,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1,0,{align},0010,0010,0018,0" },
{ "en-US", "Style: {name},Trebuchet MS,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "es-419", "Style: {name},Trebuchet MS,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,1" },
{ "es-ES", "Style: {name},Trebuchet MS,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,1" },
{ "fr-FR", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,1,1,{align},0002,0002,0025,1" },
{ "id-ID", "Style: {name},Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
{ "it-IT", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,0,{align},0010,0010,0015,1" },
{ "ms-MY", "Style: {name},Arial,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
{ "pt-BR", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,-1,0,0,0,100,100,0,0,1,2,1,{align},0040,0040,0015,0" },
{ "ru-RU", "Style: {name},Tahoma,22,&H00FFFFFF,&H000000FF,&H00000000,&H96000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0025,204" },
{ "th-TH", "Style: {name},Noto Sans Thai,30,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
{ "vi-VN", "Style: {name},Arial Unicode MS,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
{ "zh-CN", "Style: {name},Arial Unicode MS,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
{ "zh-HK", "Style: {name},Arial Unicode MS,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
// Need to check
{ "ja-JP", "Style: {name},Arial Unicode MS,23,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "en-IN", "Style: {name},Trebuchet MS,24,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "pt-PT", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "pl-PL", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "ca-ES", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "tr-TR", "Style: {name},Trebuchet MS,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "hi-IN", "Style: {name},Noto Sans Devanagari,26,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "ta-IN", "Style: {name},Noto Sans Tamil,26,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "te-IN", "Style: {name},Noto Sans Telugu,26,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
{ "zh-TW", "Style: {name},Arial Unicode MS,20,&H00FFFFFF,&H0000FFFF,&H00000000,&H7F404040,-1,0,0,0,100,100,0,0,1,2,1,{align},0020,0020,0022,0" },
{ "ko-KR", "Style: {name},Malgun Gothic,22,&H00FFFFFF,&H000000FF,&H00000000,&H00000000,0,0,0,0,100,100,0,0,1,2,1,{align},0010,0010,0018,0" },
};
public static string CleanAssAndEnsureScriptInfo(string assText, CrDownloadOptions options, LanguageItem langItem){
if (string.IsNullOrEmpty(assText))
return assText;
string? scaledLine = options.SubsAddScaledBorder switch{
ScaledBorderAndShadowSelection.ScaledBorderAndShadowYes => "ScaledBorderAndShadow: yes",
ScaledBorderAndShadowSelection.ScaledBorderAndShadowNo => "ScaledBorderAndShadow: no",
_ => null
};
bool isCcc = assText.Contains("www.closedcaptionconverter.com", StringComparison.OrdinalIgnoreCase);
if (isCcc && options.FixCccSubtitles){
assText = Regex.Replace(
assText,
@"^[ \t]*;[ \t]*Script generated by Closed Caption Converter \| www\.closedcaptionconverter\.com[ \t]*\r?\n",
"",
RegexOptions.Multiline
);
assText = Regex.Replace(
assText,
@"^[ \t]*PlayDepth[ \t]*:[ \t]*0[ \t]*\r?\n?",
"",
RegexOptions.Multiline | RegexOptions.IgnoreCase
);
assText = assText.Replace(",,,,25.00,,", ",,0,0,0,,");
assText = FixStyles(assText, langItem.CrLocale);
}
// Remove Aegisub garbage and other useless metadata
assText = RemoveAegisubProjectGarbageBlocks(assText);
// Remove Aegisub-generated comments and YCbCr Matrix lines
assText = Regex.Replace(
assText,
@"^[ \t]*;[^\r\n]*\r?\n?", // all comment lines starting with ';'
"",
RegexOptions.Multiline
);
assText = Regex.Replace(
assText,
@"^[ \t]*YCbCr Matrix:[^\r\n]*\r?\n?",
"",
RegexOptions.Multiline | RegexOptions.IgnoreCase
);
// Remove empty lines (but keep one between sections)
assText = Regex.Replace(assText, @"(\r?\n){3,}", "\r\n\r\n");
var linesToEnsure = new Dictionary<string, string>();
if (isCcc){
linesToEnsure["PlayResX"] = "PlayResX: 640";
linesToEnsure["PlayResY"] = "PlayResY: 360";
linesToEnsure["Timer"] = "Timer: 0.0000";
linesToEnsure["WrapStyle"] = "WrapStyle: 0";
}
if (scaledLine != null)
linesToEnsure["ScaledBorderAndShadow"] = scaledLine;
if (linesToEnsure.Count > 0)
assText = UpsertScriptInfo(assText, linesToEnsure);
return assText;
}
private static string UpsertScriptInfo(string input, IDictionary<string, string> linesToEnsure){
var rxSection = new Regex(@"(?is)(\[Script Info\]\s*\r?\n)(.*?)(?=\r?\n\[|$)");
var m = rxSection.Match(input);
string nl = input.Contains("\r\n") ? "\r\n" : "\n";
if (!m.Success){
// Create whole section at top
return "[Script Info]" + nl
+ string.Join(nl, linesToEnsure.Values) + nl
+ input;
}
string header = m.Groups[1].Value;
string body = m.Groups[2].Value;
string bodyNl = header.Contains("\r\n") ? "\r\n" : "\n";
foreach (var kv in linesToEnsure){
var lineRx = new Regex($@"(?im)^\s*{Regex.Escape(kv.Key)}\s*:\s*.*$");
if (lineRx.IsMatch(body))
body = lineRx.Replace(body, kv.Value);
else
body = body.TrimEnd() + bodyNl + kv.Value + bodyNl;
}
return input.Substring(0, m.Index) + header + body + input.Substring(m.Index + m.Length);
}
private static string FixStyles(string assContent, string crLocale){
var pattern = @"^Style:\s*([^,]+),\s*(?:[^,\r\n]*,\s*){17}(\d+)\s*,[^\r\n]*$";
string template = StyleTemplates.TryGetValue(crLocale, out var tmpl) ? tmpl : StyleTemplates["en-US"];
return Regex.Replace(assContent, pattern, m => {
string name = m.Groups[1].Value;
string align = m.Groups[2].Value;
return template
.Replace("{name}", name)
.Replace("{align}", align);
}, RegexOptions.Multiline);
}
private static string RemoveAegisubProjectGarbageBlocks(string text){
if (string.IsNullOrEmpty(text)) return text;
var nl = "\n";
text = text.Replace("\r\n", "\n").Replace("\r", "\n");
var sb = new System.Text.StringBuilder(text.Length);
using var sr = new System.IO.StringReader(text);
bool skipping = false;
string? line;
while ((line = sr.ReadLine()) != null){
string trimmed = line.Trim();
if (!skipping && Regex.IsMatch(trimmed, @"^\[\s*Aegisub\s+Project\s+Garbage\s*\]$", RegexOptions.IgnoreCase)){
skipping = true;
continue;
}
if (skipping){
if (trimmed.Length == 0 || Regex.IsMatch(trimmed, @"^\[.+\]$")){
skipping = false;
if (trimmed.Length != 0){
sb.Append(line).Append(nl);
}
}
continue;
}
sb.Append(line).Append(nl);
}
return sb.ToString().TrimEnd('\n').Replace("\n", "\r\n");
}
}

View file

@ -35,21 +35,33 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _downloadAudio = true;
[ObservableProperty]
private bool _downloadDescriptionAudio = true;
[ObservableProperty]
private bool _downloadChapters = true;
[ObservableProperty]
private bool _addScaledBorderAndShadow;
[ObservableProperty]
private bool _fixCccSubtitles;
[ObservableProperty]
private bool _subsDownloadDuplicate;
[ObservableProperty]
private bool _includeSignSubs;
[ObservableProperty]
private bool _includeCcSubs;
[ObservableProperty]
private bool _convertVtt2Ass;
[ObservableProperty]
private bool _showVtt2AssSettings;
[ObservableProperty]
private ComboBoxItem _selectedScaledBorderAndShadow;
@ -63,13 +75,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _muxToMp4;
[ObservableProperty]
private bool _muxToMp3;
[ObservableProperty]
private bool _muxFonts;
[ObservableProperty]
private bool _muxCover;
@ -102,7 +114,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private string _fileName = "";
[ObservableProperty]
private string _fileNameWhitespaceSubstitute = "";
@ -127,6 +139,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem _selectedHSLang;
[ObservableProperty]
private bool _hsRawFallback;
[ObservableProperty]
private ComboBoxItem _selectedDescriptionLang;
@ -138,22 +153,37 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem _selectedStreamEndpoint;
[ObservableProperty]
private bool _firstEndpointVideo;
[ObservableProperty]
private bool _firstEndpointAudio;
[ObservableProperty]
private ComboBoxItem _SelectedStreamEndpointSecondary;
[ObservableProperty]
private string _endpointAuthorization = "";
[ObservableProperty]
private string _endpointUserAgent = "";
[ObservableProperty]
private string _endpointDeviceName = "";
[ObservableProperty]
private string _endpointDeviceType = "";
[ObservableProperty]
private bool _endpointVideo;
[ObservableProperty]
private bool _endpointAudio;
[ObservableProperty]
private bool _isLoggingIn;
[ObservableProperty]
private bool _endpointNotSignedWarning;
@ -170,7 +200,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private ComboBoxItem? _selectedAudioQuality;
[ObservableProperty]
private ObservableCollection<ListBoxItem> _selectedSubLang =[];
private ObservableCollection<ListBoxItem> _selectedSubLang = [];
[ObservableProperty]
private Color _listBoxColor;
@ -187,6 +217,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public ObservableCollection<ComboBoxItem> AudioQualityList{ get; } =[
new(){ Content = "best" },
new(){ Content = "192kB/s" },
new(){ Content = "128kB/s" },
new(){ Content = "96kB/s" },
new(){ Content = "64kB/s" },
@ -212,12 +243,12 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
new(){ Content = "ar-SA" }
];
public ObservableCollection<ListBoxItem> DubLangList{ get; } =[];
public ObservableCollection<ListBoxItem> DubLangList{ get; } = [];
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } =[];
public ObservableCollection<ComboBoxItem> DefaultDubLangList{ get; } = [];
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } =[];
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [];
public ObservableCollection<ListBoxItem> SubLangList{ get; } =[
@ -240,7 +271,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
new(){ Content = "tv/vidaa" },
new(){ Content = "tv/android_tv" },
];
public ObservableCollection<ComboBoxItem> StreamEndpointsSecondary{ get; } =[
new(){ Content = "" },
// new(){ Content = "web/firefox" },
@ -258,7 +289,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
new(){ Content = "tv/android_tv" },
];
public ObservableCollection<StringItemWithDisplayName> FFmpegHWAccel{ get; } =[];
public ObservableCollection<StringItemWithDisplayName> FFmpegHWAccel{ get; } = [];
[ObservableProperty]
private StringItemWithDisplayName _selectedFFmpegHWAccel;
@ -326,23 +357,35 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
ComboBoxItem? defaultSubLang = DefaultSubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.DefaultSub ?? "")) ?? null;
SelectedDefaultSubLang = defaultSubLang ?? DefaultSubLangList[0];
ComboBoxItem? streamEndpoint = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.StreamEndpoint ?? "")) ?? null;
ComboBoxItem? streamEndpoint = StreamEndpoints.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.StreamEndpoint?.Endpoint ?? "")) ?? null;
SelectedStreamEndpoint = streamEndpoint ?? StreamEndpoints[0];
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.StreamEndpointSecondSettings?.Endpoint ?? "")) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
EndpointAuthorization = options.StreamEndpointSecondSettings?.Authorization ?? string.Empty;
EndpointUserAgent = options.StreamEndpointSecondSettings?.UserAgent ?? string.Empty;
EndpointDeviceName = options.StreamEndpointSecondSettings?.Device_name ?? string.Empty;
EndpointDeviceType = options.StreamEndpointSecondSettings?.Device_type ?? string.Empty;
EndpointVideo = options.StreamEndpointSecondSettings?.Video ?? true;
EndpointAudio = options.StreamEndpointSecondSettings?.Audio ?? true;
FirstEndpointVideo = options.StreamEndpoint?.Video ?? true;
FirstEndpointAudio = options.StreamEndpoint?.Audio ?? true;
if (CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???"){
EndpointNotSignedWarning = true;
}
FFmpegHWAccel.AddRange(GetAvailableHWAccelOptions());
if (FFmpegHWAccel.Count == 0){
FFmpegHWAccel.Add(new StringItemWithDisplayName{
DisplayName = "No hardware acceleration (error)",
value = "error"
});
}
StringItemWithDisplayName? hwAccellFlag = FFmpegHWAccel.FirstOrDefault(a => a.value == options.FfmpegHwAccelFlag) ?? null;
SelectedFFmpegHWAccel = hwAccellFlag ?? FFmpegHWAccel[0];
@ -351,7 +394,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
.Where(a => options.DlSubs.Contains(a.Content))
.OrderBy(a => options.DlSubs.IndexOf(a.Content))
.ToList();
SelectedSubLang.Clear();
foreach (var listBoxItem in softSubLang){
SelectedSubLang.Add(listBoxItem);
@ -361,7 +404,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
.Where(a => options.DubLang.Contains(a.Content))
.OrderBy(a => options.DubLang.IndexOf(a.Content))
.ToList();
SelectedDubLang.Clear();
foreach (var listBoxItem in dubLang){
SelectedDubLang.Add(listBoxItem);
@ -370,6 +413,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
AddScaledBorderAndShadow = options.SubsAddScaledBorder is ScaledBorderAndShadowSelection.ScaledBorderAndShadowNo or ScaledBorderAndShadowSelection.ScaledBorderAndShadowYes;
SelectedScaledBorderAndShadow = GetScaledBorderAndShadowFromOptions(options);
HsRawFallback = options.HsRawFallback;
FixCccSubtitles = options.FixCccSubtitles;
ConvertVtt2Ass = options.ConvertVtt2Ass;
SubsDownloadDuplicate = options.SubsDownloadDuplicate;
MarkAsWatched = options.MarkAsWatched;
DownloadFirstAvailableDub = options.DownloadFirstAvailableDub;
@ -388,6 +434,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
IncludeCcSubs = options.IncludeCcSubs;
DownloadVideo = !options.Novids;
DownloadAudio = !options.Noaudio;
DownloadDescriptionAudio = options.DownloadDescriptionAudio;
DownloadVideoForEveryDub = !options.DlVideoOnce;
KeepDubsSeparate = options.KeepDubsSeperate;
DownloadChapters = options.Chapters;
@ -428,6 +475,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
var subs = SelectedSubLang.Select(item => item.Content?.ToString());
SelectedSubs = string.Join(", ", subs) ?? "";
ShowVtt2AssSettings = IncludeCcSubs && ConvertVtt2Ass;
SelectedSubLang.CollectionChanged += Changes;
SelectedDubLang.CollectionChanged += Changes;
@ -443,6 +492,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
}
CrunchyrollManager.Instance.CrunOptions.SubsDownloadDuplicate = SubsDownloadDuplicate;
CrunchyrollManager.Instance.CrunOptions.ConvertVtt2Ass = ConvertVtt2Ass;
CrunchyrollManager.Instance.CrunOptions.FixCccSubtitles = FixCccSubtitles;
CrunchyrollManager.Instance.CrunOptions.MarkAsWatched = MarkAsWatched;
CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub = DownloadFirstAvailableDub;
CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi = UseCrBetaApi;
@ -457,6 +508,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.VideoTitle = FileTitle;
CrunchyrollManager.Instance.CrunOptions.Novids = !DownloadVideo;
CrunchyrollManager.Instance.CrunOptions.Noaudio = !DownloadAudio;
CrunchyrollManager.Instance.CrunOptions.DownloadDescriptionAudio = DownloadDescriptionAudio;
CrunchyrollManager.Instance.CrunOptions.DlVideoOnce = !DownloadVideoForEveryDub;
CrunchyrollManager.Instance.CrunOptions.KeepDubsSeperate = KeepDubsSeparate;
CrunchyrollManager.Instance.CrunOptions.Chapters = DownloadChapters;
@ -489,14 +541,18 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
string descLang = SelectedDescriptionLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.DescriptionLang = descLang != "default" ? descLang : CrunchyrollManager.Instance.DefaultLocale;
CrunchyrollManager.Instance.CrunOptions.Hslang = SelectedHSLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.HsRawFallback = HsRawFallback;
CrunchyrollManager.Instance.CrunOptions.DefaultAudio = SelectedDefaultDubLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.DefaultSub = SelectedDefaultSubLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.StreamEndpoint = SelectedStreamEndpoint.Content + "";
var endpointSettingsFirst = new CrAuthSettings();
endpointSettingsFirst.Endpoint = SelectedStreamEndpoint.Content + "";
endpointSettingsFirst.Video = FirstEndpointVideo;
endpointSettingsFirst.Audio = FirstEndpointAudio;
CrunchyrollManager.Instance.CrunOptions.StreamEndpoint = endpointSettingsFirst;
var endpointSettings = new CrAuthSettings();
endpointSettings.Endpoint = SelectedStreamEndpointSecondary.Content + "";
@ -504,9 +560,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
endpointSettings.UserAgent = EndpointUserAgent;
endpointSettings.Device_name = EndpointDeviceName;
endpointSettings.Device_type = EndpointDeviceType;
endpointSettings.Video = EndpointVideo;
endpointSettings.Audio = EndpointAudio;
CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondSettings = endpointSettings;
CrunchyrollManager.Instance.CrAuthEndpoint2.AuthSettings = endpointSettings;
List<string> dubLangs = new List<string>();
foreach (var listBoxItem in SelectedDubLang){
@ -609,6 +669,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
}
UpdateSettings();
ShowVtt2AssSettings = IncludeCcSubs && ConvertVtt2Ass;
if (e.PropertyName is nameof(History)){
if (CrunchyrollManager.Instance.CrunOptions.History){
@ -625,13 +686,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
}
}
} else{
CrunchyrollManager.Instance.HistoryList =[];
CrunchyrollManager.Instance.HistoryList = [];
}
}
_ = SonarrClient.Instance.RefreshSonarrLite();
} else{
CrunchyrollManager.Instance.HistoryList =[];
CrunchyrollManager.Instance.HistoryList = [];
}
}
}
@ -669,13 +730,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public void ResetEndpointSettings(){
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == ("android/phone")) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
EndpointAuthorization = "Basic YmY3MHg2aWhjYzhoZ3p3c2J2eGk6eDJjc3BQZXQzWno1d0pDdEpyVUNPSVM5Ynpad1JDcGM=";
EndpointUserAgent = "Crunchyroll/3.90.0 Android/16 okhttp/4.12.0";
EndpointDeviceName = "CPH2449";
EndpointDeviceType = "OnePlus CPH2449";
EndpointAuthorization = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Authorization;
EndpointUserAgent = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.UserAgent;
EndpointDeviceName = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_name;
EndpointDeviceType = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_type;
}
[RelayCommand]
public async Task Login(){
var dialog = new ContentDialog(){
@ -690,9 +751,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
};
_ = await dialog.ShowAsync();
IsLoggingIn = true;
await viewModel.LoginCompleted;
IsLoggingIn = false;
EndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???";
}
private List<StringItemWithDisplayName> GetAvailableHWAccelOptions(){
@ -706,7 +768,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
process.StartInfo.CreateNoWindow = true;
string output = string.Empty;
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
output += e.Data + Environment.NewLine;
@ -714,7 +776,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
};
process.Start();
process.BeginOutputReadLine();
// process.BeginErrorReadLine();
@ -725,11 +787,16 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
return MapHWAccelOptions(accels);
}
} catch (Exception e){
Console.WriteLine("Failed to get Available HW Accel Options" + e);
Console.Error.WriteLine("Failed to get Available HW Accel Options" + e);
}
var result = new List<StringItemWithDisplayName>();
result.Add(new StringItemWithDisplayName{
DisplayName = "No hardware acceleration / error",
value = "error"
});
return[];
return result;
}
private List<StringItemWithDisplayName> MapHWAccelOptions(List<string> accels){

View file

@ -56,6 +56,12 @@
</controls:SettingsExpander.Footer>
<controls:SettingsExpanderItem Content="Download AD for Selected Dubs" Description="Downloads audio description tracks matching the selected dub languages (if available).">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding DownloadDescriptionAudio}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
@ -69,6 +75,12 @@
</ComboBox>
</controls:SettingsExpander.Footer>
<controls:SettingsExpanderItem Content="No hardsubs fallback" Description="If no hardsubs are available, automatically download the no-hardsub (raw) video.">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HsRawFallback}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
@ -105,6 +117,12 @@
</StackPanel>
</controls:SettingsExpander.Footer>
<controls:SettingsExpanderItem Content="Fix Ccc Subtitles" Description="Automatically adjusts subtitle styles that were created with ClosedCaptionConverter">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding FixCccSubtitles}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Download Duplicate" Description="Download subtitles from all dubs where they're available">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding SubsDownloadDuplicate}"> </CheckBox>
@ -187,7 +205,13 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding IncludeCcSubs}" Content="CC Subtitles" Description="Font">
<controls:SettingsExpanderItem IsVisible="{Binding IncludeCcSubs}" Content="Convert CC Subtitles to ASS" Description="When enabled, closed-caption WEBVTT subtitles are converted into ASS format">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding ConvertVtt2Ass}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding ShowVtt2AssSettings}" Content="CC Subtitles" Description="Font">
<controls:SettingsExpanderItem.Footer>
<TextBox HorizontalAlignment="Left" MinWidth="250"
Text="{Binding CCSubsFont}" />
@ -231,12 +255,27 @@
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Stream Endpoint " IsEnabled="False">
<controls:SettingsExpanderItem Content="Stream Endpoint ">
<controls:SettingsExpanderItem.Footer>
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding StreamEndpoints}"
SelectedItem="{Binding SelectedStreamEndpoint}">
</ComboBox>
<StackPanel>
<ComboBox IsEnabled="False" HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding StreamEndpoints}"
SelectedItem="{Binding SelectedStreamEndpoint}">
</ComboBox>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding FirstEndpointVideo}" />
</StackPanel>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding FirstEndpointAudio}" />
</StackPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
@ -248,27 +287,38 @@
SelectedItem="{Binding SelectedStreamEndpointSecondary}">
</ComboBox>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Video" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding EndpointVideo}" />
</StackPanel>
<StackPanel Margin="0,5" Orientation="Horizontal" VerticalAlignment="Center">
<TextBlock Text="Audio" Margin="0 , 0 , 5 , 0" VerticalAlignment="Center"/>
<CheckBox HorizontalAlignment="Left" MinWidth="250" VerticalAlignment="Center"
IsChecked="{Binding EndpointAudio}" />
</StackPanel>
<StackPanel Margin="0,5">
<TextBlock Text="Authorization" />
<TextBox Name="AuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250"
<TextBox Name="AuthorizationTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointAuthorization}" />
</StackPanel>
<StackPanel Margin="0,5">
<TextBlock Text="User Agent" />
<TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250"
<TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointUserAgent}" />
</StackPanel>
<StackPanel Margin="0,5">
<TextBlock Text="Device Type" />
<TextBox Name="DeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250"
<TextBox Name="DeviceTypeTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointDeviceType}" />
</StackPanel>
<StackPanel Margin="0,5">
<TextBlock Text="Device Name" />
<TextBox Name="DeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250"
<TextBox Name="DeviceNameTextBox" HorizontalAlignment="Left" MinWidth="250" MaxWidth="250" TextWrapping="Wrap"
Text="{Binding EndpointDeviceName}" />
</StackPanel>
@ -286,6 +336,12 @@
<TextBlock Text="Login" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12" />
</StackPanel>
</Button>
<controls:ProgressRing Width="24" Height="24"
Margin="8,0,0,0"
IsActive="{Binding IsLoggingIn}"
IsVisible="{Binding IsLoggingIn}" />
<controls:SymbolIcon Symbol="CloudOff"
IsVisible="{Binding EndpointNotSignedWarning}"
Foreground="OrangeRed"

View file

@ -26,6 +26,10 @@ public partial class QueueManager : ObservableObject{
public int ActiveDownloads => Volatile.Read(ref activeDownloads);
public readonly SemaphoreSlim activeProcessingJobs = new SemaphoreSlim(initialCount: CrunchyrollManager.Instance.CrunOptions.SimultaneousProcessingJobs, maxCount: int.MaxValue);
private int _limit = CrunchyrollManager.Instance.CrunOptions.SimultaneousProcessingJobs;
private int _borrowed = 0;
#endregion
[ObservableProperty]
@ -60,6 +64,10 @@ public partial class QueueManager : ObservableObject{
Interlocked.Increment(ref activeDownloads);
}
public void ResetDownloads(){
Interlocked.Exchange(ref activeDownloads, 0);
}
public void DecrementDownloads(){
while (true){
int current = Volatile.Read(ref activeDownloads);
@ -69,7 +77,6 @@ public partial class QueueManager : ObservableObject{
return;
}
}
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
if (e.Action == NotifyCollectionChangedAction.Remove){
@ -315,7 +322,7 @@ public partial class QueueManager : ObservableObject{
MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1));
}
public async Task CrAddMusicVideoToQueue(string epId){
public async Task CrAddMusicVideoToQueue(string epId, string overrideDownloadPath = ""){
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var musicVideo = await CrunchyrollManager.Instance.CrMusic.ParseMusicVideoByIdAsync(epId, "");
@ -329,6 +336,7 @@ public partial class QueueManager : ObservableObject{
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(musicVideoMeta.SeriesId, musicVideoMeta.SeasonId, musicVideoMeta.Data.First().MediaId);
}
musicVideoMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
musicVideoMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
@ -339,7 +347,7 @@ public partial class QueueManager : ObservableObject{
}
}
public async Task CrAddConcertToQueue(string epId){
public async Task CrAddConcertToQueue(string epId, string overrideDownloadPath = ""){
await CrunchyrollManager.Instance.CrAuthEndpoint1.RefreshToken(true);
var concert = await CrunchyrollManager.Instance.CrMusic.ParseConcertByIdAsync(epId, "");
@ -353,6 +361,7 @@ public partial class QueueManager : ObservableObject{
historyEpisode = CrunchyrollManager.Instance.History.GetHistoryEpisodeWithDubListAndDownloadDir(concertMeta.SeriesId, concertMeta.SeasonId, concertMeta.Data.First().MediaId);
}
concertMeta.DownloadPath = !string.IsNullOrEmpty(overrideDownloadPath) ? overrideDownloadPath : (!string.IsNullOrEmpty(historyEpisode.downloadDirPath) ? historyEpisode.downloadDirPath : "");
concertMeta.VideoQuality = !string.IsNullOrEmpty(historyEpisode.videoQuality) ? historyEpisode.videoQuality : CrunchyrollManager.Instance.CrunOptions.QualityVideo;
var newOptions = Helpers.DeepCopy(CrunchyrollManager.Instance.CrunOptions);
@ -438,7 +447,7 @@ public partial class QueueManager : ObservableObject{
crunchyEpMeta.HighlightAllAvailable = true;
}
}
Queue.Add(crunchyEpMeta);
@ -467,4 +476,32 @@ public partial class QueueManager : ObservableObject{
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't add episode(s) to the queue with current dub settings", ToastType.Error, 2));
}
}
public void SetLimit(int newLimit){
lock (activeProcessingJobs){
if (newLimit == _limit) return;
if (newLimit > _limit){
int giveBack = Math.Min(_borrowed, newLimit - _limit);
if (giveBack > 0){
activeProcessingJobs.Release(giveBack);
_borrowed -= giveBack;
}
int more = newLimit - _limit - giveBack;
if (more > 0) activeProcessingJobs.Release(more);
} else{
int toPark = _limit - newLimit;
for (int i = 0; i < toPark; i++){
_ = Task.Run(async () => {
await activeProcessingJobs.WaitAsync().ConfigureAwait(false);
Interlocked.Increment(ref _borrowed);
});
}
}
_limit = newLimit;
}
}
}

View file

@ -1,6 +1,7 @@
using System;
using Avalonia;
using System.Linq;
using ReactiveUI.Avalonia;
namespace CRD;
@ -26,7 +27,8 @@ sealed class Program{
var builder = AppBuilder.Configure<App>()
.UsePlatformDetect()
.WithInterFont()
.LogToTrace();
.LogToTrace()
.UseReactiveUI() ;
if (isHeadless){
Console.WriteLine("Running in headless mode...");

View file

@ -173,6 +173,9 @@ public enum DownloadMediaType{
[EnumMember(Value = "Audio")]
Audio,
[EnumMember(Value = "AudioRoleDescription")]
AudioRoleDescription,
[EnumMember(Value = "Chapters")]
Chapters,

View file

@ -1,9 +1,14 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Globalization;
using System.IO;
using System.IO.Compression;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using CRD.Downloader.Crunchyroll;
using Newtonsoft.Json;
@ -11,7 +16,7 @@ namespace CRD.Utils.Files;
public class CfgManager{
private static string workingDirectory = AppContext.BaseDirectory;
public static readonly string PathCrToken = Path.Combine(workingDirectory, "config", "cr_token.json");
public static readonly string PathCrDownloadOptions = Path.Combine(workingDirectory, "config", "settings.json");
@ -182,30 +187,105 @@ public class CfgManager{
WriteJsonToFileCompressed(PathCrHistory, CrunchyrollManager.Instance.HistoryList);
}
private static object fileLock = new object();
private static readonly ConcurrentDictionary<string, object> _pathLocks =
new(OperatingSystem.IsWindows()
? StringComparer.OrdinalIgnoreCase
: StringComparer.Ordinal);
public static void WriteJsonToFileCompressed(string pathToFile, object obj){
try{
// Check if the directory exists; if not, create it.
string directoryPath = Path.GetDirectoryName(pathToFile);
if (!Directory.Exists(directoryPath)){
Directory.CreateDirectory(directoryPath);
}
public static void WriteJsonToFileCompressed(string pathToFile, object obj, int keepBackups = 5){
string? directoryPath = Path.GetDirectoryName(pathToFile);
if (string.IsNullOrEmpty(directoryPath))
directoryPath = Environment.CurrentDirectory;
lock (fileLock){
using (var fileStream = new FileStream(pathToFile, FileMode.Create, FileAccess.Write))
using (var gzipStream = new GZipStream(fileStream, CompressionLevel.Optimal))
using (var streamWriter = new StreamWriter(gzipStream))
using (var jsonWriter = new JsonTextWriter(streamWriter){ Formatting = Formatting.Indented }){
Directory.CreateDirectory(directoryPath);
string key = Path.GetFullPath(pathToFile);
object gate = _pathLocks.GetOrAdd(key, _ => new object());
lock (gate){
string tmp = Path.Combine(
directoryPath,
"." + Path.GetFileName(pathToFile) + "." + Guid.NewGuid().ToString("N") + ".tmp");
try{
var fso = new FileStreamOptions{
Mode = FileMode.CreateNew,
Access = FileAccess.Write,
Share = FileShare.None,
BufferSize = 64 * 1024,
Options = FileOptions.WriteThrough
};
using (var fs = new FileStream(tmp, fso))
using (var gzip = new GZipStream(fs, CompressionLevel.Optimal, leaveOpen: false))
using (var sw = new StreamWriter(gzip))
using (var jw = new JsonTextWriter(sw){ Formatting = Formatting.Indented }){
var serializer = new JsonSerializer();
serializer.Serialize(jsonWriter, obj);
serializer.Serialize(jw, obj);
}
if (File.Exists(pathToFile)){
string backupPath = GetDailyBackupPath(pathToFile, DateTime.Today);
File.Replace(tmp, pathToFile, backupPath, ignoreMetadataErrors: true);
PruneBackups(pathToFile, keepBackups);
} else{
File.Move(tmp, pathToFile, overwrite: true);
}
} catch (Exception ex){
try{
if (File.Exists(tmp)) File.Delete(tmp);
} catch{
/* ignore */
}
Console.Error.WriteLine($"An error occurred writing {pathToFile}: {ex.Message}");
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}");
}
}
private static string GetDailyBackupPath(string pathToFile, DateTime date){
string dir = Path.GetDirectoryName(pathToFile)!;
string name = Path.GetFileName(pathToFile);
string backupName = $".{name}.{date:yyyy-MM-dd}.bak";
return Path.Combine(dir, backupName);
}
private static void PruneBackups(string pathToFile, int keep){
string dir = Path.GetDirectoryName(pathToFile)!;
string name = Path.GetFileName(pathToFile);
// Backups: .<name>.YYYY-MM-DD.bak
string glob = $".{name}.*.bak";
var rx = new Regex(@"^\." + Regex.Escape(name) + @"\.(\d{4}-\d{2}-\d{2})\.bak$", RegexOptions.CultureInvariant);
var datedBackups = new List<(string Path, DateTime Date)>();
foreach (var path in Directory.EnumerateFiles(dir, glob, SearchOption.TopDirectoryOnly)){
string file = Path.GetFileName(path);
var m = rx.Match(file);
if (!m.Success) continue;
if (DateTime.TryParseExact(m.Groups[1].Value, "yyyy-MM-dd", CultureInfo.InvariantCulture,
DateTimeStyles.None, out var d)){
datedBackups.Add((path, d));
}
}
// Newest first
foreach (var old in datedBackups
.OrderByDescending(x => x.Date)
.Skip(Math.Max(keep, 0))){
try{
File.Delete(old.Path);
} catch(Exception ex){
Console.Error.WriteLine("[Backup] - Failed to delete old backups: " + ex.Message);
}
}
}
private static object fileLock = new object();
public static void WriteJsonToFile(string pathToFile, object obj){
try{
// Check if the directory exists; if not, create it.
@ -227,53 +307,45 @@ public class CfgManager{
}
}
public static string DecompressJsonFile(string pathToFile){
public static string? DecompressJsonFile(string pathToFile){
try{
using (var fileStream = new FileStream(pathToFile, FileMode.Open, FileAccess.Read)){
// Check if the file is compressed
if (IsFileCompressed(fileStream)){
// Reset the stream position to the beginning
fileStream.Position = 0;
using (var gzipStream = new GZipStream(fileStream, CompressionMode.Decompress))
using (var streamReader = new StreamReader(gzipStream)){
return streamReader.ReadToEnd();
}
}
var fso = new FileStreamOptions{
Mode = FileMode.Open,
Access = FileAccess.Read,
Share = FileShare.ReadWrite | FileShare.Delete,
Options = FileOptions.SequentialScan
};
// If not compressed, read the file as is
fileStream.Position = 0;
using (var streamReader = new StreamReader(fileStream)){
return streamReader.ReadToEnd();
}
using var fs = new FileStream(pathToFile, fso);
Span<byte> hdr = stackalloc byte[2];
int read = fs.Read(hdr);
fs.Position = 0;
bool looksGzip = read >= 2 && hdr[0] == 0x1F && hdr[1] == 0x8B;
if (looksGzip){
using var gzip = new GZipStream(fs, CompressionMode.Decompress, leaveOpen: false);
using var sr = new StreamReader(gzip, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return sr.ReadToEnd();
} else{
using var sr = new StreamReader(fs, Encoding.UTF8, detectEncodingFromByteOrderMarks: true);
return sr.ReadToEnd();
}
} catch (FileNotFoundException){
return null;
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}");
Console.Error.WriteLine($"Read failed for {pathToFile}: {ex.Message}");
return null;
}
}
private static bool IsFileCompressed(FileStream fileStream){
// Check the first two bytes for the GZip header
var buffer = new byte[2];
fileStream.Read(buffer, 0, 2);
return buffer[0] == 0x1F && buffer[1] == 0x8B;
}
public static bool CheckIfFileExists(string filePath){
string dirPath = Path.GetDirectoryName(filePath) ?? string.Empty;
return Directory.Exists(dirPath) && File.Exists(filePath);
}
// public static T DeserializeFromFile<T>(string filePath){
// var deserializer = new DeserializerBuilder()
// .Build();
//
// using (var reader = new StreamReader(filePath)){
// return deserializer.Deserialize<T>(reader);
// }
// }
public static T? ReadJsonFromFile<T>(string pathToFile) where T : class{
try{

View file

@ -51,8 +51,9 @@ public class Helpers{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var property in originalRequest.Properties){
clone.Properties.Add(property);
foreach (var kvp in originalRequest.Options){
var key = new HttpRequestOptionsKey<object?>(kvp.Key);
clone.Options.Set(key, kvp.Value);
}
return clone;
@ -71,15 +72,33 @@ public class Helpers{
return JsonConvert.DeserializeObject<T>(json);
}
public static int ToKbps(int bps) => (int)Math.Round(bps / 1000.0);
public static string ConvertTimeFormat(string time){
var timeParts = time.Split(':', '.');
int hours = int.Parse(timeParts[0]);
int minutes = int.Parse(timeParts[1]);
int seconds = int.Parse(timeParts[2]);
int milliseconds = int.Parse(timeParts[3]);
public static int SnapToAudioBucket(int kbps){
int[] buckets = { 64, 96, 128, 192, 256 };
return buckets.OrderBy(b => Math.Abs(b - kbps)).First();
}
return $"{hours}:{minutes:D2}:{seconds:D2}.{milliseconds / 10:D2}";
public static int WidthBucket(int width, int height){
int expected = (int)Math.Round(height * 16 / 9.0);
int tol = Math.Max(8, (int)(expected * 0.02)); // ~2% or ≥8 px
return Math.Abs(width - expected) <= tol ? expected : width;
}
public static string ConvertTimeFormat(string vttTime){
if (TimeSpan.TryParseExact(vttTime, @"hh\:mm\:ss\.fff", null, out var ts) ||
TimeSpan.TryParseExact(vttTime, @"mm\:ss\.fff", null, out ts)){
var totalCentiseconds = (int)Math.Round(ts.TotalMilliseconds / 10.0, MidpointRounding.AwayFromZero);
var hours = totalCentiseconds / 360000; // 100 cs * 60 * 60
var rem = totalCentiseconds % 360000;
var mins = rem / 6000;
rem %= 6000;
var secs = rem / 100;
var cs = rem % 100;
return $"{hours}:{mins:00}:{secs:00}.{cs:00}";
}
return "0:00:00.00";
}
public static string ConvertVTTStylesToASS(string dialogue){
@ -391,6 +410,7 @@ public class Helpers{
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.EnableRaisingEvents = true;
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
@ -411,7 +431,28 @@ public class Helpers{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
using var reg = data?.Cts.Token.Register(() => {
try{
if (!process.HasExited)
process.Kill(true);
} 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;
@ -423,7 +464,14 @@ public class Helpers{
File.Move(tempOutputFilePath, inputFilePath);
} else{
// If something went wrong, delete the temporary output file
File.Delete(tempOutputFilePath);
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
/* ignore */
}
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine($"Command: {ffmpegCommand}");
}
@ -509,7 +557,7 @@ public class Helpers{
return CosineSimilarity(vector1, vector2);
}
private static readonly char[] Delimiters ={ ' ', ',', '.', ';', ':', '-', '_', '\'' };
private static readonly char[] Delimiters = { ' ', ',', '.', ';', ':', '-', '_', '\'' };
public static Dictionary<string, double> ComputeWordFrequency(string text){
var wordFrequency = new Dictionary<string, double>(StringComparer.OrdinalIgnoreCase);
@ -572,6 +620,12 @@ public class Helpers{
string cNumber = match.Groups[2].Value; // Extract the C number if present
string pNumber = match.Groups[3].Value; // Extract the P number if present
if (int.TryParse(sNumber, out int sNumericBig)){
// Reject invalid S numbers (>= 1000)
if (sNumericBig >= 1000)
return null;
}
if (!string.IsNullOrEmpty(cNumber)){
// Case for C: Return S + . + C
return $"{sNumber}.{cNumber}";
@ -645,10 +699,10 @@ public class Helpers{
group.Add(descriptionMedia[0]);
}
}
//Find and add Cover media to each group
var coverMedia = allMedia.Where(media => media.Type == DownloadMediaType.Cover).ToList();
if (coverMedia.Count > 0){
foreach (var group in languageGroups.Values){
group.Add(coverMedia[0]);
@ -666,7 +720,7 @@ public class Helpers{
bool isValid = !folderName.Any(c => invalidChars.Contains(c));
// Check for reserved names on Windows
string[] reservedNames =["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"];
string[] reservedNames = ["CON", "PRN", "AUX", "NUL", "COM1", "LPT1"];
bool isReservedName = reservedNames.Contains(folderName.ToUpperInvariant());
if (isValid && !isReservedName && folderName.Length <= 255){
@ -793,30 +847,46 @@ public class Helpers{
}
}
public static void MergePlaylistData(
Dictionary<string, ServerData> target,
Dictionary<string, ServerData> source){
foreach (var kvp in source){
if (target.TryGetValue(kvp.Key, out var existing)){
// Merge audio
existing.audio ??=[];
if (kvp.Value.audio != null)
existing.audio.AddRange(kvp.Value.audio);
// Merge video
existing.video ??=[];
if (kvp.Value.video != null)
existing.video.AddRange(kvp.Value.video);
} else{
// Add new entry (clone lists to avoid reference issues)
target[kvp.Key] = new ServerData{
audio = kvp.Value.audio != null ? new List<AudioPlaylist>(kvp.Value.audio) : new List<AudioPlaylist>(),
video = kvp.Value.video != null ? new List<VideoPlaylist>(kvp.Value.video) : new List<VideoPlaylist>()
};
public static void MergePlaylistData(
ServerData target,
Dictionary<string, ServerData> source,
bool mergeAudio,
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){
if (!string.IsNullOrWhiteSpace(s) && serverSet.Add(s))
target.servers.Add(s);
}
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);
}
if (mergeVideo && src.video != null){
target.video ??= [];
target.video.AddRange(src.video);
}
}
}
private static readonly SemaphoreSlim ShutdownLock = new(1, 1);
public static async Task ShutdownComputer(){
@ -860,7 +930,7 @@ public class Helpers{
} else{
throw new PlatformNotSupportedException();
}
try{
using (var process = new Process()){
process.StartInfo.FileName = shutdownCmd;
@ -875,13 +945,13 @@ public class Helpers{
Console.Error.WriteLine($"{e.Data}");
}
};
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine(e.Data);
Console.Error.WriteLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
@ -892,11 +962,9 @@ public class Helpers{
if (process.ExitCode != 0){
Console.Error.WriteLine($"Shutdown failed with exit code {process.ExitCode}");
}
}
} catch (Exception ex){
Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}");
}
}
}

View file

@ -0,0 +1,137 @@
using System;
using System.Collections.Generic;
using System.Net;
using System.Net.Http;
using System.Text;
using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using Newtonsoft.Json;
namespace CRD.Utils;
public class FlareSolverrClient{
private readonly HttpClient _httpClient;
private FlareSolverrProperties properties;
private string flaresolverrUrl = "http://localhost:8191";
public FlareSolverrClient(){
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null) properties = CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties;
if (properties != null){
flaresolverrUrl = $"http{(properties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(properties.Host) ? properties.Host : "localhost")}:{properties.Port}";
}
_httpClient = new HttpClient{ BaseAddress = new Uri(flaresolverrUrl) };
_httpClient.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/129.0.0.0 Safari/537.36");
}
public async Task<(bool IsOk, string ResponseContent, List<Cookie> cookies)> SendViaFlareSolverrAsync(HttpRequestMessage request,List<Cookie> cookiesToSend){
var flaresolverrCookies = new List<object>();
foreach (var cookie in cookiesToSend)
{
flaresolverrCookies.Add(new
{
name = cookie.Name,
value = cookie.Value,
domain = cookie.Domain,
path = cookie.Path,
secure = cookie.Secure,
httpOnly = cookie.HttpOnly
});
}
var requestData = new{
cmd = request.Method.Method.ToLower() switch{
"get" => "request.get",
"post" => "request.post",
"patch" => "request.patch",
_ => "request.get" // Default to GET if the method is unknown
},
url = request.RequestUri.ToString(),
maxTimeout = 60000,
postData = request.Method == HttpMethod.Post || request.Method == HttpMethod.Patch
? await request.Content.ReadAsStringAsync()
: null,
cookies = flaresolverrCookies
};
// Serialize the request data to JSON
var json = JsonConvert.SerializeObject(requestData);
var flareSolverrContent = new StringContent(json, Encoding.UTF8, "application/json");
// Send the request to FlareSolverr
var flareSolverrRequest = new HttpRequestMessage(HttpMethod.Post, $"{flaresolverrUrl}/v1"){
Content = flareSolverrContent
};
HttpResponseMessage flareSolverrResponse;
try{
flareSolverrResponse = await _httpClient.SendAsync(flareSolverrRequest);
} catch (Exception ex){
Console.Error.WriteLine($"Error sending request to FlareSolverr: {ex.Message}");
return (IsOk: false, ResponseContent: $"Error sending request to FlareSolverr: {ex.Message}", []);
}
string flareSolverrResponseContent = await flareSolverrResponse.Content.ReadAsStringAsync();
// Parse the FlareSolverr response
var flareSolverrResult = JsonConvert.DeserializeObject<FlareSolverrResponse>(flareSolverrResponseContent);
if (flareSolverrResult != null && flareSolverrResult.Status == "ok"){
return (IsOk: true, ResponseContent: flareSolverrResult.Solution.Response, flareSolverrResult.Solution.cookies);
} else{
Console.Error.WriteLine($"Flare Solverr Failed \n Response: {flareSolverrResponseContent}");
return (IsOk: false, ResponseContent: flareSolverrResponseContent, []);
}
}
private Dictionary<string, string> GetHeadersDictionary(HttpRequestMessage request){
var headers = new Dictionary<string, string>();
foreach (var header in request.Headers){
headers[header.Key] = string.Join(", ", header.Value);
}
if (request.Content != null){
foreach (var header in request.Content.Headers){
headers[header.Key] = string.Join(", ", header.Value);
}
}
return headers;
}
private Dictionary<string, string> GetCookiesDictionary(HttpRequestMessage request, Dictionary<string, CookieCollection> cookieStore){
var cookiesDictionary = new Dictionary<string, string>();
if (cookieStore.TryGetValue(request.RequestUri.Host, out CookieCollection cookies)){
foreach (Cookie cookie in cookies){
cookiesDictionary[cookie.Name] = cookie.Value;
}
}
return cookiesDictionary;
}
}
public class FlareSolverrResponse{
public string Status{ get; set; }
public FlareSolverrSolution Solution{ get; set; }
}
public class FlareSolverrSolution{
public string Url{ get; set; }
public string Status{ get; set; }
public List<Cookie> cookies{ get; set; }
public string Response{ get; set; }
}
public class FlareSolverrProperties(){
public bool UseFlareSolverr{ get; set; }
public string? Host{ get; set; }
public int Port{ get; set; }
public bool UseSsl{ get; set; }
}

View file

@ -33,9 +33,12 @@ public class HttpClientReq{
}
#endregion
private HttpClient client;
public readonly bool useFlareSolverr;
private FlareSolverrClient flareSolverrClient;
public HttpClientReq(){
IWebProxy systemProxy = WebRequest.DefaultWebProxy;
@ -79,6 +82,11 @@ public class HttpClientReq{
client.DefaultRequestHeaders.AcceptEncoding.ParseAdd("gzip, deflate, br");
// client.DefaultRequestHeaders.AcceptLanguage.ParseAdd("en-US,en;q=0.5");
client.DefaultRequestHeaders.Connection.ParseAdd("keep-alive");
if (CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties != null && CrunchyrollManager.Instance.CrunOptions.FlareSolverrProperties.UseFlareSolverr){
useFlareSolverr = true;
flareSolverrClient = new FlareSolverrClient();
}
}
private HttpMessageHandler CreateHttpClientHandler(){
@ -137,6 +145,8 @@ public class HttpClientReq{
response.EnsureSuccessStatusCode();
CaptureResponseCookies(response, request.RequestUri!, cookieStore);
return (IsOk: true, ResponseContent: content, error: "");
} catch (Exception e){
// Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
@ -148,6 +158,48 @@ public class HttpClientReq{
}
}
public async Task<(bool IsOk, string ResponseContent, string error)> SendFlareSolverrHttpRequest(HttpRequestMessage request, bool suppressError = false){
string content = string.Empty;
try{
var flareSolverrResponses = await flareSolverrClient.SendViaFlareSolverrAsync(request, []);
content = flareSolverrResponses.ResponseContent;
return (IsOk: flareSolverrResponses.IsOk, ResponseContent: content, error: "");
} catch (Exception e){
if (!suppressError){
Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
}
return (IsOk: false, ResponseContent: content, error: "");
}
}
private void CaptureResponseCookies(HttpResponseMessage response, Uri requestUri, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return;
}
if (response.Headers.TryGetValues("Set-Cookie", out var cookieHeaders)){
string domain = requestUri.Host.StartsWith("www.") ? requestUri.Host.Substring(4) : requestUri.Host;
foreach (var header in cookieHeaders){
var cookies = header.Split(';', StringSplitOptions.RemoveEmptyEntries);
var nameValue = cookies[0].Split('=', 2);
if (nameValue.Length != 2) continue;
var cookie = new Cookie(nameValue[0].Trim(), nameValue[1].Trim()){
Domain = domain,
Path = "/"
};
AddCookie(domain, cookie, cookieStore);
}
}
}
private void AttachCookies(HttpRequestMessage request, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return;
@ -177,6 +229,19 @@ public class HttpClientReq{
}
}
public string? GetCookieValue(string domain, string cookieName, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return null;
}
if (cookieStore.TryGetValue(domain, out var cookies)){
var cookie = cookies.Cast<Cookie>().FirstOrDefault(c => c.Name == cookieName);
return cookie?.Value;
}
return null;
}
public void AddCookie(string domain, Cookie cookie, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return;
@ -231,13 +296,14 @@ 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 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";
public static string Cms => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/cms";
public static string Content => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2";
public static string Playback => "https://cr-play-service.prd.crunchyrollsvc.com/v2";
public static string Playback => "https://cr-play-service.prd.crunchyrollsvc.com/v3";
//https://www.crunchyroll.com/playback/v2
//https://cr-play-service.prd.crunchyrollsvc.com/v2

View file

@ -16,9 +16,6 @@ public class Merger{
public Merger(MergerOptions options){
this.options = options;
if (this.options.SkipSubMux != null && this.options.SkipSubMux == true){
this.options.Subtitles = new();
}
if (this.options.VideoTitle != null && this.options.VideoTitle.Length > 0){
this.options.VideoTitle = this.options.VideoTitle.Replace("\"", "'");
@ -74,35 +71,39 @@ public class Merger{
index++;
}
bool hasSignsSub = options.Subtitles.Any(sub => sub.Signs && options.Defaults.Sub.Code == sub.Language.Code);
if (!options.SkipSubMux){
bool hasSignsSub = options.Subtitles.Any(sub => sub.Signs && options.Defaults.Sub.Code == sub.Language.Code);
foreach (var sub in options.Subtitles.Select((value, i) => new{ value, i })){
if (sub.value.Delay != null && sub.value.Delay != 0){
double delay = sub.value.Delay / 1000.0 ?? 0;
args.Add($"-itsoffset {delay.ToString(CultureInfo.InvariantCulture)}");
foreach (var sub in options.Subtitles.Select((value, i) => new{ value, i })){
if (sub.value.Delay != null && sub.value.Delay != 0){
double delay = sub.value.Delay / 1000.0 ?? 0;
args.Add($"-itsoffset {delay.ToString(CultureInfo.InvariantCulture)}");
}
args.Add($"-i \"{sub.value.File}\"");
metaData.Add($"-map {index}:s");
if (options.Defaults.Sub.Code == sub.value.Language.Code &&
(options.DefaultSubSigns == sub.value.Signs || options.DefaultSubSigns && !hasSignsSub)
&& sub.value.ClosedCaption == false){
metaData.Add($"-disposition:s:{sub.i} default");
} else{
metaData.Add($"-disposition:s:{sub.i} 0");
}
index++;
}
args.Add($"-i \"{sub.value.File}\"");
metaData.Add($"-map {index}:s");
if (options.Defaults.Sub.Code == sub.value.Language.Code &&
(options.DefaultSubSigns == sub.value.Signs || options.DefaultSubSigns && !hasSignsSub)
&& sub.value.ClosedCaption == false){
metaData.Add($"-disposition:s:{sub.i} default");
} else{
metaData.Add($"-disposition:s:{sub.i} 0");
}
index++;
}
args.AddRange(metaData);
// args.AddRange(options.Subtitles.Select((sub, subIndex) => $"-map {subIndex + index}"));
args.Add("-c:v copy");
args.Add("-c:a copy");
args.Add(options.Output.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ? "-c:s mov_text" : "-c:s ass");
args.AddRange(options.Subtitles.Select((sub, subindex) =>
$"-metadata:s:s:{subindex} title=\"{sub.Language.Language ?? sub.Language.Name}{(sub.ClosedCaption == true ? $" {options.CcTag}" : "")}{(sub.Signs == true ? " Signs" : "")}\" -metadata:s:s:{subindex} language={sub.Language.Code}"));
if (!options.SkipSubMux){
args.AddRange(options.Subtitles.Select((sub, subindex) =>
$"-metadata:s:s:{subindex} title=\"{sub.Language.Language ?? sub.Language.Name}{(sub.ClosedCaption == true ? $" {options.CcTag}" : "")}{(sub.Signs == true ? " Signs" : "")}\" -metadata:s:s:{subindex} language={sub.Language.Code}"));
}
if (!string.IsNullOrEmpty(options.VideoTitle)){
args.Add($"-metadata title=\"{options.VideoTitle}\"");
@ -134,9 +135,9 @@ public class Merger{
}
var audio = options.OnlyAudio.First();
args.Add($"-i \"{audio.Path}\"");
args.Add("-c:a libmp3lame" + (audio.Bitrate > 0 ? $" -b:a {audio.Bitrate}k" : "") );
args.Add("-c:a libmp3lame" + (audio.Bitrate > 0 ? $" -b:a {audio.Bitrate}k" : ""));
args.Add($"\"{options.Output}\"");
return string.Join(" ", args);
}
@ -167,19 +168,31 @@ public class Merger{
}
}
// var sortedAudio = options.OnlyAudio
// .OrderBy(sub => options.DubLangList.IndexOf(sub.Language.CrLocale) != -1 ? options.DubLangList.IndexOf(sub.Language.CrLocale) : int.MaxValue)
// .ToList();
var rank = options.DubLangList
.Select((val, i) => new{ val, i })
.ToDictionary(x => x.val, x => x.i, StringComparer.OrdinalIgnoreCase);
var sortedAudio = options.OnlyAudio
.OrderBy(sub => options.DubLangList.IndexOf(sub.Language.CrLocale) != -1 ? options.DubLangList.IndexOf(sub.Language.CrLocale) : int.MaxValue)
.OrderBy(m => {
var key = m.Language?.CrLocale ?? string.Empty;
return rank.TryGetValue(key, out var r) ? r : int.MaxValue; // unknown locales last
})
.ThenBy(m => m.IsAudioRoleDescription) // false first, then true
.ToList();
foreach (var aud in sortedAudio){
string trackName = aud.Language.Name;
string trackName = aud.Language.Name + (aud.IsAudioRoleDescription ? " [AD]" : "");
args.Add("--audio-tracks 0");
args.Add("--no-video");
args.Add($"--track-name 0:\"{trackName}\"");
args.Add($"--language 0:{aud.Language.Code}");
if (options.Defaults.Audio.Code == aud.Language.Code){
if (options.Defaults.Audio.Code == aud.Language.Code && !aud.IsAudioRoleDescription){
args.Add("--default-track 0");
} else{
args.Add("--default-track 0:0");
@ -192,7 +205,7 @@ public class Merger{
args.Add($"\"{Helpers.AddUncPrefixIfNeeded(aud.Path)}\"");
}
if (options.Subtitles.Count > 0){
if (options.Subtitles.Count > 0 && !options.SkipSubMux){
bool hasSignsSub = options.Subtitles.Any(sub => sub.Signs && options.Defaults.Sub.Code == sub.Language.Code);
var sortedSubtitles = options.Subtitles
@ -262,7 +275,7 @@ public class Merger{
if (options.Description is{ Count: > 0 }){
args.Add($"--global-tags \"{Helpers.AddUncPrefixIfNeeded(options.Description[0].Path)}\"");
}
if (options.Cover.Count > 0){
if (File.Exists(options.Cover.First().Path)){
args.Add($"--attach-file \"{options.Cover.First().Path}\"");
@ -434,14 +447,16 @@ public class Merger{
allMediaFiles.ForEach(file => Helpers.DeleteFile(file.Path + ".new.resume"));
options.Description?.ForEach(description => Helpers.DeleteFile(description.Path));
options.Cover?.ForEach(cover => Helpers.DeleteFile(cover.Path));
// Delete chapter files if any
options.Chapters?.ForEach(chapter => Helpers.DeleteFile(chapter.Path));
// Delete subtitle files
options.Subtitles.ForEach(subtitle => Helpers.DeleteFile(subtitle.File));
if (!options.SkipSubMux){
// Delete subtitle files
options.Subtitles.ForEach(subtitle => Helpers.DeleteFile(subtitle.File));
}
}
}
@ -450,7 +465,7 @@ public class MergerInput{
public LanguageItem Language{ get; set; }
public int? Duration{ get; set; }
public int? Delay{ get; set; }
public bool? IsPrimary{ get; set; }
public bool IsAudioRoleDescription{ get; set; }
public int? Bitrate{ get; set; }
}
@ -474,7 +489,7 @@ public class CrunchyMuxOptions{
public List<string> DubLangList{ get; set; } = new List<string>();
public List<string> SubLangList{ get; set; } = new List<string>();
public string Output{ get; set; }
public bool? SkipSubMux{ get; set; }
public bool SkipSubMux{ get; set; }
public bool? KeepAllVideos{ get; set; }
public bool? Novids{ get; set; }
public bool Mp4{ get; set; }
@ -512,7 +527,7 @@ public class MergerOptions{
public string VideoTitle{ get; set; }
public bool? KeepAllVideos{ get; set; }
public List<ParsedFont> Fonts{ get; set; } = new List<ParsedFont>();
public bool? SkipSubMux{ get; set; }
public bool SkipSubMux{ get; set; }
public MuxOptions Options{ get; set; }
public Defaults Defaults{ get; set; }
public bool mp3{ get; set; }

View file

@ -51,6 +51,7 @@ public class VideoItem: VideoPlaylist{
public class AudioItem: AudioPlaylist{
public string resolutionText{ get; set; }
public string resolutionTextSnap{ get; set; }
}
public class Quality{
@ -63,8 +64,9 @@ public class MPDParsed{
}
public class ServerData{
public List<AudioPlaylist> audio{ get; set; } =[];
public List<VideoPlaylist> video{ get; set; } =[];
public List<string> servers{ get; set; } = [];
public List<AudioPlaylist>? audio{ get; set; } =[];
public List<VideoPlaylist>? video{ get; set; } =[];
}
public static class MPDParser{

View file

@ -29,7 +29,7 @@ public partial class AnilistSeries : ObservableObject{
public string BannerImage{ get; set; }
public bool IsAdult{ get; set; }
public CoverImage CoverImage{ get; set; }
public Trailer Trailer{ get; set; }
public Trailer? Trailer{ get; set; }
public List<ExternalLink>? ExternalLinks{ get; set; }
public List<Ranking> Rankings{ get; set; }
public Studios Studios{ get; set; }
@ -53,6 +53,9 @@ public partial class AnilistSeries : ObservableObject{
}
}
[JsonIgnore]
[ObservableProperty]
public bool _fetchedFromCR;
[JsonIgnore]
public string? CrunchyrollID;

View file

@ -39,9 +39,13 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
public string? SeasonName{ get; set; }
public string? CrSeriesID{ get; set; }
public bool AnilistEpisode{ get; set; }
public bool FilteredOut{ get; set; }
public Locale? AudioLocale{ get; set; }
public List<CalendarEpisode> CalendarEpisodes{ get; set; } =[];
public event PropertyChangedEventHandler? PropertyChanged;
@ -57,13 +61,12 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
await QueueManager.Instance.CrAddEpisodeToQueue(id, Languages.Locale2language(locale).CrLocale, CrunchyrollManager.Instance.CrunOptions.DubLang, true);
}
}
if (CalendarEpisodes.Count > 0){
foreach (var calendarEpisode in CalendarEpisodes){
calendarEpisode.AddEpisodeToQue();
}
}
}
public async Task LoadImage(int width = 0, int height = 0){

View file

@ -10,7 +10,7 @@ public class CrDownloadOptions{
[JsonProperty("shutdown_when_queue_empty")]
public bool ShutdownWhenQueueEmpty{ get; set; }
[JsonProperty("auto_download")]
public bool AutoDownload{ get; set; }
@ -22,22 +22,25 @@ public class CrDownloadOptions{
[JsonProperty("retry_delay")]
public int RetryDelay{ get; set; }
[JsonProperty("retry_attempts")]
public int RetryAttempts{ get; set; }
[JsonIgnore]
public string Force{ get; set; } = "";
[JsonProperty("download_methode_new")]
public bool DownloadMethodeNew{ get; set; }
[JsonProperty("download_allow_early_start")]
public bool DownloadAllowEarlyStart{ get; set; }
[JsonProperty("simultaneous_downloads")]
public int SimultaneousDownloads{ get; set; }
[JsonProperty("simultaneous_processing_jobs")]
public int SimultaneousProcessingJobs{ get; set; }
[JsonProperty("theme")]
public string Theme{ get; set; } = "";
@ -49,11 +52,11 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_play_sound")]
public bool DownloadFinishedPlaySound{ get; set; }
[JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; }
[JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; }
@ -71,9 +74,9 @@ public class CrDownloadOptions{
[JsonProperty("history")]
public bool History{ get; set; }
[JsonProperty("history_count_missing")]
public bool HistoryCountMissing { get; set; }
public bool HistoryCountMissing{ get; set; }
[JsonProperty("history_include_cr_artists")]
public bool HistoryIncludeCrArtists{ get; set; }
@ -131,11 +134,18 @@ public class CrDownloadOptions{
[JsonProperty("proxy_password")]
public string? ProxyPassword{ get; set; }
[JsonProperty("flare_solverr_properties")]
public FlareSolverrProperties? FlareSolverrProperties{ get; set; }
#endregion
#region Crunchyroll Settings
[JsonProperty("cr_download_description_audio")]
public bool DownloadDescriptionAudio{ get; set; }
[JsonProperty("cr_mark_as_watched")]
public bool MarkAsWatched{ get; set; }
@ -144,6 +154,9 @@ public class CrDownloadOptions{
[JsonProperty("hard_sub_lang")]
public string Hslang{ get; set; } = "";
[JsonProperty("hard_sub_raw_fallback")]
public bool HsRawFallback{ get; set; }
[JsonIgnore]
public int Kstream{ get; set; }
@ -165,7 +178,7 @@ public class CrDownloadOptions{
[JsonProperty("file_name_whitespace_substitute")]
public string FileNameWhitespaceSubstitute{ get; set; } = "";
[JsonProperty("file_name")]
public string FileName{ get; set; } = "";
@ -181,6 +194,9 @@ public class CrDownloadOptions{
[JsonIgnore]
public bool SkipSubs{ get; set; }
[JsonProperty("subs_fix_ccc_subs")]
public bool FixCccSubtitles{ get; set; }
[JsonProperty("mux_skip_subs")]
public bool SkipSubsMux{ get; set; }
@ -189,7 +205,7 @@ public class CrDownloadOptions{
[JsonProperty("subs_download_duplicate")]
public bool SubsDownloadDuplicate{ get; set; }
[JsonProperty("include_signs_subs")]
public bool IncludeSignsSubs{ get; set; }
@ -199,6 +215,9 @@ public class CrDownloadOptions{
[JsonProperty("include_cc_subs")]
public bool IncludeCcSubs{ get; set; }
[JsonProperty("convert_cc_vtt_subs_to_ass")]
public bool ConvertVtt2Ass{ get; set; }
[JsonProperty("cc_subs_font")]
public string? CcSubsFont{ get; set; }
@ -207,13 +226,13 @@ public class CrDownloadOptions{
[JsonProperty("mux_mp4")]
public bool Mp4{ get; set; }
[JsonProperty("mux_audio_only_to_mp3")]
public bool AudioOnlyToMp3 { get; set; }
public bool AudioOnlyToMp3{ get; set; }
[JsonProperty("mux_fonts")]
public bool MuxFonts{ get; set; }
[JsonProperty("mux_cover")]
public bool MuxCover{ get; set; }
@ -258,7 +277,7 @@ public class CrDownloadOptions{
[JsonProperty("mux_sync_dubs")]
public bool SyncTiming{ get; set; }
[JsonProperty("mux_sync_hwaccel")]
public string? FfmpegHwAccelFlag{ get; set; }
@ -286,17 +305,14 @@ public class CrDownloadOptions{
[JsonProperty("calendar_hide_dubs")]
public bool CalendarHideDubs{ get; set; }
[JsonProperty("calendar_filter_by_air_date")]
public bool CalendarFilterByAirDate{ get; set; }
[JsonProperty("calendar_show_upcoming_episodes")]
public bool CalendarShowUpcomingEpisodes{ get; set; }
[JsonProperty("stream_endpoint")]
public string? StreamEndpoint{ get; set; }
[JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; }
[JsonProperty("stream_endpoint_secondary_settings")]
public CrAuthSettings? StreamEndpointSecondSettings { get; set; }
public CrAuthSettings? StreamEndpointSecondSettings{ get; set; }
[JsonProperty("search_fetch_featured_music")]
public bool SearchFetchFeaturedMusic{ get; set; }

View file

@ -11,6 +11,9 @@ public class CrProfile{
[JsonProperty("profile_name")]
public string? ProfileName{ get; set; }
[JsonProperty("profile_id")]
public string? ProfileId{ get; set; }
[JsonProperty("preferred_content_audio_language")]
public string? PreferredContentAudioLanguage{ get; set; }

View file

@ -6,6 +6,7 @@ namespace CRD.Utils.Structs.Crunchyroll;
public class CrunchyStreamData{
public string? AssetId{ get; set; }
public Locale? AudioLocale{ get; set; }
public string? AudioRole{ get; set; }
public string? Bifs{ get; set; }
public string? BurnedInLocale{ get; set; }
public Dictionary<string, Caption>? Captions{ get; set; }

View file

@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.Linq;
using System.Text.RegularExpressions;
using System.Threading;
using Avalonia.Media.Imaging;
using CRD.Utils.Structs.Crunchyroll;
using CRD.Utils.Structs.History;
@ -338,6 +339,8 @@ public class EpisodeVersion{
[JsonProperty("season_guid")]
public string SeasonGuid{ get; set; }
public string[] roles{ get; set; } =[];
public string Variant{ get; set; }
}
@ -394,6 +397,8 @@ public class CrunchyEpMeta{
public CrDownloadOptions? DownloadSettings;
public bool HighlightAllAvailable{ get; set; }
public CancellationTokenSource Cts { get; } = new();
}
public class DownloadProgress{
@ -415,6 +420,8 @@ public class CrunchyEpMetaData{
public bool IsSubbed{ get; set; }
public bool IsDubbed{ get; set; }
public bool IsAudioRoleDescription{ get; set; }
public (string? seasonID, string? guid) GetOriginalIds(){
var version = Versions?.FirstOrDefault(a => a.Original);
if (version != null && !string.IsNullOrEmpty(version.Guid) && !string.IsNullOrEmpty(version.SeasonGuid)){

View file

@ -30,6 +30,8 @@ public class StreamDetails{
public class UrlWithAuth{
public CrAuth? CrAuth{ get; set; }
public bool Video{ get; set; }
public bool Audio{ get; set; }
public string? Url{ get; set; }

View file

@ -21,7 +21,7 @@ public class StreamError{
}
public bool IsTooManyActiveStreamsError(){
return Error == "TOO_MANY_ACTIVE_STREAMS";
return Error is "TOO_MANY_ACTIVE_STREAMS" or "TOO_MANY_CONCURRENT_STREAMS";
}
}

View file

@ -18,6 +18,15 @@ public class CrAuthSettings{
public string UserAgent{ get; set; }
public string Device_type{ get; set; }
public string Device_name{ get; set; }
public bool Video{ get; set; }
public bool Audio{ get; set; }
}
public class StreamInfo{
public string Playlist { get; set; }
public bool Audio { get; set; }
public bool Video { get; set; }
}
public class DrmAuthData{
@ -56,8 +65,8 @@ public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool?
}
public class CrunchySeriesList{
public List<Episode> List{ get; set; }
public Dictionary<string, EpisodeAndLanguage> Data{ get; set; }
public List<Episode> List{ get; set; } = [];
public Dictionary<string, EpisodeAndLanguage> Data{ get; set; } = [];
}
public class Episode{

View file

@ -143,13 +143,13 @@ public class HistoryEpisode : INotifyPropertyChanged{
await DownloadEpisode();
}
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default){
public async Task DownloadEpisode(EpisodeDownloadMode episodeDownloadMode = EpisodeDownloadMode.Default, string overrideDownloadPath = ""){
switch (EpisodeType){
case EpisodeType.MusicVideo:
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty);
await QueueManager.Instance.CrAddMusicVideoToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
break;
case EpisodeType.Concert:
await QueueManager.Instance.CrAddConcertToQueue(EpisodeId ?? string.Empty);
await QueueManager.Instance.CrAddConcertToQueue(EpisodeId ?? string.Empty, overrideDownloadPath);
break;
case EpisodeType.Episode:
case EpisodeType.Unknown:

View file

@ -16,6 +16,8 @@ using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll.Music;
using CRD.Views;
using ReactiveUI;
// ReSharper disable InconsistentNaming
@ -210,6 +212,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
} else if (AddAllEpisodes){
var musicClass = CrunchyrollManager.Instance.CrMusic;
if (currentMusicVideoList == null) return;
foreach (var meta in currentMusicVideoList.Data.Select(crunchyMusicVideo => musicClass.EpisodeMeta(crunchyMusicVideo))){
QueueManager.Instance.CrAddMusicMetaToQueue(meta);
}
@ -421,7 +424,9 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
}
CurrentSelectedSeason = SeasonList.First();
if (SeasonList.Count > 0){
CurrentSelectedSeason = SeasonList.First();
}
}
private string DetermineLocale(string locale){
@ -525,15 +530,35 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
var list = await FetchSeriesListAsync(value.Id);
if (list != null){
if (list is{ List.Count: > 0 }){
currentSeriesList = list;
await SearchPopulateEpisodesBySeason(value.Id);
UpdateUiForEpisodeSelection();
} else{
ButtonEnabled = true;
ResetSearch();
MessageBus.Current.SendMessage(new ToastMessage($"Failed to get Episodes for Series", ToastType.Error, 2));
}
}
private void ResetSearch(){
currentMusicVideoList = null;
UrlInput = "";
selectedEpisodes.Clear();
SelectedItems.Clear();
Items.Clear();
currentSeriesList = null;
SeasonList.Clear();
episodesBySeason.Clear();
AllButtonEnabled = false;
AddAllEpisodes = false;
ButtonEnabled = false;
SearchVisible = true;
SlectSeasonVisible = false;
ShowLoading = false;
SearchEnabled = false; // disable and enable for button text
SearchEnabled = true;
}
private void UpdateUiForSearchSelection(){
SearchPopupVisible = false;
RaisePropertyChanged(nameof(SearchVisible));
@ -602,7 +627,9 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
}
CurrentSelectedSeason = SeasonList.First();
if (SeasonList.Count > 0){
CurrentSelectedSeason = SeasonList.First();
}
}
private void UpdateUiForEpisodeSelection(){

View file

@ -7,6 +7,7 @@ using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using DynamicData;
@ -18,7 +19,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _prevButtonEnabled = true;
[ObservableProperty]
private bool _nextButtonEnabled = true;
@ -28,9 +29,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _customCalendar;
[ObservableProperty]
private bool _filterByAirDate;
[ObservableProperty]
private bool _showUpcomingEpisodes;
@ -75,7 +73,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
CustomCalendar = CrunchyrollManager.Instance.CrunOptions.CustomCalendar;
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
FilterByAirDate = CrunchyrollManager.Instance.CrunOptions.CalendarFilterByAirDate;
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes;
ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
@ -87,7 +84,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
}
private string GetThisWeeksMondayDate(){
DateTime today = DateTime.Today;
@ -104,13 +100,12 @@ public partial class CalendarPageViewModel : ViewModelBase{
return formattedDate;
}
public async void LoadCalendar(string mondayDate,DateTime customCalDate, bool forceUpdate){
public async void LoadCalendar(string mondayDate, DateTime customCalDate, bool forceUpdate){
ShowLoading = true;
CalendarWeek week;
if (CustomCalendar){
if (customCalDate.Date == DateTime.Now.Date){
PrevButtonEnabled = false;
NextButtonEnabled = true;
@ -118,7 +113,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
PrevButtonEnabled = true;
NextButtonEnabled = false;
}
week = await CalendarManager.Instance.BuildCustomCalendar(customCalDate, forceUpdate);
} else{
PrevButtonEnabled = true;
@ -140,29 +135,24 @@ public partial class CalendarPageViewModel : ViewModelBase{
foreach (var calendarDayCalendarEpisode in calendarDay.CalendarEpisodes){
if (calendarDayCalendarEpisode.ImageBitmap == null){
if (calendarDayCalendarEpisode.AnilistEpisode){
_ = calendarDayCalendarEpisode.LoadImage(100,150);
_ = calendarDayCalendarEpisode.LoadImage(100, 150);
} else{
_ = calendarDayCalendarEpisode.LoadImage();
}
}
}
}
} else{
foreach (var calendarDay in CalendarDays){
var episodesCopy = new List<CalendarEpisode>(calendarDay.CalendarEpisodes);
foreach (var calendarDayCalendarEpisode in episodesCopy){
if (calendarDayCalendarEpisode.SeasonName != null && HideDubs && calendarDayCalendarEpisode.SeasonName.EndsWith("Dub)")){
calendarDay.CalendarEpisodes.Remove(calendarDayCalendarEpisode);
continue;
}
if (HideDubs)
calendarDay.CalendarEpisodes.RemoveAll(e => CrSimulcastCalendarFilter.IsDubOrAltLanguageSeason(e.SeasonName));
if (calendarDayCalendarEpisode.ImageBitmap == null){
if (calendarDayCalendarEpisode.AnilistEpisode){
_ = calendarDayCalendarEpisode.LoadImage(100,150);
} else{
_ = calendarDayCalendarEpisode.LoadImage();
}
foreach (var e in calendarDay.CalendarEpisodes){
if (e.ImageBitmap == null){
if (e.AnilistEpisode)
_ = e.LoadImage(100, 150);
else
_ = e.LoadImage();
}
}
}
@ -199,7 +189,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
refreshDate = currentWeek.FirstDayOfWeek.AddDays(6);
}
LoadCalendar(mondayDate,refreshDate, true);
LoadCalendar(mondayDate, refreshDate, true);
}
[RelayCommand]
@ -215,13 +205,13 @@ public partial class CalendarPageViewModel : ViewModelBase{
} else{
mondayDate = GetThisWeeksMondayDate();
}
var refreshDate = DateTime.Now;
if (currentWeek?.FirstDayOfWeek != null && currentWeek.FirstDayOfWeek != DateTime.MinValue){
refreshDate = currentWeek.FirstDayOfWeek.AddDays(-1);
}
LoadCalendar(mondayDate,refreshDate, false);
LoadCalendar(mondayDate, refreshDate, false);
}
[RelayCommand]
@ -237,15 +227,13 @@ public partial class CalendarPageViewModel : ViewModelBase{
} else{
mondayDate = GetThisWeeksMondayDate();
}
var refreshDate = DateTime.Now;
if (currentWeek?.FirstDayOfWeek != null && currentWeek.FirstDayOfWeek != DateTime.MinValue){
refreshDate = currentWeek.FirstDayOfWeek.AddDays(13);
}
LoadCalendar(mondayDate,refreshDate, false);
LoadCalendar(mondayDate, refreshDate, false);
}
@ -268,7 +256,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.CustomCalendar = value;
LoadCalendar(GetThisWeeksMondayDate(),DateTime.Now, true);
LoadCalendar(GetThisWeeksMondayDate(), DateTime.Now, true);
CfgManager.WriteCrSettings();
}
@ -282,15 +270,6 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings();
}
partial void OnFilterByAirDateChanged(bool value){
if (loading){
return;
}
CrunchyrollManager.Instance.CrunOptions.CalendarFilterByAirDate = value;
CfgManager.WriteCrSettings();
}
partial void OnShowUpcomingEpisodesChanged(bool value){
if (loading){
return;
@ -310,7 +289,4 @@ public partial class CalendarPageViewModel : ViewModelBase{
CfgManager.WriteCrSettings();
}
}
}

View file

@ -270,6 +270,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
CrunchyEpMeta? downloadItem = QueueManager.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null;
if (downloadItem != null){
QueueManager.Instance.Queue.Remove(downloadItem);
epMeta.Cts.Cancel();
if (!Done){
foreach (var downloadItemDownloadedFile in downloadItem.downloadedFiles){
try{

View file

@ -125,7 +125,7 @@ public partial class SeriesPageViewModel : ViewModelBase{
FullSizeDesired = true
};
var viewModel = new ContentDialogFeaturedMusicViewModel(dialog, musicList, CrunchyrollManager.Instance.CrunOptions.HistoryIncludeCrArtists);
var viewModel = new ContentDialogFeaturedMusicViewModel(dialog, musicList, CrunchyrollManager.Instance.CrunOptions.HistoryIncludeCrArtists, SelectedSeries.SeriesFolderPathExists ? SelectedSeries.SeriesFolderPath : "");
dialog.Content = new ContentDialogFeaturedMusicView(){
DataContext = viewModel
};

View file

@ -7,6 +7,7 @@ using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Collections;
using Avalonia.Threading;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
@ -17,6 +18,7 @@ using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.History;
using CRD.Views;
using FluentAvalonia.UI.Data;
using Newtonsoft.Json;
using ReactiveUI;
@ -149,6 +151,9 @@ public partial class UpcomingPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _quickAddMode;
[ObservableProperty]
private static bool _showCrFetches;
[ObservableProperty]
private bool _isLoading;
@ -168,7 +173,7 @@ public partial class UpcomingPageViewModel : ViewModelBase{
public ObservableCollection<SeasonViewModel> Seasons{ get; set; } =[];
public ObservableCollection<AnilistSeries> SelectedSeason{ get; set; } =[];
private SeasonViewModel currentSelection;
public UpcomingPageViewModel(){
@ -198,11 +203,11 @@ public partial class UpcomingPageViewModel : ViewModelBase{
currentSelection = Seasons.Last();
currentSelection.IsSelected = true;
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false);
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false, crunchySimul);
SelectedSeason.Clear();
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
foreach (var anilistSeries in list){
SelectedSeason.Add(anilistSeries);
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
@ -214,6 +219,8 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
}
FilterItems();
SortItems();
}
@ -223,11 +230,11 @@ public partial class UpcomingPageViewModel : ViewModelBase{
currentSelection = selectedSeason;
currentSelection.IsSelected = true;
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false);
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
var list = await GetSeriesForSeason(currentSelection.Season, currentSelection.Year, false, crunchySimul);
SelectedSeason.Clear();
var crunchySimul = await CrunchyrollManager.Instance.CrSeries.GetSeasonalSeries(currentSelection.Season, currentSelection.Year + "", "");
foreach (var anilistSeries in list){
SelectedSeason.Add(anilistSeries);
if (!string.IsNullOrEmpty(anilistSeries.CrunchyrollID) && crunchySimul?.Data is{ Count: > 0 }){
@ -238,17 +245,24 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
}
}
FilterItems();
SortItems();
}
[RelayCommand]
public void OpenTrailer(AnilistSeries series){
if (series.Trailer.Site.Equals("youtube")){
var url = "https://www.youtube.com/watch?v=" + series.Trailer.Id; // Replace with your video URL
Process.Start(new ProcessStartInfo{
FileName = url,
UseShellExecute = true
});
if (series.Trailer != null){
if (series.Trailer.Site.Equals("youtube")){
var url = "https://www.youtube.com/watch?v=" + series.Trailer.Id;
Process.Start(new ProcessStartInfo{
FileName = url,
UseShellExecute = true
});
}
}
}
@ -258,7 +272,7 @@ public partial class UpcomingPageViewModel : ViewModelBase{
MessageBus.Current.SendMessage(new ToastMessage($"History still loading", ToastType.Warning, 3));
return;
}
if (!string.IsNullOrEmpty(series.CrunchyrollID)){
if (CrunchyrollManager.Instance.CrunOptions.History){
series.IsInHistory = true;
@ -280,42 +294,48 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
}
private async Task<List<AnilistSeries>> GetSeriesForSeason(string season, int year, bool forceRefresh){
private async Task<List<AnilistSeries>> GetSeriesForSeason(string season, int year, bool forceRefresh, CrBrowseSeriesBase? crBrowseSeriesBase){
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(season + year) && !forceRefresh){
return ProgramManager.Instance.AnilistSeasons[season + year];
}
IsLoading = true;
var variables = new{
season,
year,
format = "TV",
page = 1
};
var allMedia = new List<AnilistSeries>();
var page = 1;
var maxPage = 10;
bool hasNext;
var payload = new{
query,
variables
};
do{
var payload = new{
query,
variables = new{ season, year, page }
};
string jsonPayload = JsonConvert.SerializeObject(payload, Formatting.Indented);
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Anilist);
request.Content = new StringContent(JsonConvert.SerializeObject(payload, Formatting.Indented),
Encoding.UTF8, "application/json");
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Anilist);
request.Content = new StringContent(jsonPayload, Encoding.UTF8, "application/json");
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine($"Anilist Request Failed for {season} {year} (page {page})");
break;
}
var response = await HttpClientReq.Instance.SendHttpRequest(request);
var ani = Helpers.Deserialize<AniListResponse>(
response.ResponseContent,
CrunchyrollManager.Instance.SettingsJsonSerializerSettings
) ?? new AniListResponse();
if (!response.IsOk){
Console.Error.WriteLine($"Anilist Request Failed for {season} {year}");
return[];
}
var pageNode = ani.Data?.Page;
var media = pageNode?.Media ?? new List<AnilistSeries>();
allMedia.AddRange(media);
AniListResponse aniListResponse = Helpers.Deserialize<AniListResponse>(response.ResponseContent, CrunchyrollManager.Instance.SettingsJsonSerializerSettings) ?? new AniListResponse();
var list = aniListResponse.Data?.Page?.Media ??[];
list = list.Where(ele => ele.ExternalLinks != null && ele.ExternalLinks.Any(external =>
hasNext = pageNode?.PageInfo?.HasNextPage ?? false;
page++;
} while (hasNext || page <= maxPage);
var list = allMedia.Where(ele => ele.ExternalLinks != null && ele.ExternalLinks.Any(external =>
string.Equals(external.Site, "Crunchyroll", StringComparison.OrdinalIgnoreCase))).ToList();
@ -324,6 +344,8 @@ public partial class UpcomingPageViewModel : ViewModelBase{
anilistEle.Description = anilistEle.Description
.Replace("<i>", "")
.Replace("</i>", "")
.Replace("<b>", "")
.Replace("</b>", "")
.Replace("<BR>", "")
.Replace("<br>", "");
@ -388,6 +410,49 @@ public partial class UpcomingPageViewModel : ViewModelBase{
}
}
var existingIds = list
.Where(a => !string.IsNullOrEmpty(a.CrunchyrollID))
.Select(a => a.CrunchyrollID!)
.ToHashSet(StringComparer.Ordinal);
var notInList = (crBrowseSeriesBase?.Data ?? Enumerable.Empty<CrBrowseSeries>())
.ExceptBy(existingIds, cs => cs.Id, StringComparer.Ordinal)
.ToList();
foreach (var crBrowseSeries in notInList){
var newAnlistObject = new AnilistSeries();
newAnlistObject.Title = new Title();
newAnlistObject.Title.English = crBrowseSeries.Title ?? "";
newAnlistObject.Description = crBrowseSeries.Description ?? "";
newAnlistObject.CoverImage = new CoverImage();
int targetW = 240, targetH = 360;
string posterSrc =
crBrowseSeries.Images.PosterTall.FirstOrDefault()?
.OrderBy(i => Math.Abs(i.Width - targetW) + Math.Abs(i.Height - targetH))
.ThenBy(i => Math.Abs((i.Width / (double)i.Height) - (targetW / (double)targetH)))
.Select(i => i.Source)
.FirstOrDefault(s => !string.IsNullOrEmpty(s))
?? crBrowseSeries.Images.PosterTall.FirstOrDefault()?.FirstOrDefault()?.Source
?? string.Empty;
newAnlistObject.CoverImage.ExtraLarge = posterSrc;
newAnlistObject.ThumbnailImage = await Helpers.LoadImage(newAnlistObject.CoverImage.ExtraLarge, 185, 265);
newAnlistObject.ExternalLinks = new List<ExternalLink>();
newAnlistObject.ExternalLinks.Add(new ExternalLink(){ Url = $"https://www.crunchyroll.com/series/{crBrowseSeries.Id}/{crBrowseSeries.SlugTitle}" });
newAnlistObject.FetchedFromCR = true;
newAnlistObject.HasCrID = true;
newAnlistObject.CrunchyrollID = crBrowseSeries.Id;
if (CrunchyrollManager.Instance.CrunOptions.History){
var historyIDs = new HashSet<string>(CrunchyrollManager.Instance.HistoryList.Select(item => item.SeriesId ?? ""));
if (newAnlistObject.CrunchyrollID != null && historyIDs.Contains(newAnlistObject.CrunchyrollID)){
newAnlistObject.IsInHistory = true;
}
}
list.Add(newAnlistObject);
}
ProgramManager.Instance.AnilistSeasons[season + year] = list;
@ -447,6 +512,11 @@ public partial class UpcomingPageViewModel : ViewModelBase{
partial void OnSelectedSeriesChanged(AnilistSeries? value){
SelectionChangedOfSeries(value);
}
partial void OnShowCrFetchesChanged(bool value){
FilterItems();
SortItems();
}
#region Sorting
@ -512,6 +582,24 @@ public partial class UpcomingPageViewModel : ViewModelBase{
SelectedSeason.Add(item);
}
}
private void FilterItems(){
List<AnilistSeries> filteredList;
if (ProgramManager.Instance.AnilistSeasons.ContainsKey(currentSelection.Season + currentSelection.Year)){
filteredList = ProgramManager.Instance.AnilistSeasons[currentSelection.Season + currentSelection.Year];
} else{
return;
}
filteredList = !ShowCrFetches ? filteredList.Where(e => !e.FetchedFromCR).ToList() : filteredList.ToList();
SelectedSeason.Clear();
foreach (var item in filteredList){
SelectedSeason.Add(item);
}
}
#endregion
}

View file

@ -5,6 +5,7 @@ using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Structs.Crunchyroll.Music;
using CRD.Utils.Structs.History;
using CRD.Utils.UI;
@ -23,11 +24,13 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{
private bool _musicInHistory;
private CrunchyMusicVideoList featuredMusic;
private string FolderPath = "";
public ContentDialogFeaturedMusicViewModel(CustomContentDialog contentDialog, CrunchyMusicVideoList featuredMusic, bool crunOptionsHistoryIncludeCrArtists){
public ContentDialogFeaturedMusicViewModel(CustomContentDialog contentDialog, CrunchyMusicVideoList featuredMusic, bool crunOptionsHistoryIncludeCrArtists, string overrideDownloadPath = ""){
ArgumentNullException.ThrowIfNull(contentDialog);
this.featuredMusic = featuredMusic;
this.FolderPath = overrideDownloadPath + "/OST";
dialog = contentDialog;
dialog.Closed += DialogOnClosed;
@ -78,7 +81,7 @@ public partial class ContentDialogFeaturedMusicViewModel : ViewModelBase{
[RelayCommand]
public void DownloadEpisode(HistoryEpisode episode){
episode.DownloadEpisode();
episode.DownloadEpisode(EpisodeDownloadMode.Default, FolderPath);
}
private void SaveButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){

View file

@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Downloader.Crunchyroll;
using CRD.Utils.Structs;
@ -9,15 +10,19 @@ namespace CRD.ViewModels.Utils;
public partial class ContentDialogInputLoginViewModel : ViewModelBase{
private readonly ContentDialog dialog;
private readonly TaskCompletionSource<bool> _loginTcs = new();
public Task LoginCompleted => _loginTcs.Task;
[ObservableProperty]
private string _email;
[ObservableProperty]
private string _password;
private AccountPageViewModel accountPageViewModel;
private AccountPageViewModel? accountPageViewModel;
public ContentDialogInputLoginViewModel(ContentDialog dialog, AccountPageViewModel accountPageViewModel = null){
public ContentDialogInputLoginViewModel(ContentDialog dialog, AccountPageViewModel? accountPageViewModel = null){
if (dialog is null){
throw new ArgumentNullException(nameof(dialog));
}
@ -30,15 +35,19 @@ public partial class ContentDialogInputLoginViewModel : ViewModelBase{
private async void LoginButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){
dialog.PrimaryButtonClick -= LoginButton;
await CrunchyrollManager.Instance.CrAuthEndpoint1.Auth(new AuthData{Password = Password,Username = Email});
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint2.AuthSettings.Endpoint)){
await CrunchyrollManager.Instance.CrAuthEndpoint2.Auth(new AuthData{Password = Password,Username = Email});
}
try{
await CrunchyrollManager.Instance.CrAuthEndpoint1.Auth(new AuthData{ Password = Password, Username = Email });
if (!string.IsNullOrEmpty(CrunchyrollManager.Instance.CrAuthEndpoint2.AuthSettings.Endpoint)){
await CrunchyrollManager.Instance.CrAuthEndpoint2.Auth(new AuthData{ Password = Password, Username = Email });
}
accountPageViewModel?.UpdatetProfile();
if (accountPageViewModel != null){
accountPageViewModel.UpdatetProfile();
_loginTcs.TrySetResult(true);
} catch (Exception ex){
_loginTcs.TrySetException(ex);
}
}
private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){

View file

@ -54,9 +54,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private double? _simultaneousDownloads;
[ObservableProperty]
private double? _simultaneousProcessingJobs;
[ObservableProperty]
private bool _downloadMethodeNew;
[ObservableProperty]
private bool _downloadAllowEarlyStart;
@ -204,6 +207,18 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private string _proxyPassword;
[ObservableProperty]
private string _flareSolverrHost = "localhost";
[ObservableProperty]
private string _flareSolverrPort = "8191";
[ObservableProperty]
private bool _flareSolverrUseSsl = false;
[ObservableProperty]
private bool _useFlareSolverr = false;
[ObservableProperty]
private string _tempDownloadDirPath;
@ -261,6 +276,15 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
SonarrPort = props.Port + "";
SonarrApiKey = props.ApiKey + "";
}
var propsFlareSolverr = options.FlareSolverrProperties;
if (propsFlareSolverr != null){
FlareSolverrUseSsl = propsFlareSolverr.UseSsl;
UseFlareSolverr = propsFlareSolverr.UseFlareSolverr;
FlareSolverrHost = propsFlareSolverr.Host + "";
FlareSolverrPort = propsFlareSolverr.Port + "";
}
ProxyEnabled = options.ProxyEnabled;
ProxySocks = options.ProxySocks;
@ -280,6 +304,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
DownloadToTempFolder = options.DownloadToTempFolder;
SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
LogMode = options.LogMode;
ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
@ -320,6 +345,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.HistoryCountSonarr = HistoryCountSonarr;
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10);
QueueManager.Instance.SetLimit(settings.SimultaneousProcessingJobs);
settings.ProxyEnabled = ProxyEnabled;
settings.ProxySocks = ProxySocks;
@ -355,9 +383,22 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
props.ApiKey = SonarrApiKey;
settings.SonarrProperties = props;
var propsFlareSolverr = new FlareSolverrProperties();
propsFlareSolverr.UseSsl = FlareSolverrUseSsl;
propsFlareSolverr.UseFlareSolverr = UseFlareSolverr;
propsFlareSolverr.Host = FlareSolverrHost;
if (int.TryParse(FlareSolverrPort, out var portNumberFlare)){
propsFlareSolverr.Port = portNumberFlare;
} else{
propsFlareSolverr.Port = 8989;
}
settings.FlareSolverrProperties = propsFlareSolverr;
settings.LogMode = LogMode;

View file

@ -96,9 +96,6 @@
SelectedItem="{Binding CurrentCalendarDubFilter}"
ItemsSource="{Binding CalendarDubFilter}">
</ComboBox>
<CheckBox IsChecked="{Binding FilterByAirDate}"
Content="Filter by episode air date" Margin="5 5 0 0">
</CheckBox>
<CheckBox IsChecked="{Binding ShowUpcomingEpisodes}"
Content="Show Upcoming episodes" Margin="5 5 0 0">
</CheckBox>
@ -109,11 +106,16 @@
</controls:SettingsExpander>
<controls:SettingsExpander Header="Calendar ">
<controls:SettingsExpander Header="Calendar " IsVisible="{Binding !CustomCalendar}">
<controls:SettingsExpander.Footer>
<CheckBox IsChecked="{Binding HideDubs}"
Content="Hide Dubs" Margin="5 0 0 0">
</CheckBox>
<StackPanel Orientation="Vertical">
<CheckBox IsChecked="{Binding HideDubs}"
Content="Hide Dubs" Margin="5 0 0 0">
</CheckBox>
<CheckBox IsChecked="{Binding ShowUpcomingEpisodes}"
Content="Show Upcoming episodes" Margin="5 5 0 0">
</CheckBox>
</StackPanel>
</controls:SettingsExpander.Footer>
</controls:SettingsExpander>
@ -232,8 +234,7 @@
<Button HorizontalAlignment="Center" Content="Download"
IsEnabled="{Binding HasPassed}"
IsVisible="{Binding HasPassed}"
Command="{Binding AddEpisodeToQue}"
/>
Command="{Binding AddEpisodeToQue}" />
</StackPanel>
</Border>
</DataTemplate>

View file

@ -120,7 +120,10 @@ public partial class MainWindow : AppWindow{
.Subscribe(message => ShowToast(message.Message ?? string.Empty, message.Type, message.Seconds));
}
public async void ShowError(string message, bool githubWikiButton = false){
//ffmpeg - https://github.com/GyanD/codexffmpeg/releases/latest
//mkvmerge - https://mkvtoolnix.download/downloads.html#windows
//git wiki - https://github.com/Crunchy-DL/Crunchy-Downloader/wiki
public async void ShowError(string message, string urlButtonText = "", string url = ""){
if (activeErrors.Contains(message))
return;
@ -132,14 +135,14 @@ public partial class MainWindow : AppWindow{
CloseButtonText = "Close"
};
if (githubWikiButton){
dialog.PrimaryButtonText = "Github Wiki";
if (!string.IsNullOrEmpty(urlButtonText)){
dialog.PrimaryButtonText = urlButtonText;
}
var result = await dialog.ShowAsync();
if (result == ContentDialogResult.Primary){
Helpers.OpenUrl($"https://github.com/Crunchy-DL/Crunchy-Downloader/wiki");
Helpers.OpenUrl(url);
}
activeErrors.Remove(message);

View file

@ -59,6 +59,19 @@
<StackPanel Grid.Column="1" Margin="10" HorizontalAlignment="Right" VerticalAlignment="Center" Orientation="Horizontal">
<ToggleButton Width="50" Height="50" BorderThickness="0" Margin="0 0"
VerticalAlignment="Center"
IsChecked="{Binding ShowCrFetches}"
IsEnabled="{Binding !IsLoading}">
<StackPanel Orientation="Vertical">
<controls:ImageIcon Source="../Assets/crunchy_icon_round.png" Width="25" Height="25" />
<TextBlock Text="CR" TextWrapping="Wrap" HorizontalAlignment="Center" FontSize="12"></TextBlock>
<ToolTip.Tip>
<TextBlock Text="Fetch Crunchyroll shows and append missing entries. This may create duplicates" FontSize="15" />
</ToolTip.Tip>
</StackPanel>
</ToggleButton>
<ToggleButton Width="50" Height="50" BorderThickness="0" Margin="5 0"
VerticalAlignment="Center"
IsChecked="{Binding QuickAddMode}"
@ -149,7 +162,7 @@
<ListBox.ItemTemplate>
<DataTemplate>
<Grid>
<Grid >
<Grid.ColumnDefinitions>
<ColumnDefinition Width="185" />
@ -268,7 +281,7 @@
</Grid>
<StackPanel Orientation="Horizontal" HorizontalAlignment="Center" VerticalAlignment="Bottom">
<Button HorizontalAlignment="Right" VerticalAlignment="Bottom" Content="Trailer" Margin=" 0 0 5 0"
<Button HorizontalAlignment="Right" VerticalAlignment="Bottom" IsVisible="{Binding Trailer}" Content="Trailer" Margin=" 0 0 5 0"
Command="{Binding $parent[UserControl].((vm:UpcomingPageViewModel)DataContext).OpenTrailer}"
CommandParameter="{Binding}">
</Button>

View file

@ -75,7 +75,7 @@
<CheckBox IsChecked="{Binding DownloadMethodeNew}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Allow early start of next download" Description="When enabled, the next download starts as soon as the previous file has finished downloading, even if it is still being finalized (muxed/moved).">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding DownloadAllowEarlyStart}"> </CheckBox>
@ -178,7 +178,7 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Simultaneous Downloads">
<controls:SettingsExpanderItem Content="Parallel Downloads">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0" Maximum="10"
Value="{Binding SimultaneousDownloads}"
@ -187,6 +187,15 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding DownloadAllowEarlyStart}" Content="Parallel Processing Jobs" Description="The maximum number of completed downloads that can be processed simultaneously (encoding, muxing, moving)">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0" Maximum="10"
Value="{Binding SimultaneousProcessingJobs}"
SpinButtonPlacementMode="Inline"
HorizontalAlignment="Stretch" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished">
<controls:SettingsExpanderItem.Footer>
@ -376,6 +385,40 @@
</controls:SettingsExpander>
<controls:SettingsExpander Header="Flare Solverr Settings"
IconSource="Wifi2"
Description="FlareSolverr settings (used only for the Calendar)"
IsExpanded="False">
<controls:SettingsExpanderItem Content="Use Flare Solverr">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding UseFlareSolverr}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Host">
<controls:SettingsExpanderItem.Footer>
<TextBox HorizontalAlignment="Left" MinWidth="250"
Text="{Binding FlareSolverrHost}" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Port">
<controls:SettingsExpanderItem.Footer>
<TextBox HorizontalAlignment="Left" MinWidth="250"
Text="{Binding FlareSolverrPort}" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Use SSL">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding FlareSolverrUseSsl}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
<controls:SettingsExpander Header="App Appearance"
IconSource="DarkTheme"
Description="Customize the look and feel of the application"
@ -608,7 +651,7 @@
DockPanel.Dock="Left" />
<ColorPicker Color="{Binding CustomAccentColor}"
DockPanel.Dock="Right" />
DockPanel.Dock="Right" />
</DockPanel>
</StackPanel>
</controls:SettingsExpanderItem.Footer>

View file

@ -29,7 +29,7 @@ A simple crunchyroll downloader that allows you to download your favorite series
## 🛠️ System Requirements
- **Operating System:** Windows 10 or Windows 11
- **.NET Desktop Runtime:** Version 8.0
- **.NET Desktop Runtime:** Version 10.0
- **Visual C++ Redistributable:** 20152022
## 🖥️ Features
@ -50,7 +50,7 @@ For detailed information on each feature, please refer to the [GitHub Wiki](http
Place one of the following tools in the `./lib/` directory:
- **mp4decrypt:** Available at [Bento4](https://www.bento4.com/downloads/)
- **shaka-packager:** Available at [Shaka Packager Releases](https://github.com/shaka-project/shaka-packager/releases/latest)
- **shaka-packager:** Available at [Shaka Packager Releases](https://github.com/shaka-project/shaka-packager/releases/latest) ⚠️ Version 3.x is not working correctly — use v2.6.1 instead
### 2. Acquire Widevine CDM Files