Compare commits

...

4 commits

Author SHA1 Message Date
Elwador
795d82fb80 - Changed **history partial download icon** to show what dubs/subs are missing on hover
- Changed **history dub tooltips** to display available subtitle languages
- Changed **partial download indicators** to only show as orange when available selected dubs/subs are still missing
- Fixed **changelog path** on Linux
- Fixed **manual "Mark as Downloaded"** in history not resetting downloaded dub/sub tracking, which could incorrectly mark episodes as partially downloaded again
- Fixed **syncing videos with different scales** causing "unable to sync" errors
- Fixed **special season detection** incorrectly identifying some regular seasons as specials
2026-06-14 19:11:43 +02:00
Elwador
245cf787ea - Added setting to **disable partial history tracking**
- Changed **playback error reason parsing and display**
- Changed **queue refreshing and saving** to improve performance
- Fixed **Fast History Refresh** not working correctly
- Fixed **calendar not loading**
- Fixed **partial history tracking** being overwritten on each download
2026-06-10 22:01:01 +02:00
Elwador
b7b10daf85 - Added **calendar history marks**
- Added **proxy setting**
- Added **Add Download tab settings** for auto-adding history, instant episode adding, and default search
- Added toggle to only delay between **episodes**, not between episodes and dubs
- Changed **URL parsing** in the Add Download tab
- Changed **history episode sorting** to allow multi-episode ranges like `E11-12`
- Improved **calendar loading speed**
- Changed message for missing **FFmpeg** and **mkvmerge**
2026-06-05 15:42:19 +02:00
Elwador
c123093e21 - Added general setting to **replace existing output files** instead of creating numbered copies
- Added **default video setting**
- Changed **history tracking** to include downloaded dub and subtitle languages for each episode
- Changed **downloaded episode indicators** to show partial download status when selected dubs or subtitles are still missing
- Changed **history and series views** to show downloaded dub/subtitle languages in tooltips
- Changed **fast history refresh** to also update available dub and subtitle metadata for existing episodes
- Changed **partially downloaded episode handling** so episodes with newly available selected dubs/subs are counted as actionable
- Changed **persisted queue auto-download** to wait until login/init is complete
- Changed **history search popup** to focus the search input when opened
- Improved **Sonarr episode matching** to preserve valid matches and avoid duplicate assignments
- Fixed **cover attachment race condition** during parallel muxing
- Fixed **missing cover attachments** causing mkvmerge command building to fail
- Fixed **Sonarr episode matching** when history was updated from the calendar
2026-05-25 15:44:43 +02:00
43 changed files with 2703 additions and 931 deletions

View file

@ -7,6 +7,7 @@ using System.Net.Http;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Threading;
using CRD.Downloader.Crunchyroll;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
@ -25,6 +26,10 @@ public class CalendarManager{
#region Calendar Variables
private Dictionary<string, CalendarWeek> calendar = new();
private DateTime? anilistUpcomingLoadedDate;
private static readonly Regex GenericSeasonLabelRegex = new(
@"^(?<word>\p{L}+(?:[\p{L}\p{Mn}'\.\- ]*\p{L})?)\s+(?<n>\d+)$",
RegexOptions.CultureInvariant | RegexOptions.Compiled);
private Dictionary<string, string> calendarLanguage = new(){
{ "en-us", "https://www.crunchyroll.com/simulcastcalendar" },
@ -67,6 +72,7 @@ public class CalendarManager{
public async Task<CalendarWeek> GetCalendarForDate(string weeksMondayDate, bool forceUpdate){
if (!forceUpdate && calendar.TryGetValue(weeksMondayDate, out var forDate)){
RefreshHistoryStatuses(forDate);
return forDate;
}
@ -197,6 +203,7 @@ public class CalendarManager{
}
calendar[weeksMondayDate] = week;
RefreshHistoryStatuses(week);
return week;
@ -204,15 +211,15 @@ public class CalendarManager{
public async Task<CalendarWeek> BuildCustomCalendar(DateTime calTargetDate, bool forceUpdate){
if (!forceUpdate && calendar.TryGetValue("C" + calTargetDate.ToString("yyyy-MM-dd"), out var forDate)){
var crunInstance = CrunchyrollManager.Instance;
var crunOptions = crunInstance.CrunOptions;
var calendarKey = "C" + calTargetDate.ToString("yyyy-MM-dd");
if (!forceUpdate && calendar.TryGetValue(calendarKey, out var forDate)){
RefreshHistoryStatuses(forDate);
return forDate;
}
if (CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes){
await LoadAnilistUpcoming();
}
CalendarWeek week = new CalendarWeek();
week.CalendarDays = new List<CalendarDay>();
@ -233,20 +240,34 @@ public class CalendarManager{
var firstDayOfWeek = week.CalendarDays.First().DateTime;
week.FirstDayOfWeek = firstDayOfWeek;
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
var calendarDaysByDate = week.CalendarDays.ToDictionary(day => day.DateTime.Date);
var episodeMergeIndexByDate = week.CalendarDays.ToDictionary(
day => day.DateTime.Date,
_ => new Dictionary<(string? CrSeriesID, Locale? AudioLocale), CalendarEpisode>());
Task anilistUpcomingTask = crunOptions.CalendarShowUpcomingEpisodes
? LoadAnilistUpcoming()
: Task.CompletedTask;
Task<CrBrowseEpisodeBase?> newEpisodesTask = crunInstance.CrEpisode.GetNewEpisodes(crunOptions.HistoryLang, 2000, firstDayOfWeek, true);
await Task.WhenAll(anilistUpcomingTask, newEpisodesTask);
var newEpisodesBase = await newEpisodesTask;
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data ?? [];
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception e){
Console.Error.WriteLine("Failed to update History from calendar");
}
if (crunOptions.UpdateHistoryFromCalendar){
QueueHistoryUpdateFromCalendar(newEpisodes);
}
DateTime now = DateTime.Now;
DateTime nowDate = now.Date;
DateTime oneYearFromNow = now.AddYears(1);
var dubFilter = crunOptions.CalendarDubFilter;
bool hasDubFilter = !string.IsNullOrEmpty(dubFilter) && dubFilter != "none";
string? historyLang = crunOptions.HistoryLang;
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false;
@ -258,9 +279,6 @@ public class CalendarManager{
? crBrowseEpisode.EpisodeMetadata.PremiumAvailableDate.ToLocalTime()
: crBrowseEpisode.EpisodeMetadata.PremiumAvailableDate;
DateTime now = DateTime.Now;
DateTime oneYearFromNow = now.AddYears(1);
DateTime targetDate;
@ -279,53 +297,52 @@ public class CalendarManager{
}
var dubFilter = CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter;
if (CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs && crBrowseEpisode.EpisodeMetadata.SeasonTitle != null &&
if (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))){
(!hasDubFilter || (crBrowseEpisode.EpisodeMetadata.AudioLocale != null && crBrowseEpisode.EpisodeMetadata.AudioLocale.GetEnumMemberValue() != dubFilter))){
//|| crBrowseEpisode.EpisodeMetadata.AudioLocale != Locale.JaJp
filtered = true;
}
if (!string.IsNullOrEmpty(dubFilter) && dubFilter != "none"){
if (hasDubFilter){
if (crBrowseEpisode.EpisodeMetadata.AudioLocale != null && crBrowseEpisode.EpisodeMetadata.AudioLocale.GetEnumMemberValue() != dubFilter){
filtered = true;
}
}
var calendarDay = (from day in week.CalendarDays
where day.DateTime != DateTime.MinValue && day.DateTime.Date == targetDate.Date
select day).FirstOrDefault();
if (calendarDay != null){
if (calendarDaysByDate.TryGetValue(targetDate.Date, out var calendarDay)){
CalendarEpisode calEpisode = new CalendarEpisode();
string? seasonTitle = string.IsNullOrEmpty(crBrowseEpisode.EpisodeMetadata.SeasonTitle)
? crBrowseEpisode.EpisodeMetadata.SeriesTitle
: LooksLikeGenericSeasonLabel(crBrowseEpisode.EpisodeMetadata.SeasonTitle, crBrowseEpisode.EpisodeMetadata.SeasonNumber)
: LooksLikeGenericSeasonLabel(crBrowseEpisode.EpisodeMetadata.SeasonTitle)
? $"{crBrowseEpisode.EpisodeMetadata.SeriesTitle} {crBrowseEpisode.EpisodeMetadata.SeasonTitle}"
: crBrowseEpisode.EpisodeMetadata.SeasonTitle;
calEpisode.DateTime = targetDate;
calEpisode.HasPassed = DateTime.Now > targetDate;
calEpisode.HasPassed = now > targetDate;
calEpisode.EpisodeName = crBrowseEpisode.Title;
calEpisode.SeriesUrl = $"https://www.crunchyroll.com/{CrunchyrollManager.Instance.CrunOptions.HistoryLang}/series/" + crBrowseEpisode.EpisodeMetadata.SeriesId;
calEpisode.EpisodeUrl = $"https://www.crunchyroll.com/{CrunchyrollManager.Instance.CrunOptions.HistoryLang}/watch/{crBrowseEpisode.Id}/";
calEpisode.SeriesUrl = $"https://www.crunchyroll.com/{historyLang}/series/" + crBrowseEpisode.EpisodeMetadata.SeriesId;
calEpisode.EpisodeUrl = $"https://www.crunchyroll.com/{historyLang}/watch/{crBrowseEpisode.Id}/";
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 = seasonTitle;
calEpisode.EpisodeNumber = crBrowseEpisode.EpisodeMetadata.Episode;
calEpisode.CrSeriesID = crBrowseEpisode.EpisodeMetadata.SeriesId;
calEpisode.CrSeasonID = crBrowseEpisode.EpisodeMetadata.SeasonId;
calEpisode.CrEpisodeID = crBrowseEpisode.Id;
calEpisode.FilteredOut = filtered;
calEpisode.AudioLocale = crBrowseEpisode.EpisodeMetadata.AudioLocale;
calEpisode.Versions = crBrowseEpisode.EpisodeMetadata.versions;
ExtractVersionGuids(calEpisode);
ApplyHistoryStatus(calEpisode);
var existingEpisode = calendarDay.CalendarEpisodes
.FirstOrDefault(e => e.CrSeriesID == calEpisode.CrSeriesID && e.AudioLocale == calEpisode.AudioLocale);
var episodeMergeKey = (calEpisode.CrSeriesID, calEpisode.AudioLocale);
var episodeMergeIndex = episodeMergeIndexByDate[calendarDay.DateTime.Date];
if (existingEpisode != null){
if (episodeMergeIndex.TryGetValue(episodeMergeKey, out var existingEpisode)){
if (!int.TryParse(existingEpisode.EpisodeNumber, out _)){
existingEpisode.EpisodeNumber = "...";
} else{
@ -354,17 +371,18 @@ public class CalendarManager{
}
existingEpisode.CalendarEpisodes.Add(calEpisode);
ApplyMergedHistoryStatus(existingEpisode);
} else{
calendarDay.CalendarEpisodes.Add(calEpisode);
episodeMergeIndex[episodeMergeKey] = calEpisode;
}
}
}
if (CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes){
if (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")];
if (calendarDay.DateTime.Date >= nowDate){
if (ProgramManager.Instance.AnilistUpcoming.TryGetValue(calendarDay.DateTime.ToString("yyyy-MM-dd"), out var list)){
foreach (var calendarEpisode in list.Where(calendarEpisodeAnilist => calendarDay.DateTime.Date.Day == calendarEpisodeAnilist.DateTime.Date.Day)
.Where(calendarEpisodeAnilist =>
@ -396,19 +414,176 @@ public class CalendarManager{
// if (day.CalendarEpisodes != null) day.CalendarEpisodes = day.CalendarEpisodes.OrderBy(e => e.DateTime).ToList();
// }
calendar["C" + calTargetDate.ToString("yyyy-MM-dd")] = week;
calendar[calendarKey] = week;
RefreshHistoryStatuses(week);
return week;
}
private static void QueueHistoryUpdateFromCalendar(List<CrBrowseEpisode> newEpisodes){
Dispatcher.UIThread.Post(async () => {
try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
CfgManager.UpdateHistoryFile();
} catch (Exception){
Console.Error.WriteLine("Failed to update History from calendar");
}
}, DispatcherPriority.Background);
}
private static void ExtractVersionGuids(CalendarEpisode calEpisode){
calEpisode.VersionGuids = calEpisode.Versions?
.Select(version => version.Guid)
.Where(guid => !string.IsNullOrWhiteSpace(guid))
.Select(guid => guid!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList() ?? [];
var originalVersion = calEpisode.Versions?
.FirstOrDefault(version => version.Original);
calEpisode.OriginalEpisodeGuid = originalVersion?.Guid;
calEpisode.OriginalSeasonGuid = originalVersion?.SeasonGuid;
}
private static void RefreshHistoryStatuses(CalendarWeek week){
if (week.CalendarDays == null){
return;
}
foreach (var calendarEpisode in week.CalendarDays.SelectMany(day => day.CalendarEpisodes)){
RefreshHistoryStatus(calendarEpisode);
}
}
private static void RefreshHistoryStatus(CalendarEpisode calendarEpisode){
foreach (var childEpisode in calendarEpisode.CalendarEpisodes){
ApplyHistoryStatus(childEpisode);
}
ApplyHistoryStatus(calendarEpisode);
if (calendarEpisode.CalendarEpisodes.Count > 0){
ApplyMergedHistoryStatus(calendarEpisode);
}
}
private static void ApplyHistoryStatus(CalendarEpisode calEpisode){
calEpisode.ShowHistoryMark = CrunchyrollManager.Instance.CrunOptions.CalendarShowHistoryMark;
calEpisode.HistoryDownloadState = CalendarHistoryDownloadState.None;
calEpisode.IsInHistory = false;
if (!CrunchyrollManager.Instance.CrunOptions.History || string.IsNullOrWhiteSpace(calEpisode.CrSeriesID)){
return;
}
var historySeries = CrunchyrollManager.Instance.HistoryList
.FirstOrDefault(series => string.Equals(series.SeriesId, calEpisode.CrSeriesID, StringComparison.OrdinalIgnoreCase));
if (historySeries == null){
return;
}
calEpisode.IsInHistory = true;
var historyMatch = FindHistoryMatch(historySeries, calEpisode);
if (historyMatch.HistoryEpisode == null){
calEpisode.HistoryDownloadState = CalendarHistoryDownloadState.NotDownloaded;
return;
}
if (!historyMatch.HistoryEpisode.WasDownloaded){
calEpisode.HistoryDownloadState = CalendarHistoryDownloadState.NotDownloaded;
return;
}
if (CrunchyrollManager.Instance.CrunOptions.HistoryCheckPartialDownloads){
var requestedDubs = HistorySeries.GetEffectiveDubLang(historySeries, historyMatch.HistorySeason);
var requestedSoftSubs = HistorySeries.GetEffectiveSoftSubs(historySeries, historyMatch.HistorySeason, historyMatch.HistoryEpisode);
calEpisode.HistoryDownloadState = historyMatch.HistoryEpisode.IsPartiallyDownloaded(requestedDubs, requestedSoftSubs)
? CalendarHistoryDownloadState.PartlyDownloaded
: CalendarHistoryDownloadState.Downloaded;
} else{
calEpisode.HistoryDownloadState = CalendarHistoryDownloadState.Downloaded;
}
}
private static (HistorySeason? HistorySeason, HistoryEpisode? HistoryEpisode) FindHistoryMatch(
HistorySeries historySeries,
CalendarEpisode calEpisode){
var candidateSeasonIds = new[]{
calEpisode.OriginalSeasonGuid,
calEpisode.CrSeasonID
}
.Where(id => !string.IsNullOrWhiteSpace(id))
.Select(id => id!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var candidateEpisodeIds = new List<string>();
if (!string.IsNullOrWhiteSpace(calEpisode.OriginalEpisodeGuid)){
candidateEpisodeIds.Add(calEpisode.OriginalEpisodeGuid);
}
candidateEpisodeIds.AddRange(calEpisode.VersionGuids);
if (!string.IsNullOrWhiteSpace(calEpisode.CrEpisodeID)){
candidateEpisodeIds.Add(calEpisode.CrEpisodeID);
}
candidateEpisodeIds = candidateEpisodeIds
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
foreach (var historySeason in historySeries.Seasons
.Where(historySeason => candidateSeasonIds.Count == 0 || candidateSeasonIds.Contains(historySeason.SeasonId ?? string.Empty, StringComparer.OrdinalIgnoreCase))){
var historyEpisode = historySeason.EpisodesList
.FirstOrDefault(episode => candidateEpisodeIds.Contains(episode.EpisodeId ?? string.Empty, StringComparer.OrdinalIgnoreCase));
if (historyEpisode != null){
return (historySeason, historyEpisode);
}
}
foreach (var historySeason in historySeries.Seasons){
var historyEpisode = historySeason.EpisodesList
.FirstOrDefault(episode => candidateEpisodeIds.Contains(episode.EpisodeId ?? string.Empty, StringComparer.OrdinalIgnoreCase));
if (historyEpisode != null){
return (historySeason, historyEpisode);
}
}
return (null, null);
}
private static void ApplyMergedHistoryStatus(CalendarEpisode calendarEpisode){
var episodes = new[]{ calendarEpisode }
.Concat(calendarEpisode.CalendarEpisodes)
.Where(episode => episode.IsInHistory)
.ToList();
calendarEpisode.IsInHistory = episodes.Count > 0;
if (episodes.Count == 0){
calendarEpisode.HistoryDownloadState = CalendarHistoryDownloadState.None;
} else if (episodes.Any(episode => episode.HistoryDownloadState == CalendarHistoryDownloadState.PartlyDownloaded) ||
episodes.Select(episode => episode.HistoryDownloadState).Distinct().Count() > 1){
calendarEpisode.HistoryDownloadState = CalendarHistoryDownloadState.PartlyDownloaded;
} else{
calendarEpisode.HistoryDownloadState = episodes[0].HistoryDownloadState;
}
}
private async Task LoadAnilistUpcoming(){
DateTime today = DateTime.Today;
string formattedDate = today.ToString("yyyy-MM-dd");
if (ProgramManager.Instance.AnilistUpcoming.ContainsKey(formattedDate)){
if (anilistUpcomingLoadedDate == today || ProgramManager.Instance.AnilistUpcoming.ContainsKey(formattedDate)){
anilistUpcomingLoadedDate = today;
return;
}
@ -447,9 +622,14 @@ public class CalendarManager{
return;
}
AniListResponseCalendar currentResponse = Helpers.Deserialize<AniListResponseCalendar>(
AniListResponseCalendar? currentResponse = Helpers.Deserialize<AniListResponseCalendar>(
response.ResponseContent, CrunchyrollManager.Instance.SettingsJsonSerializerSettings
) ?? new AniListResponseCalendar();
);
if (currentResponse?.Data?.Page == null){
Console.Error.WriteLine("Anilist response could not be parsed for upcoming calendar episodes");
return;
}
aniListResponse ??= currentResponse;
@ -539,6 +719,8 @@ public class CalendarManager{
value.Add(calendarEpisode);
}
anilistUpcomingLoadedDate = today;
}
private static void AdjustReleaseTimeToHistory(CalendarEpisode calEp, string crunchyrollId){
@ -580,22 +762,20 @@ public class CalendarManager{
}
}
private bool LooksLikeGenericSeasonLabel(string? seasonTitle, double? seasonNo){
if (string.IsNullOrWhiteSpace(seasonTitle)) return true;
private bool LooksLikeGenericSeasonLabel(string? seasonTitle){
if (string.IsNullOrWhiteSpace(seasonTitle))
return true;
var t = seasonTitle.Trim();
var m = Regex.Match(t, @"^(?<word>\p{L}+(?:[\p{L}\p{Mn}'\.\- ]*\p{L})?)\s+(?<n>\d+)$",
RegexOptions.CultureInvariant);
var m = GenericSeasonLabelRegex.Match(t);
if (!m.Success) return false;
if (!m.Success)
return false;
if (seasonNo.HasValue &&
double.TryParse(m.Groups["n"].Value, NumberStyles.None, CultureInfo.InvariantCulture, out var n)){
return n == seasonNo.Value;
}
var word = m.Groups["word"].Value.Trim();
return true;
return word.Equals("Season", StringComparison.OrdinalIgnoreCase);
}
#region Query
@ -674,4 +854,4 @@ public class CalendarManager{
}";
#endregion
}
}

View file

@ -23,6 +23,13 @@ using ReactiveUI;
namespace CRD.Downloader.Crunchyroll;
public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings){
private const string UnknownUsername = "???";
private const string DefaultAvatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png";
private const string CrunchyrollDomain = ".crunchyroll.com";
private const string SsoDomain = "sso.crunchyroll.com";
private const string DefaultAudioLanguage = "ja-JP";
private const string DefaultAnonymousSubtitleLanguage = "de-DE";
private static readonly TimeSpan TokenRefreshBuffer = TimeSpan.FromSeconds(60);
public CrToken? Token;
@ -36,86 +43,83 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
public Dictionary<string, CookieCollection> cookieStore = new();
private string authCodeVerifier = string.Empty;
private string authCode = string.Empty;
private bool IsTokenExpiredOrNearExpiry(){
return Token == null || DateTime.Now >= Token.expires - TokenRefreshBuffer;
}
public void Init(){
Profile = new CrProfile{
Username = "???",
Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png",
PreferredContentAudioLanguage = "ja-JP",
PreferredContentSubtitleLanguage = crunInstance.DefaultLocale,
HasPremium = false,
Profile = CreateDefaultProfile(crunInstance.DefaultLocale, false);
}
private static CrProfile CreateDefaultProfile(string subtitleLanguage, bool hasPremium = false){
return new CrProfile{
Username = UnknownUsername,
Avatar = DefaultAvatar,
PreferredContentAudioLanguage = DefaultAudioLanguage,
PreferredContentSubtitleLanguage = subtitleLanguage,
HasPremium = hasPremium,
};
}
private string GetTokenFilePath(){
switch (AuthSettings.Endpoint){
case "tv/samsung":
case "tv/vidaa":
case "tv/android_tv":
return CfgManager.PathCrToken.Replace(".json", "_tv.json");
case "android/phone":
case "android/tablet":
return CfgManager.PathCrToken.Replace(".json", "_android.json");
case "console/switch":
case "console/ps4":
case "console/ps5":
case "console/xbox_one":
return CfgManager.PathCrToken.Replace(".json", "_console.json");
case "---":
return CfgManager.PathCrToken.Replace(".json", "_guest.json");
default:
return CfgManager.PathCrToken;
}
return AuthSettings.Endpoint switch{
"tv/samsung" or "tv/vidaa" or "tv/android_tv" => CfgManager.PathCrToken.Replace(".json", "_tv.json"),
"android/phone" or "android/tablet" => CfgManager.PathCrToken.Replace(".json", "_android.json"),
"console/switch" or "console/ps4" or "console/ps5" or "console/xbox_one" => CfgManager.PathCrToken.Replace(".json", "_console.json"),
"---" => CfgManager.PathCrToken.Replace(".json", "_guest.json"),
_ => CfgManager.PathCrToken
};
}
public async Task Auth(){
if (CfgManager.CheckIfFileExists(GetTokenFilePath())){
Token = CfgManager.ReadJsonFromFile<CrToken>(GetTokenFilePath());
var tokenFilePath = GetTokenFilePath();
if (CfgManager.CheckIfFileExists(tokenFilePath)){
Token = CfgManager.ReadJsonFromFile<CrToken>(tokenFilePath);
await LoginWithToken();
} else{
await AuthAnonymous();
}
}
public void SetETPCookie(string refreshToken){
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("etp_rt", refreshToken), cookieStore);
HttpClientReq.Instance.AddCookie(".crunchyroll.com", new Cookie("c_locale", "en-US"), cookieStore);
public async Task Auth(AuthData authData){
cookieStore.Clear();
if (AuthSettings.Endpoint.StartsWith("tv")){
await AuthOld(authData);
} else{
await AuthCode(authData);
}
}
public async Task AuthAnonymous(){
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
public void SetETPCookie(string refreshToken){
HttpClientReq.Instance.AddCookie(CrunchyrollDomain, new Cookie("etp_rt", refreshToken), cookieStore);
HttpClientReq.Instance.AddCookie(CrunchyrollDomain, new Cookie("c_locale", "en-US"), cookieStore);
}
Subscription = new Subscription();
public Task AuthAnonymous(){
return AuthAnonymousInternal(true);
}
public Task AuthAnonymousFoxy(){
return AuthAnonymousInternal(false);
}
private async Task AuthAnonymousInternal(bool includeDeviceMetadata){
var uuid = ResolveDeviceId();
var formData = new Dictionary<string, string>{
{ "grant_type", "client_id" },
{ "scope", "offline_access" },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
if (!string.IsNullOrEmpty(AuthSettings.Device_name)){
formData.Add("device_name", AuthSettings.Device_name);
}
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
if (includeDeviceMetadata){
Subscription = new Subscription();
formData["scope"] = "offline_access";
AddDeviceMetadata(formData, uuid);
}
var request = CreateTokenRequest(formData);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.IsOk){
@ -124,129 +128,193 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
Console.Error.WriteLine("Anonymous login failed");
}
Profile = new CrProfile{
Username = "???",
Avatar = "crbrand_avatars_logo_marks_mangagirl_taupe.png",
PreferredContentAudioLanguage = "ja-JP",
PreferredContentSubtitleLanguage = "de-DE"
};
Profile = CreateDefaultProfile(DefaultAnonymousSubtitleLanguage);
}
private void JsonTokenToFileAndVariable(string content, string deviceId){
Token = Helpers.Deserialize<CrToken>(content, crunInstance.SettingsJsonSerializerSettings);
if (Token is{ expires_in: not null }){
Token.device_id = deviceId;
Token.expires = DateTime.Now.AddSeconds((double)Token.expires_in);
if (Token is not{ expires_in: not null }){
return;
}
if (EndpointEnum == CrunchyrollEndpoints.Guest){
return;
}
Token.device_id = deviceId;
Token.expires = DateTime.Now.AddSeconds((double)Token.expires_in);
NotificationPublisher.Instance.ResetLoginExpiredNotification();
CfgManager.WriteJsonToFile(GetTokenFilePath(), Token);
if (EndpointEnum == CrunchyrollEndpoints.Guest){
return;
}
CfgManager.WriteJsonToFile(GetTokenFilePath(), Token);
}
private async Task AuthCode(AuthData authData){
var uuid = ResolveDeviceId();
var loginPayload = JsonConvert.SerializeObject(new Dictionary<string, object>{
{ "email", authData.Username },
{ "password", authData.Password },
{ "eventSettings", new Dictionary<string, object>() }
});
var requestContent = new StringContent(loginPayload, Encoding.UTF8);
requestContent.Headers.ContentType = new MediaTypeHeaderValue("text/plain"){
CharSet = "UTF-8"
};
var request = CreateRequest(HttpMethod.Post, "https://sso.crunchyroll.com/api/login", requestContent, includeAuthorization: false);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
var refreshToken = HttpClientReq.Instance.GetCookieValue(SsoDomain, "etp_rt", cookieStore);
Token = new CrToken{ refresh_token = refreshToken, device_id = uuid };
await GetCodeAuth();
await LoginWithCode();
} else{
await PublishLoginFailureAsync(response.ResponseContent);
}
}
private async Task AuthOld(AuthData data){
string uuid = Guid.NewGuid().ToString();
private static string GenerateCodeVerifier(int length = 64){
// RFC 7636: length between 43 and 128.
const string allowed = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789-._~";
var bytes = RandomNumberGenerator.GetBytes(length);
var stringBuilder = new StringBuilder(length);
foreach (var value in bytes){
stringBuilder.Append(allowed[value % allowed.Length]);
}
return stringBuilder.ToString();
}
public async Task GetCodeAuth(){
var uuid = Guid.NewGuid().ToString();
NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query);
authCodeVerifier = GenerateCodeVerifier();
var clientId = GetClientIdFromBasicHeader(AuthSettings.Authorization);
query["client_id"] = clientId;
query["redirect_uri"] = "sso.crunchyroll://auth";
query["response_type"] = "code";
query["scope"] = "offline_access";
query["state"] = "{\"flow\":\"SIGN_IN\",\"flowRoot\":\"ONBOARDING\"}";
query["code_challenge"] = authCodeVerifier;
query["code_challenge_method"] = "plain";
HttpClientReq.Instance.AddCookie(SsoDomain, new Cookie("client_id", clientId), cookieStore);
HttpClientReq.Instance.AddCookie(SsoDomain, new Cookie("device_id", uuid), cookieStore);
var uriBuilder = new UriBuilder("https://sso.crunchyroll.com/authorize"){
Query = query.ToString()
};
var request = CreateRequest(HttpMethod.Get, uriBuilder.ToString(), includeAuthorization: false);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
authCode = ExtractCode(response.ResponseContent);
if (string.IsNullOrEmpty(authCode)){
Console.Error.WriteLine("Auth code is empty");
}
}
private static string GetClientIdFromBasicHeader(string authorizationHeader){
if (string.IsNullOrWhiteSpace(authorizationHeader)){
throw new ArgumentException("Authorization header is null/empty.", nameof(authorizationHeader));
}
const string prefix = "Basic ";
if (!authorizationHeader.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)){
throw new FormatException("Authorization header is not Basic.");
}
var base64 = authorizationHeader[prefix.Length..].Trim();
byte[] bytes;
try{
bytes = Convert.FromBase64String(base64);
} catch (FormatException ex){
throw new FormatException("Basic token is not valid Base64.", ex);
}
var decoded = Encoding.UTF8.GetString(bytes);
var separatorIndex = decoded.IndexOf(':');
if (separatorIndex <= 0){
throw new FormatException("Decoded Basic value is not in 'clientId:clientSecret' format.");
}
return decoded[..separatorIndex];
}
private static string Normalize(string value){
value = Regex.Unescape(value);
value = value.Replace(@"\u0026", "&");
value = value.Replace("\\\"", "\"");
return value;
}
private static string ExtractCode(string body){
var text = Normalize(body);
var match = Regex.Match(text, @"(?:[?&]|\\u0026)code=([A-Za-z0-9\-_]+)");
if (match.Success){
return match.Groups[1].Value;
}
match = Regex.Match(text, @"code=([A-Za-z0-9\-_]+)");
if (match.Success){
return match.Groups[1].Value;
}
Console.Error.WriteLine("Authorization code not found in response body.");
return string.Empty;
}
private async Task AuthOld(AuthData data){
var uuid = Guid.NewGuid().ToString();
var formData = new Dictionary<string, string>{
{ "username", data.Username },
{ "password", data.Password },
{ "grant_type", "password" },
{ "scope", "offline_access" },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
AddDeviceMetadata(formData, uuid);
if (!string.IsNullOrEmpty(AuthSettings.Device_name)){
formData.Add("device_name", AuthSettings.Device_name);
}
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
var request = CreateTokenRequest(formData);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
} else{
if (response.ResponseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage("Login failed. Please check your username and password.", ToastType.Error, 5));
} else if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
} else{
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - {response.ResponseContent.Substring(0, response.ResponseContent.Length < 200 ? response.ResponseContent.Length : 200)}",
ToastType.Error, 5));
await Console.Error.WriteLineAsync("Full Response: " + response.ResponseContent);
}
await PublishLoginFailureAsync(response.ResponseContent);
}
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetMultiProfile();
}
}
public async Task ChangeProfile(string profileId){
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
if (HasNoUsableRefreshToken()){
await AuthAnonymous();
}
if (Profile.Username == "???"){
if (Profile.Username == UnknownUsername || string.IsNullOrEmpty(profileId) || Token?.refresh_token == null){
return;
}
if (string.IsNullOrEmpty(profileId) || Token?.refresh_token == null){
return;
}
string uuid = string.IsNullOrEmpty(Token.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
var uuid = ResolveDeviceId();
SetETPCookie(Token.refresh_token);
var formData = new Dictionary<string, string>{
{ "grant_type", "refresh_token_profile_id" },
{ "profile_id", profileId },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
AddDeviceMetadata(formData, uuid);
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
if (Token?.refresh_token != null) SetETPCookie(Token.refresh_token);
var request = CreateTokenRequest(formData);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
@ -256,7 +324,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
}
await GetMultiProfile();
} else{
Console.Error.WriteLine("Refresh Token Auth Failed");
}
@ -269,7 +336,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
}
var request = HttpClientReq.CreateRequestMessage(ApiUrls.Profile, HttpMethod.Get, true, Token.access_token, null);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.IsOk){
@ -277,7 +343,6 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
if (profileTemp != null){
Profile = profileTemp;
await GetSubscription();
}
}
@ -285,40 +350,40 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
private async Task GetSubscription(){
var requestSubs = HttpClientReq.CreateRequestMessage(ApiUrls.Subscription + Token.account_id, HttpMethod.Get, true, Token.access_token, null);
var responseSubs = await HttpClientReq.Instance.SendHttpRequest(requestSubs);
if (responseSubs.IsOk){
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Subscription = subsc;
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
var remaining = expiration - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = expiration;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
} else{
if (!responseSubs.IsOk){
Profile.HasPremium = false;
Console.Error.WriteLine("Failed to check premium subscription status");
return;
}
var subsc = Helpers.Deserialize<Subscription>(responseSubs.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
Subscription = subsc;
if (subsc is{ SubscriptionProducts:{ Count: 0 }, ThirdPartySubscriptionProducts.Count: > 0 }){
var thirdPartySub = subsc.ThirdPartySubscriptionProducts.First();
var expiration = thirdPartySub.InGrace ? thirdPartySub.InGraceExpirationDate : thirdPartySub.ExpirationDate;
var remaining = expiration - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = expiration;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, NonrecurringSubscriptionProducts.Count: > 0 }){
var nonRecurringSub = subsc.NonrecurringSubscriptionProducts.First();
var remaining = nonRecurringSub.EndDate - DateTime.Now;
Profile.HasPremium = true;
if (Subscription != null){
Subscription.IsActive = remaining > TimeSpan.Zero;
Subscription.NextRenewalDate = nonRecurringSub.EndDate;
}
} else if (subsc is{ SubscriptionProducts:{ Count: 0 }, FunimationSubscriptions.Count: > 0 }){
Profile.HasPremium = true;
} else if (subsc is{ SubscriptionProducts.Count: > 0 }){
Profile.HasPremium = true;
} else{
Profile.HasPremium = false;
Console.Error.WriteLine($"No subscription available:\n {JsonConvert.SerializeObject(subsc, Formatting.Indented)} ");
}
}
@ -328,21 +393,42 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
return;
}
var request = HttpClientReq.CreateRequestMessage(ApiUrls.MultiProfile, HttpMethod.Get, true, Token?.access_token);
var request = HttpClientReq.CreateRequestMessage(ApiUrls.MultiProfile, HttpMethod.Get, true, Token.access_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request, false, cookieStore);
if (response.IsOk){
MultiProfile = Helpers.Deserialize<CrMultiProfile>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings) ?? new CrMultiProfile();
var selectedProfile = MultiProfile.Profiles.FirstOrDefault( e => e.IsSelected);
if (selectedProfile != null) Profile = selectedProfile;
var selectedProfile = MultiProfile.Profiles.FirstOrDefault(e => e.IsSelected);
if (selectedProfile != null){
Profile = selectedProfile;
}
await GetSubscription();
}
}
}
public async Task LoginWithCode(){
if (string.IsNullOrEmpty(authCode)){
Console.Error.WriteLine("Missing code");
await AuthAnonymous();
return;
}
var uuid = ResolveDeviceId();
var formData = new Dictionary<string, string>{
{ "code", authCode },
{ "code_verifier", authCodeVerifier },
{ "grant_type", "authorization_code" },
{ "scope", "offline_access" },
};
AddDeviceMetadata(formData, uuid);
var request = CreateTokenRequest(formData);
SetETPCookie(Token?.refresh_token ?? string.Empty);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
await CompleteInteractiveTokenLoginAsync(response, uuid);
}
public async Task LoginWithToken(){
@ -352,62 +438,12 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
return;
}
string uuid = string.IsNullOrEmpty(Token.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
var formData = new Dictionary<string, string>{
{ "refresh_token", Token.refresh_token },
{ "scope", "offline_access" },
{ "device_id", uuid },
{ "grant_type", "refresh_token" },
{ "device_type", AuthSettings.Device_type },
};
if (!string.IsNullOrEmpty(AuthSettings.Device_name)){
formData.Add("device_name", AuthSettings.Device_name);
}
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
var uuid = ResolveDeviceId();
var request = CreateRefreshTokenRequest(uuid, Token.refresh_token);
SetETPCookie(Token.refresh_token);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.ResponseContent.Contains("<title>Just a moment...</title>") ||
response.ResponseContent.Contains("<title>Access denied</title>") ||
response.ResponseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
response.ResponseContent.Trim().Equals("error code: 1020") ||
response.ResponseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1){
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}", ToastType.Error, 5));
Console.Error.WriteLine($"Failed to login - Cloudflare error {(crunInstance.CrunOptions.UseCrBetaApi ? "" : "try to change to BetaAPI in settings")}");
}
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetMultiProfile();
}
} else{
Console.Error.WriteLine("Token Auth Failed");
await AuthAnonymous();
MainWindow.Instance.ShowError("Login failed. Please check the log for more details.");
}
await CompleteInteractiveTokenLoginAsync(response, uuid);
}
public async Task RefreshToken(bool needsToken){
@ -420,52 +456,22 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
return;
}
if (Token?.access_token == null && Token?.refresh_token == null ||
Token.access_token != null && Token.refresh_token == null){
if (HasNoUsableRefreshToken()){
await AuthAnonymous();
} else{
if (!IsTokenExpiredOrNearExpiry() && needsToken){
return;
}
}
if (Profile.Username == "???"){
} else if (!IsTokenExpiredOrNearExpiry() && needsToken){
return;
}
var hadUserSession = !string.IsNullOrWhiteSpace(Token?.refresh_token) && !string.IsNullOrWhiteSpace(Profile.Username) && Profile.Username != "???";
string uuid = string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
var formData = new Dictionary<string, string>{
{ "refresh_token", Token?.refresh_token ?? "" },
{ "grant_type", "refresh_token" },
{ "scope", "offline_access" },
{ "device_id", uuid },
{ "device_type", AuthSettings.Device_type },
};
if (!string.IsNullOrEmpty(AuthSettings.Device_name)){
formData.Add("device_name", AuthSettings.Device_name);
if (Profile.Username == UnknownUsername){
return;
}
var requestContent = new FormUrlEncodedContent(formData);
var crunchyAuthHeaders = new Dictionary<string, string>{
{ "Authorization", AuthSettings.Authorization },
{ "User-Agent", AuthSettings.UserAgent }
};
var request = new HttpRequestMessage(HttpMethod.Post, ApiUrls.Auth){
Content = requestContent
};
foreach (var header in crunchyAuthHeaders){
request.Headers.Add(header.Key, header.Value);
}
SetETPCookie(Token?.refresh_token ?? string.Empty);
var hadUserSession = !string.IsNullOrWhiteSpace(Token?.refresh_token) && !string.IsNullOrWhiteSpace(Profile.Username) && Profile.Username != UnknownUsername;
var uuid = ResolveDeviceId();
var refreshToken = Token?.refresh_token ?? string.Empty;
var request = CreateRefreshTokenRequest(uuid, refreshToken);
SetETPCookie(refreshToken);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (response.IsOk){
@ -477,4 +483,95 @@ public class CrAuth(CrunchyrollManager crunInstance, CrAuthSettings authSettings
}
}
}
private async Task CompleteInteractiveTokenLoginAsync((bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers) response, string uuid){
if (IsCloudflareBlock(response.ResponseContent)){
PublishCloudflareLoginFailure();
}
if (response.IsOk){
JsonTokenToFileAndVariable(response.ResponseContent, uuid);
if (Token?.refresh_token != null){
SetETPCookie(Token.refresh_token);
await GetMultiProfile();
}
} else{
Console.Error.WriteLine("Token Auth Failed");
await AuthAnonymous();
MainWindow.Instance.ShowError("Login failed. Please check the log for more details.");
}
}
private HttpRequestMessage CreateRefreshTokenRequest(string uuid, string refreshToken){
var formData = new Dictionary<string, string>{
{ "refresh_token", refreshToken },
{ "scope", "offline_access" },
{ "grant_type", "refresh_token" },
};
AddDeviceMetadata(formData, uuid);
return CreateTokenRequest(formData);
}
private HttpRequestMessage CreateTokenRequest(Dictionary<string, string> formData){
return CreateRequest(HttpMethod.Post, ApiUrls.Auth, new FormUrlEncodedContent(formData));
}
private HttpRequestMessage CreateRequest(HttpMethod method, string uri, HttpContent? content = null, bool includeAuthorization = true){
var request = new HttpRequestMessage(method, uri){
Content = content
};
if (includeAuthorization){
request.Headers.Add("Authorization", AuthSettings.Authorization);
}
request.Headers.Add("User-Agent", AuthSettings.UserAgent);
return request;
}
private void AddDeviceMetadata(Dictionary<string, string> formData, string uuid){
formData["device_id"] = uuid;
formData["device_type"] = AuthSettings.Device_type;
if (!string.IsNullOrEmpty(AuthSettings.Device_name)){
formData["device_name"] = AuthSettings.Device_name;
}
}
private string ResolveDeviceId(){
return string.IsNullOrEmpty(Token?.device_id) ? Guid.NewGuid().ToString() : Token.device_id;
}
private bool HasNoUsableRefreshToken(){
return Token?.access_token == null && Token?.refresh_token == null ||
Token?.access_token != null && Token.refresh_token == null;
}
private async Task PublishLoginFailureAsync(string responseContent){
if (responseContent.Contains("invalid_credentials")){
MessageBus.Current.SendMessage(new ToastMessage("Login failed. Please check your username and password.", ToastType.Error, 5));
} else if (IsCloudflareBlock(responseContent)){
PublishCloudflareLoginFailure();
} else{
var previewLength = Math.Min(responseContent.Length, 200);
MessageBus.Current.SendMessage(new ToastMessage($"Failed to login - {responseContent[..previewLength]}", ToastType.Error, 5));
await Console.Error.WriteLineAsync("Full Response: " + responseContent);
}
}
private void PublishCloudflareLoginFailure(){
var betaApiHint = crunInstance.CrunOptions.UseCrBetaApi ? string.Empty : "try to change to BetaAPI in settings";
var message = $"Failed to login - Cloudflare error {betaApiHint}";
MessageBus.Current.SendMessage(new ToastMessage(message, ToastType.Error, 5));
Console.Error.WriteLine(message);
}
private static bool IsCloudflareBlock(string responseContent){
return responseContent.Contains("<title>Just a moment...</title>") ||
responseContent.Contains("<title>Access denied</title>") ||
responseContent.Contains("<title>Attention Required! | Cloudflare</title>") ||
responseContent.Trim().Equals("error code: 1020") ||
responseContent.IndexOf("<title>DDOS-GUARD</title>", StringComparison.OrdinalIgnoreCase) > -1;
}
}

View file

@ -35,7 +35,7 @@ public class CrEpisode(){
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
Console.Error.WriteLine($"Episode request failed for id '{id}'");
return null;
}
@ -253,20 +253,67 @@ public class CrEpisode(){
}
}
query["n"] = requestAmount + "";
query["sort_by"] = "newly_added";
query["type"] = "episode";
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Browse}", HttpMethod.Get, true, crunInstance.CrAuthGuest.Token?.access_token, query);
if (requestAmount <= 0){
return new CrBrowseEpisodeBase{
Data = []
};
}
var response = await HttpClientReq.Instance.SendHttpRequest(request);
const int maxPageSize = 100;
const int stalePageTolerance = 3;
CrBrowseEpisodeBase? series = null;
var episodes = new List<CrBrowseEpisode>();
var stalePageCount = 0;
var firstWeekDayDate = firstWeekDay?.Date;
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
for (var start = 0; start < requestAmount; start += maxPageSize){
var pageSize = Math.Min(maxPageSize, requestAmount - start);
query["start"] = start.ToString();
query["n"] = pageSize.ToString();
var request = HttpClientReq.CreateRequestMessage($"{ApiUrls.Browse}", HttpMethod.Get, true, crunInstance.CrAuthGuest.Token?.access_token, query);
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine($"New episodes request failed for start '{start}' and n '{pageSize}'");
return null;
}
var page = Helpers.Deserialize<CrBrowseEpisodeBase>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
series ??= page;
if (page?.Data is not{ Count: > 0 } pageData){
break;
}
episodes.AddRange(pageData);
if (firstWeekDayDate.HasValue){
if (pageData.Any(episode => GetCalendarTargetDate(episode).Date >= firstWeekDayDate.Value)){
stalePageCount = 0;
} else{
stalePageCount++;
if (stalePageCount >= stalePageTolerance){
break;
}
}
}
if (pageData.Count < pageSize){
break;
}
}
if (series == null){
return null;
}
CrBrowseEpisodeBase? series = Helpers.Deserialize<CrBrowseEpisodeBase>(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings);
series.Data = episodes;
series?.Data?.Sort((a, b) =>
b.EpisodeMetadata.PremiumAvailableDate.CompareTo(a.EpisodeMetadata.PremiumAvailableDate));
@ -274,6 +321,31 @@ public class CrEpisode(){
return series;
}
private static DateTime GetCalendarTargetDate(CrBrowseEpisode episode){
DateTime episodeAirDate = episode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc
? episode.EpisodeMetadata.EpisodeAirDate.ToLocalTime()
: episode.EpisodeMetadata.EpisodeAirDate;
DateTime premiumAvailableStart = episode.EpisodeMetadata.PremiumAvailableDate.Kind == DateTimeKind.Utc
? episode.EpisodeMetadata.PremiumAvailableDate.ToLocalTime()
: episode.EpisodeMetadata.PremiumAvailableDate;
DateTime targetDate = premiumAvailableStart;
DateTime oneYearFromNow = DateTime.Now.AddYears(1);
if (targetDate >= oneYearFromNow){
DateTime freeAvailableStart = episode.EpisodeMetadata.FreeAvailableDate.Kind == DateTimeKind.Utc
? episode.EpisodeMetadata.FreeAvailableDate.ToLocalTime()
: episode.EpisodeMetadata.FreeAvailableDate;
targetDate = freeAvailableStart <= oneYearFromNow
? freeAvailableStart
: episodeAirDate;
}
return targetDate;
}
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);
@ -284,4 +356,4 @@ public class CrEpisode(){
Console.Error.WriteLine($"Mark as watched for {episodeId} failed");
}
}
}
}

View file

@ -3,7 +3,6 @@ using System.Collections.Generic;
using System.Collections.Specialized;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using System.Web;
using CRD.Downloader.Crunchyroll.Utils;
@ -26,11 +25,11 @@ public class CrSeries{
var hslang = crunInstance.CrunOptions.Hslang;
bool ShouldInclude(string epNum) =>
all is true || (e != null && e.Contains(epNum));
bool ShouldInclude(string selectionKey) =>
all is true || (e != null && e.Contains(selectionKey));
foreach (var (key, episode) in eps){
var epNum = key.StartsWith('E') ? key[1..] : key;
var epNum = GetEpisodeLabelFromKey(key);
foreach (var v in episode.Variants){
var item = v.Item;
@ -70,7 +69,7 @@ public class CrSeries{
}
// selection gate
if (!ShouldInclude(epNum))
if (!ShouldInclude(key))
continue;
// Create base queue item once per "key"
@ -133,7 +132,7 @@ public class CrSeries{
}
public async Task<CrunchySeriesList?> ListSeriesId(string id, string crLocale, CrunchyMultiDownload? data, bool forcedLocale = false){
public async Task<CrunchySeriesList?> ListSeriesId(string id, string crLocale, CrunchyMultiDownload? data, bool forcedLocale = false, bool updateHistory = true){
bool serieshasversions = true;
CrSeriesSearch? parsedSeries = await ParseSeriesById(id, crLocale, forcedLocale);
@ -145,7 +144,7 @@ public class CrSeries{
var episodes = new Dictionary<string, EpisodeAndLanguage>();
if (crunInstance.CrunOptions.History)
if (crunInstance.CrunOptions.History && updateHistory)
_ = crunInstance.History.CrUpdateSeries(id, "");
var cachedSeasonId = "";
@ -170,7 +169,7 @@ public class CrSeries{
(episode.Episode != string.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}"))
?? string.Empty;
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier)
var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) && s.Identifier.Contains('|')
? s.Identifier.Split('|')[1]
: $"S{episode.SeasonNumber}";
@ -194,7 +193,7 @@ public class CrSeries{
}
}
if (crunInstance.CrunOptions.History){
if (crunInstance.CrunOptions.History && updateHistory){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == id);
if (historySeries != null){
crunInstance.History.MatchHistorySeriesWithSonarr(false);
@ -215,14 +214,18 @@ public class CrSeries{
var baseEp = item.Variants[0].Item;
var epStr = baseEp.Episode;
var isSpecial = epStr != null && !Regex.IsMatch(epStr, @"^\d+(\.\d+)?$");
var isSpecial = baseEp.IsSpecialEpisode();
string newKey;
if (isSpecial && !string.IsNullOrEmpty(baseEp.Episode)){
newKey = $"SP{specialIndex}_" + baseEp.Episode;
} else{
newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + baseEp.Id) : epIndex + "")}";
var episodeLabel = CrunchyEpisode.IsRegularEpisodeNumber(baseEp.Episode)
? baseEp.Episode
: epIndex.ToString();
var separatorIndex = key.LastIndexOf('E');
var keyPrefix = separatorIndex > 0 ? key[..separatorIndex] : string.Empty;
newKey = $"{keyPrefix}E{episodeLabel}";
}
episodes.Remove(key);
@ -240,7 +243,7 @@ public class CrSeries{
else epIndex++;
}
var normal = episodes.Where(kvp => kvp.Key.StartsWith("E")).ToList();
var normal = episodes.Where(kvp => !kvp.Key.StartsWith("SP")).ToList();
var specials = episodes.Where(kvp => kvp.Key.StartsWith("SP")).ToList();
var sortedEpisodes = new Dictionary<string, EpisodeAndLanguage>(normal.Concat(specials));
@ -259,7 +262,7 @@ public class CrSeries{
);
var title = baseEp.Title;
var seasonNumber = Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString();
var seasonNumber = GetSeasonDisplaySuffix(baseEp);
var languages = item.Variants
.Select(v => $"{(v.Item.IsPremiumOnly ? "+ " : "")}{v.Lang?.Name ?? "Unknown"}")
@ -281,7 +284,7 @@ public class CrSeries{
if (value.Variants.Count == 0){
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
E = key,
Lang = new List<string>(),
Name = string.Empty,
Season = string.Empty,
@ -311,15 +314,15 @@ public class CrSeries{
Languages.SortListByLangList(langList);
return new Episode{
E = key.StartsWith("E") ? key.Substring(1) : key,
E = key,
Lang = langList,
Name = baseEp.Title ?? string.Empty,
Season = (Helpers.ExtractNumberAfterS(baseEp.Identifier) ?? baseEp.SeasonNumber.ToString()) ?? string.Empty,
Season = GetSeasonDisplaySuffix(baseEp),
SeriesTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeriesTitle),
SeasonTitle = DownloadQueueItemFactory.StripDubSuffix(baseEp.SeasonTitle),
EpisodeNum = key.StartsWith("SP")
? key
: (baseEp.EpisodeNumber?.ToString() ?? baseEp.Episode ?? "?"),
: GetEpisodeLabelFromKey(key),
Id = baseEp.SeasonId ?? string.Empty,
Img = img,
Description = baseEp.Description ?? string.Empty,
@ -331,6 +334,20 @@ public class CrSeries{
return crunchySeriesList;
}
private static string GetEpisodeLabelFromKey(string key){
if (key.StartsWith("SP"))
return key;
var separatorIndex = key.LastIndexOf('E');
return separatorIndex >= 0 && separatorIndex < key.Length - 1
? key[(separatorIndex + 1)..]
: key;
}
private static string GetSeasonDisplaySuffix(CrunchyEpisode episode){
return Helpers.ExtractNumberAfterS(episode.Identifier) ?? episode.SeasonNumber.ToString();
}
public async Task<CrunchyEpisodeList> GetSeasonDataById(string seasonId, string? crLocale, bool forcedLang = false, bool log = false){
await crunInstance.CrAuthGuest.RefreshToken(true);
CrunchyEpisodeList episodeList = new CrunchyEpisodeList(){ Data = new List<CrunchyEpisode>(), Total = 0, Meta = new Meta() };
@ -404,7 +421,7 @@ public class CrSeries{
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
Console.Error.WriteLine($"Series seasons request failed for series id '{id}'");
return null;
}
@ -437,7 +454,7 @@ public class CrSeries{
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
Console.Error.WriteLine($"Series details request failed for series id '{id}'");
return null;
}
@ -473,7 +490,7 @@ public class CrSeries{
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
Console.Error.WriteLine($"Series search request failed for query '{searchString}'");
return null;
}
@ -519,7 +536,7 @@ public class CrSeries{
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
Console.Error.WriteLine($"All series browse request failed for start '{i}'");
return null;
}
@ -555,7 +572,7 @@ public class CrSeries{
var response = await HttpClientReq.Instance.SendHttpRequest(request);
if (!response.IsOk){
Console.Error.WriteLine("Series Request Failed");
Console.Error.WriteLine($"Seasonal series request failed for '{season}-{year}'");
return null;
}
@ -563,4 +580,4 @@ public class CrSeries{
return series;
}
}
}

View file

@ -68,6 +68,9 @@ public class CrunchyrollManager{
};
private Widevine _widevine = Widevine.Instance;
private readonly SemaphoreSlim dubDownloadDelaySemaphore = new(1, 1);
private readonly object dubDownloadDelayLock = new();
private DateTimeOffset? nextDubDownloadAllowedAtUtc;
public CrAuth CrAuthEndpoint1;
public CrAuth CrAuthEndpoint2;
@ -120,11 +123,12 @@ public class CrunchyrollManager{
options.Force = "Y";
options.FileName = "${seriesTitle} - S${season}E${episode} [${height}p]";
options.Partsize = 10;
options.DubDownloadDelaySeconds = 0;
options.DownloadDelaySeconds = 0;
options.DlSubs = new List<string>{ "en-US" };
options.SkipMuxing = false;
options.MkvmergeOptions = [];
options.FfmpegOptions = [];
options.DefaultVideo = "ja-JP";
options.DefaultAudio = "ja-JP";
options.DefaultSub = "en-US";
options.QualityAudio = "best";
@ -380,10 +384,6 @@ public class CrunchyrollManager{
Parallel.ForEach(historyList, historySeries => {
historySeries.Init();
foreach (var historySeriesSeason in historySeries.Seasons){
historySeriesSeason.Init();
}
});
} else{
HistoryList = [];
@ -422,7 +422,7 @@ public class CrunchyrollManager{
Doing = "Starting",
RetryAttemptCount = retryAttemptCount
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
var res = new DownloadResponse();
try{
res = await DownloadMediaList(data, options);
@ -454,7 +454,7 @@ public class CrunchyrollManager{
DownloadSpeedBytes = 0,
Doing = "Download Error" + (!string.IsNullOrEmpty(res.ErrorText) ? " - " + res.ErrorText : ""),
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.NotifyQueueItemStateChanged(data);
await NotificationPublisher.Instance.PublishDownloadFailedAsync(CrunOptions.NotificationSettings, data, res.ErrorText);
return false;
}
@ -469,7 +469,7 @@ public class CrunchyrollManager{
DownloadSpeedBytes = 0,
Doing = "Waiting for Muxing/Encoding"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
await QueueManager.Instance.WaitForProcessingSlotAsync(data.Cts.Token);
processingSlotHeld = true;
}
@ -489,7 +489,7 @@ public class CrunchyrollManager{
Doing = "Muxing"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
if (options.MuxFonts){
await FontsManager.Instance.GetFontsAsync();
@ -516,6 +516,7 @@ public class CrunchyrollManager{
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
DefaultVideo = !string.IsNullOrWhiteSpace(options.DefaultVideo) && options.DefaultVideo != "none" ? Languages.FindLang(options.DefaultVideo) : null,
DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
MkvmergeOptions = options.MkvmergeOptions,
@ -524,6 +525,7 @@ public class CrunchyrollManager{
CcTag = options.CcTag,
KeepAllVideos = true,
MuxDescription = options.IncludeVideoDescription,
ReplaceExistingFiles = options.ReplaceExistingFiles,
DlVideoOnce = options.DlVideoOnce,
DefaultSubSigns = options.DefaultSubSigns,
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
@ -557,7 +559,7 @@ public class CrunchyrollManager{
Doing = "Encoding"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
@ -565,7 +567,7 @@ public class CrunchyrollManager{
}
if (options.DownloadToTempFolder){
await MoveFromTempFolder(merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, merger.Options.Subtitles);
await MoveFromTempFolder(merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, merger.Options.Subtitles, options.ReplaceExistingFiles);
}
}
} else{
@ -583,6 +585,7 @@ public class CrunchyrollManager{
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
DefaultVideo = !string.IsNullOrWhiteSpace(options.DefaultVideo) && options.DefaultVideo != "none" ? Languages.FindLang(options.DefaultVideo) : null,
DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
MkvmergeOptions = options.MkvmergeOptions,
@ -591,6 +594,7 @@ public class CrunchyrollManager{
CcTag = options.CcTag,
KeepAllVideos = true,
MuxDescription = options.IncludeVideoDescription,
ReplaceExistingFiles = options.ReplaceExistingFiles,
DlVideoOnce = options.DlVideoOnce,
DefaultSubSigns = options.DefaultSubSigns,
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
@ -622,6 +626,7 @@ public class CrunchyrollManager{
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
DefaultVideo = !string.IsNullOrWhiteSpace(options.DefaultVideo) && options.DefaultVideo != "none" ? Languages.FindLang(options.DefaultVideo) : null,
DefaultAudio = options.DefaultAudio != "none" ? Languages.FindLang(options.DefaultAudio) : null,
DefaultSub = options.DefaultSub != "none" ? Languages.FindLang(options.DefaultSub) : null,
MkvmergeOptions = options.MkvmergeOptions,
@ -630,6 +635,7 @@ public class CrunchyrollManager{
CcTag = options.CcTag,
KeepAllVideos = true,
MuxDescription = options.IncludeVideoDescription,
ReplaceExistingFiles = options.ReplaceExistingFiles,
DlVideoOnce = false,
DefaultSubSigns = options.DefaultSubSigns,
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
@ -664,7 +670,7 @@ public class CrunchyrollManager{
Doing = "Encoding"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.Options.Output, preset, data);
@ -687,7 +693,7 @@ public class CrunchyrollManager{
})
.ToList();
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles, fallbackUsed);
await MoveFromTempFolder(result.merger, data, options, tempFolder, subtitles, fallbackUsed || options.ReplaceExistingFiles);
}
}
@ -710,7 +716,7 @@ public class CrunchyrollManager{
} else{
// Move files
foreach (var downloadedMedia in res.Data){
await MoveFile(downloadedMedia.Path ?? string.Empty, res.TempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
await MoveFile(downloadedMedia.Path ?? string.Empty, res.TempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options, options.ReplaceExistingFiles);
}
}
}
@ -736,11 +742,16 @@ public class CrunchyrollManager{
}
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
if (options.History && data.Data is{ Count: > 0 } && (options.HistoryIncludeCrArtists && data.Music || !data.Music)){
var ids = data.Data.First().GetOriginalIds();
History.SetAsDownloaded(data.SeriesId, ids.seasonID ?? data.SeasonId, ids.guid ?? data.Data.First().MediaId);
History.SetAsDownloaded(
data.SeriesId,
ids.seasonID ?? data.SeasonId,
ids.guid ?? data.Data.First().MediaId,
GetDownloadedDubs(data, res, options),
GetDownloadedSoftSubs(res));
}
if (options.MarkAsWatched && data.Data is{ Count: > 0 }){
@ -763,6 +774,42 @@ public class CrunchyrollManager{
return true;
}
private static List<string> GetDownloadedDubs(CrunchyEpMeta data, DownloadResponse? response, CrDownloadOptions? options){
if (options?.Noaudio == true){
return [];
}
var downloadedFromFiles = NormalizeLocales(response?.Data?
.Where(item => item.Type is DownloadMediaType.Video or DownloadMediaType.SyncVideo or DownloadMediaType.Audio)
.Select(item => item.Lang.CrLocale));
if (downloadedFromFiles.Count > 0){
return downloadedFromFiles;
}
return NormalizeLocales(data.Data
.Where(item => !item.IsAudioRoleDescription)
.Select(item => item.Lang?.CrLocale));
}
private static List<string> GetDownloadedSoftSubs(DownloadResponse? response){
if (response?.Data == null){
return [];
}
return NormalizeLocales(response.Data
.Where(item => item.Type == DownloadMediaType.Subtitle && item.Signs != true)
.Select(item => item.Language.CrLocale));
}
private static List<string> NormalizeLocales(IEnumerable<string?>? locales){
return (locales ?? [])
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Select(locale => locale!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
#region Temp Files Move
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles, bool replaceExisting = false){
@ -776,7 +823,7 @@ public class CrunchyrollManager{
Doing = "Moving Files"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
if (string.IsNullOrEmpty(tempFolderPath) || !Directory.Exists(tempFolderPath)){
Console.WriteLine("Invalid or non-existent temp folder path.");
@ -865,7 +912,11 @@ public class CrunchyrollManager{
subsList.Add(subt);
}
if (File.Exists($"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}") && !string.IsNullOrEmpty(filename)){
var outputPath = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}";
if (File.Exists(outputPath) && !string.IsNullOrEmpty(filename)){
if (options.ReplaceExistingFiles){
Helpers.DeleteFile(outputPath);
} else{
string newFilePath = filename;
int counter = 1;
@ -875,6 +926,7 @@ public class CrunchyrollManager{
}
filename = newFilePath;
}
}
bool muxDesc = false;
@ -907,6 +959,7 @@ public class CrunchyrollManager{
Mkvmerge = options.MkvmergeOptions
},
Defaults = new Defaults(){
Video = options.DefaultVideo,
Audio = options.DefaultAudio,
Sub = options.DefaultSub
},
@ -944,7 +997,7 @@ public class CrunchyrollManager{
Doing = "Muxing Syncing Dub Timings"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(crunchyEpMeta);
var basePath = merger.Options.OnlyVid.First().Path;
var syncVideosList = data.Where(a => a.Type == DownloadMediaType.SyncVideo).ToList();
@ -1008,7 +1061,7 @@ public class CrunchyrollManager{
Doing = "Muxing"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(crunchyEpMeta);
}
if (!options.Mp4 && !muxToMp3){
@ -1037,7 +1090,7 @@ public class CrunchyrollManager{
DownloadSpeedBytes = 0,
Doing = $"Downloading full-quality fallback video ({string.Join(", ", uniqueFailedLocales)})"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
foreach (var syncVideo in res.Data.Where(media => media.Type == DownloadMediaType.SyncVideo && uniqueFailedLocales.Contains(media.Lang.CrLocale, StringComparer.OrdinalIgnoreCase)).ToList()){
if (!string.IsNullOrEmpty(syncVideo.Path)){
@ -1146,25 +1199,35 @@ public class CrunchyrollManager{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)){
if (!ffmpegAvailable){
Console.Error.WriteLine("Missing ffmpeg");
MainWindow.Instance.ShowError($"FFmpeg not found at: {CfgManager.PathFFMPEG}", "FFmpeg",
MainWindow.Instance.ShowError(
"FFmpeg is required to process downloaded video and audio, but CRD could not find it.\n\n" +
$"Expected file:\n{CfgManager.PathFFMPEG}\n\n" +
"Download FFmpeg and place ffmpeg.exe in CRD's lib folder.",
"Download FFmpeg",
"https://github.com/GyanD/codexffmpeg/releases/latest");
Helpers.EnsureDirectoriesExist(CfgManager.PathFFMPEG);
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
FileName = "./unknown",
ErrorText = "Missing ffmpeg"
ErrorText = "FFmpeg is missing"
};
}
if (!mkvmergeAvailable){
Console.Error.WriteLine("Missing Mkvmerge");
MainWindow.Instance.ShowError($"Mkvmerge not found at: {CfgManager.PathMKVMERGE}", "Mkvmerge",
MainWindow.Instance.ShowError(
"Mkvmerge is required to create the final MKV file, but CRD could not find it.\n\n" +
$"Expected file:\n{CfgManager.PathMKVMERGE}\n\n" +
"Download MKVToolNix and place mkvmerge.exe in CRD's lib folder.",
"Download MKVToolNix",
"https://mkvtoolnix.download/downloads.html#windows");
Helpers.EnsureDirectoriesExist(CfgManager.PathMKVMERGE);
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
FileName = "./unknown",
ErrorText = "Missing Mkvmerge"
ErrorText = "Mkvmerge is missing"
};
}
} else{
@ -1293,16 +1356,15 @@ public class CrunchyrollManager{
data.Data = sortedMetaData;
var epMetaIndex = 0;
if (!options.DownloadDelayUseDubBased){
await WaitForDownloadDelayAsync(data, options);
}
foreach (CrunchyEpMetaData epMeta in data.Data){
if (epMetaIndex > 0 && options.DubDownloadDelaySeconds > 0){
var delay = TimeSpan.FromSeconds(options.DubDownloadDelaySeconds);
data.DownloadProgress.Doing = $"Waiting {options.DubDownloadDelaySeconds}s before next dub";
QueueManager.Instance.RefreshQueue();
await Task.Delay(delay, data.Cts.Token);
if (options.DownloadDelayUseDubBased){
await WaitForDownloadDelayAsync(data, options);
}
epMetaIndex++;
Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}");
string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId);
@ -1439,7 +1501,7 @@ public class CrunchyrollManager{
}
if (!string.IsNullOrEmpty(error?.Error)){
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}");
MainWindow.Instance.ShowError($"Couldn't get Playback Data\n{error.Error}\n{error.Reason}");
return new DownloadResponse{
Data = new List<DownloadedMedia>(),
Error = true,
@ -2030,7 +2092,7 @@ public class CrunchyrollManager{
DownloadSpeedBytes = 0,
Doing = "Decrypting"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
Console.WriteLine("Decryption Needed, attempting to decrypt");
@ -2121,7 +2183,7 @@ public class CrunchyrollManager{
DownloadSpeedBytes = 0,
Doing = "Decrypting video"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
var decryptVideo = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
commandVideo, tempTsFileWorkDir);
@ -2182,7 +2244,7 @@ public class CrunchyrollManager{
DownloadSpeedBytes = 0,
Doing = "Decrypting audio"
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(data);
var decryptAudio = await Helpers.ExecuteCommandAsyncWorkDir(shaka ? "shaka-packager" : "mp4decrypt", shaka ? CfgManager.PathShakaPackager : CfgManager.PathMP4Decrypt,
commandAudio, tempTsFileWorkDir);
@ -2335,6 +2397,13 @@ public class CrunchyrollManager{
}
// await Task.Delay(options.Waittime);
if (options.DownloadDelayUseDubBased){
ScheduleNextDownloadDelay(options);
}
}
if (!options.DownloadDelayUseDubBased){
ScheduleNextDownloadDelay(options);
}
}
@ -2384,10 +2453,10 @@ public class CrunchyrollManager{
}
if (options is{ MuxCover: true, Noaudio: false, Novids: false }){
if (!string.IsNullOrEmpty(data.ImageBig) && !File.Exists(fileDir + "cover.png")){
var coverPath = Path.Combine(fileDir, $"{fileName}.cover.png");
if (!string.IsNullOrEmpty(data.ImageBig) && !File.Exists(coverPath)){
var bitmap = await Helpers.LoadImage(data.ImageBig);
if (bitmap != null){
string coverPath = Path.Combine(fileDir, "cover.png");
Helpers.EnsureDirectoriesExist(coverPath);
await using (var fs = File.OpenWrite(coverPath)){
bitmap.Save(fs); // always saves PNG
@ -2400,6 +2469,7 @@ public class CrunchyrollManager{
Lang = Languages.DEFAULT_lang,
Path = coverPath
});
data.downloadedFiles.Add(coverPath);
}
}
}
@ -2426,6 +2496,48 @@ public class CrunchyrollManager{
};
}
private async Task WaitForDownloadDelayAsync(CrunchyEpMeta data, CrDownloadOptions options){
if (options.DownloadDelaySeconds <= 0){
return;
}
await dubDownloadDelaySemaphore.WaitAsync(data.Cts.Token);
try{
var now = DateTimeOffset.UtcNow;
DateTimeOffset? nextAllowedAt;
lock (dubDownloadDelayLock){
nextAllowedAt = nextDubDownloadAllowedAtUtc;
}
if (nextAllowedAt.HasValue && nextAllowedAt.Value > now){
var delay = nextAllowedAt.Value - now;
var delayTarget = options.DownloadDelayUseDubBased ? "dub" : "episode";
data.DownloadProgress.Doing = $"Waiting {Math.Ceiling(delay.TotalSeconds)}s before next {delayTarget}";
QueueManager.Instance.RefreshItem(data);
await Task.Delay(delay, data.Cts.Token);
}
lock (dubDownloadDelayLock){
nextDubDownloadAllowedAtUtc = DateTimeOffset.UtcNow.AddSeconds(options.DownloadDelaySeconds);
}
} finally{
dubDownloadDelaySemaphore.Release();
}
}
private void ScheduleNextDownloadDelay(CrDownloadOptions options){
if (options.DownloadDelaySeconds <= 0){
return;
}
var nextAllowedAt = DateTimeOffset.UtcNow.AddSeconds(options.DownloadDelaySeconds);
lock (dubDownloadDelayLock){
if (!nextDubDownloadAllowedAtUtc.HasValue || nextAllowedAt > nextDubDownloadAllowedAtUtc.Value){
nextDubDownloadAllowedAtUtc = nextAllowedAt;
}
}
}
private static async Task DownloadSubtitles(CrDownloadOptions options, PlaybackData pbData, LanguageItem audDub, string fileName, List<DownloadedMedia> files, string fileDir, CrunchyEpMeta data,
DownloadedMedia videoDownloadMedia){
if (pbData.Meta != null && (pbData.Meta.Subtitles is{ Count: > 0 } || pbData.Meta.Captions is{ Count: > 0 })){
@ -2985,7 +3097,7 @@ public class CrunchyrollManager{
}
public async Task DeAuthVideo(string currentMediaId, string videoToken, CrAuth authEndoint){
var deauthVideoToken = HttpClientReq.CreateRequestMessage($"https://cr-play-service.prd.crunchyrollsvc.com/v1/token/{currentMediaId}/{videoToken}/inactive", HttpMethod.Patch, true,
var deauthVideoToken = HttpClientReq.CreateRequestMessage($"https://cr-play-service.prd.crunchyrollsvc.com/playback/v1/token/{currentMediaId}/{videoToken}/inactive", HttpMethod.Patch, true,
authEndoint.Token?.access_token, null);
var deauthVideoTokenResponse = await HttpClientReq.Instance.SendHttpRequest(deauthVideoToken);
}

View file

@ -120,7 +120,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
private double? _partSize;
[ObservableProperty]
private double? _dubDownloadDelaySeconds;
private double? _downloadDelaySeconds;
[ObservableProperty]
private bool _downloadDelayUseDubBased;
[ObservableProperty]
private string _fileName = "";
@ -218,6 +221,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool endpointNotSignedWarning;
[ObservableProperty]
private ComboBoxItem _selectedDefaultVideoLang;
[ObservableProperty]
private ComboBoxItem _selectedDefaultDubLang;
@ -281,6 +287,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
new(){ Content = "none" },
];
public ObservableCollection<ComboBoxItem> DefaultVideoLangList{ get; } = [
new(){ Content = "none" },
];
public ObservableCollection<ComboBoxItem> DefaultSubLangList{ get; } = [
new(){ Content = "none" },
];
@ -369,6 +379,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
HardSubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale });
SubLangList.Add(new ListBoxItem{ Content = languageItem.CrLocale });
DubLangList.Add(new ListBoxItem{ Content = languageItem.CrLocale });
DefaultVideoLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale });
DefaultDubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale });
DefaultSubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale });
}
@ -389,6 +400,9 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
ComboBoxItem? hsLang = HardSubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Hslang) ?? null;
SelectedHSLang = hsLang ?? HardSubLangList[0];
ComboBoxItem? defaultVideoLang = DefaultVideoLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.DefaultVideo ?? "")) ?? null;
SelectedDefaultVideoLang = defaultVideoLang ?? DefaultVideoLangList[0];
ComboBoxItem? defaultDubLang = DefaultDubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.DefaultAudio ?? "")) ?? null;
SelectedDefaultDubLang = defaultDubLang ?? DefaultDubLangList[0];
@ -477,7 +491,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay;
DefaultSubSigns = options.DefaultSubSigns;
PartSize = options.Partsize;
DubDownloadDelaySeconds = options.DubDownloadDelaySeconds;
DownloadDelaySeconds = options.DownloadDelaySeconds;
DownloadDelayUseDubBased = options.DownloadDelayUseDubBased;
IncludeEpisodeDescription = options.IncludeVideoDescription;
FileTitle = options.VideoTitle ?? "";
IncludeSignSubs = options.IncludeSignsSubs;
@ -579,7 +594,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.IncludeSignsSubs = IncludeSignSubs;
CrunchyrollManager.Instance.CrunOptions.IncludeCcSubs = IncludeCcSubs;
CrunchyrollManager.Instance.CrunOptions.Partsize = Math.Clamp((int)(PartSize ?? 1), 1, 10000);
CrunchyrollManager.Instance.CrunOptions.DubDownloadDelaySeconds = Math.Max((int)(DubDownloadDelaySeconds ?? 0), 0);
CrunchyrollManager.Instance.CrunOptions.DownloadDelaySeconds = Math.Max((int)(DownloadDelaySeconds ?? 0), 0);
CrunchyrollManager.Instance.CrunOptions.DownloadDelayUseDubBased = DownloadDelayUseDubBased;
CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic = SearchFetchFeaturedMusic;
CrunchyrollManager.Instance.CrunOptions.SubsAddScaledBorder = GetScaledBorderAndShadowSelection();
@ -600,6 +616,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.Hslang = SelectedHSLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.HsRawFallback = HsRawFallback;
CrunchyrollManager.Instance.CrunOptions.DefaultVideo = SelectedDefaultVideoLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.DefaultAudio = SelectedDefaultDubLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.DefaultSub = SelectedDefaultSubLang.Content + "";
@ -742,9 +759,6 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
foreach (var historySeries in CrunchyrollManager.Instance.HistoryList){
historySeries.Init();
foreach (var historySeriesSeason in historySeries.Seasons){
historySeriesSeason.Init();
}
}
} else{
CrunchyrollManager.Instance.HistoryList = [];

View file

@ -255,16 +255,23 @@
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Dub Download Delay"
Description="Delay in seconds before starting the next selected dub. 0 disables the delay.">
<controls:SettingsExpanderItem Content="Download Delay"
Description="Delay in seconds before starting the next episode download. 0 disables the delay.">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0"
Value="{Binding DubDownloadDelaySeconds}"
Value="{Binding DownloadDelaySeconds}"
SpinButtonPlacementMode="Hidden"
HorizontalAlignment="Stretch" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Dub Based Download Delay"
Description="Apply the download delay before every selected dub instead of only between episodes.">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding DownloadDelayUseDubBased}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Stream Endpoint ">
<controls:SettingsExpanderItem.Footer>
@ -560,6 +567,15 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Default Video ">
<controls:SettingsExpanderItem.Footer>
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"
ItemsSource="{Binding DefaultVideoLangList}"
SelectedItem="{Binding SelectedDefaultVideoLang}">
</ComboBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding !SkipMuxing}" Content="Default Audio ">
<controls:SettingsExpanderItem.Footer>
<ComboBox HorizontalContentAlignment="Center" MinWidth="210" MaxDropDownHeight="400"

View file

@ -93,7 +93,7 @@ public class History{
if (seasonData.Data is{ Count: > 0 }){
result = true;
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>());
await crunInstance.History.UpdateWithSeasonData(seasonData.Data.ToList<IHistorySource>(), false);
break;
}
} catch{
@ -130,11 +130,11 @@ public class History{
var musicVideoGroups = episodeList.Where(e => e.EpisodeType == EpisodeType.MusicVideo).GroupBy(e => e.Artist.Id);
foreach (var concertGroup in concertGroups){
await UpdateWithSeasonData(concertGroup.ToList<IHistorySource>());
await UpdateWithSeasonData(concertGroup.ToList<IHistorySource>(), false);
}
foreach (var musicVideoGroup in musicVideoGroups){
await UpdateWithSeasonData(musicVideoGroup.ToList<IHistorySource>());
await UpdateWithSeasonData(musicVideoGroup.ToList<IHistorySource>(), false);
}
}
}
@ -203,13 +203,6 @@ public class History{
var originalItems = seriesGroup.Where(IsOriginalItem).ToList();
if (originalItems.Count > 0){
if (allOriginalsInHistory){
var sT = seriesGroup.Select(e => e.EpisodeMetadata?.SeriesTitle)
.FirstOrDefault(t => !string.IsNullOrWhiteSpace(t)) ?? "";
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} {sT} - all ORIGINAL episodes already in history.");
continue;
}
var convertedList = originalItems.Select(crBrowseEpisode => crBrowseEpisode.ToCrunchyEpisode()).ToList();
await crunInstance.History.UpdateWithSeasonData(convertedList.ToList<IHistorySource>());
@ -222,6 +215,7 @@ public class History{
if (allOriginalsInHistory){
// Console.WriteLine($"[INFO] Skipping SeriesId={seriesId} - originals implied by Versions already in history.");
RefreshExistingEpisodesFromBrowse(seriesGroup);
continue;
}
@ -229,6 +223,8 @@ public class History{
if (HasAllSeriesEpisodesInHistory(historyIndex, seriesId, seriesGroup)){
Console.WriteLine($"[History] Skip (already in history): {seriesId}");
var convertedList = seriesGroup.Select(crBrowseEpisode => crBrowseEpisode.ToCrunchyEpisode()).ToList();
await crunInstance.History.UpdateWithSeasonData(convertedList.ToList<IHistorySource>());
} else{
await CrUpdateSeries(seriesId, null);
Console.WriteLine($"[History] Updating (full series): {seriesId}");
@ -238,6 +234,37 @@ public class History{
return;
}
private void RefreshExistingEpisodesFromBrowse(IEnumerable<CrBrowseEpisode> episodes){
var refreshedSeriesIds = new HashSet<string>(StringComparer.Ordinal);
foreach (var episode in episodes){
var seriesId = episode.EpisodeMetadata?.SeriesId;
var seasonId = TryGetOriginalSeasonId(episode);
var episodeId = TryGetOriginalId(episode) ?? episode.Id;
if (string.IsNullOrWhiteSpace(seriesId) ||
string.IsNullOrWhiteSpace(seasonId) ||
string.IsNullOrWhiteSpace(episodeId)){
continue;
}
var historyEpisode = GetHistoryEpisode(seriesId, seasonId, episodeId);
if (historyEpisode == null){
continue;
}
var historySource = episode.ToCrunchyEpisode();
historyEpisode.UpdateAvailableMedia(historySource.GetEpisodeAvailableDubLang(), historySource.GetEpisodeAvailableSoftSubs());
historyEpisode.IsEpisodeAvailableOnStreamingService = true;
refreshedSeriesIds.Add(seriesId);
}
foreach (var seriesId in refreshedSeriesIds){
crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId)?.UpdateNewEpisodes();
}
}
private string? TryGetOriginalId(CrBrowseEpisode e) =>
e.EpisodeMetadata?.versions?
.FirstOrDefault(v => v.Original && !string.IsNullOrWhiteSpace(v.Guid))
@ -284,7 +311,7 @@ public class History{
/// <summary>
/// This method updates the History with a list of episodes. The episodes have to be from the same season.
/// </summary>
private async Task UpdateWithSeasonData(List<IHistorySource> episodeList){
private async Task UpdateWithSeasonData(List<IHistorySource> episodeList, bool matchSonarr = true){
if (episodeList is{ Count: > 0 }){
var firstEpisode = episodeList.First();
var seriesId = firstEpisode.GetSeriesId();
@ -317,6 +344,7 @@ public class History{
HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs(),
EpisodeCrPremiumAirDate = historySource.GetAvailableDate(),
EpisodeType = historySource.GetEpisodeType(),
EpisodeSeriesType = historySource.GetSeriesType(),
IsEpisodeAvailableOnStreamingService = true,
ThumbnailImageUrl = historySource.GetImageUrl(),
};
@ -336,8 +364,7 @@ public class History{
historyEpisode.IsEpisodeAvailableOnStreamingService = true;
historyEpisode.ThumbnailImageUrl = historySource.GetImageUrl();
historyEpisode.HistoryEpisodeAvailableDubLang = historySource.GetEpisodeAvailableDubLang();
historyEpisode.HistoryEpisodeAvailableSoftSubs = historySource.GetEpisodeAvailableSoftSubs();
historyEpisode.UpdateAvailableMedia(historySource.GetEpisodeAvailableDubLang(), historySource.GetEpisodeAvailableSoftSubs());
}
}
@ -348,6 +375,7 @@ public class History{
newSeason.EpisodesList.Sort(new NumericStringPropertyComparer());
historySeries.Seasons.Add(newSeason);
newSeason.StreamingService = historySeries.SeriesStreamingService;
newSeason.Init();
}
@ -376,36 +404,79 @@ public class History{
_ = historySeries.LoadImage();
historySeries.UpdateNewEpisodes();
historySeries.Init();
newSeason.Init();
}
SortItems();
if (historySeries != null){
SortSeasons(historySeries);
if (matchSonarr){
await MatchHistorySeriesWithSonarr(historySeries);
}
}
}
}
public void SetAsDownloaded(string? seriesId, string? seasonId, string episodeId){
private async Task MatchHistorySeriesWithSonarr(HistorySeries historySeries){
if (crunInstance.CrunOptions.SonarrProperties is not{ SonarrEnabled: true } ||
historySeries.SeriesType == SeriesType.Artist){
return;
}
MatchSingleHistorySeriesWithSonarr(historySeries);
await MatchHistoryEpisodesWithSonarr(false, historySeries);
}
private void MatchSingleHistorySeriesWithSonarr(HistorySeries historySeries){
if (string.IsNullOrEmpty(historySeries.SonarrSeriesId)){
var sonarrSeries = FindClosestMatch(historySeries.SeriesTitle ?? string.Empty);
if (sonarrSeries != null){
historySeries.SonarrSeriesId = sonarrSeries.Id + "";
historySeries.SonarrTvDbId = sonarrSeries.TvdbId + "";
historySeries.SonarrSlugTitle = sonarrSeries.TitleSlug;
}
} else{
var sonarrSeries = SonarrClient.Instance.SonarrSeries.FirstOrDefault(series => series.Id + "" == historySeries.SonarrSeriesId);
if (sonarrSeries != null){
historySeries.SonarrSeriesId = sonarrSeries.Id + "";
historySeries.SonarrTvDbId = sonarrSeries.TvdbId + "";
historySeries.SonarrSlugTitle = sonarrSeries.TitleSlug;
} else{
Console.Error.WriteLine($"Unable to find sonarr series for {historySeries.SeriesTitle}");
}
}
}
public void SetAsDownloaded(string? seriesId, string? seasonId, string episodeId, IEnumerable<string?>? downloadedDubs = null, IEnumerable<string?>? downloadedSoftSubs = null){
var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId);
if (historySeries != null){
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
if (historySeries == null){
return;
}
if (historySeason != null){
var historyEpisode = historySeason.EpisodesList.Find(e => e.EpisodeId == episodeId);
var historySeason = historySeries.Seasons.FirstOrDefault(s => s.SeasonId == seasonId);
if (historyEpisode != null){
historyEpisode.WasDownloaded = true;
historySeason.UpdateDownloaded();
return;
}
if (historySeason != null){
var historyEpisode = historySeason.EpisodesList.Find(e => e.EpisodeId == episodeId);
if (historyEpisode != null){
historyEpisode.SetDownloadedMedia(NormalizeLocales(downloadedDubs), NormalizeLocales(downloadedSoftSubs));
historySeason.UpdateDownloaded();
return;
}
}
MessageBus.Current.SendMessage(new ToastMessage($"Couldn't update download History", ToastType.Warning, 2));
}
private static List<string> NormalizeLocales(IEnumerable<string?>? locales){
return (locales ?? [])
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Select(locale => locale!)
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public HistoryEpisode? GetHistoryEpisode(string? seriesId, string? seasonId, string episodeId){
return CrunchyrollManager.Instance.HistoryList
.FirstOrDefault(series => series.SeriesId == seriesId)?
@ -796,10 +867,14 @@ public class History{
}
public void MatchHistorySeriesWithSonarr(bool updateAll){
if (crunInstance.CrunOptions.SonarrProperties is{ SonarrEnabled: false }){
if (crunInstance.CrunOptions.SonarrProperties is not{ SonarrEnabled: true }){
return;
}
var sonarrSeriesById = updateAll
? SonarrClient.Instance.SonarrSeries.ToDictionary(series => series.Id.ToString())
: [];
foreach (var historySeries in crunInstance.HistoryList){
if (string.IsNullOrEmpty(historySeries.SonarrSeriesId)){
var sonarrSeries = FindClosestMatch(historySeries.SeriesTitle ?? string.Empty);
@ -809,8 +884,7 @@ public class History{
historySeries.SonarrSlugTitle = sonarrSeries.TitleSlug;
}
} else if (updateAll){
var sonarrSeries = SonarrClient.Instance.SonarrSeries.FirstOrDefault(series => series.Id + "" == historySeries.SonarrSeriesId);
if (sonarrSeries != null){
if (sonarrSeriesById.TryGetValue(historySeries.SonarrSeriesId, out var sonarrSeries)){
historySeries.SonarrSeriesId = sonarrSeries.Id + "";
historySeries.SonarrTvDbId = sonarrSeries.TvdbId + "";
historySeries.SonarrSlugTitle = sonarrSeries.TitleSlug;
@ -821,120 +895,130 @@ public class History{
}
}
private static readonly object _lock = new object();
public async Task MatchHistoryEpisodesWithSonarr(bool rematchAll, HistorySeries historySeries){
if (crunInstance.CrunOptions.SonarrProperties is{ SonarrEnabled: false }){
if (crunInstance.CrunOptions.SonarrProperties is not{ SonarrEnabled: true }){
return;
}
if (!string.IsNullOrEmpty(historySeries.SonarrSeriesId)){
List<SonarrEpisode> episodes = await SonarrClient.Instance.GetEpisodes(int.Parse(historySeries.SonarrSeriesId));
historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
List<HistoryEpisode> allHistoryEpisodes = [];
foreach (var historySeriesSeason in historySeries.Seasons){
allHistoryEpisodes.AddRange(historySeriesSeason.EpisodesList);
}
if (!rematchAll){
var historyEpisodesWithSonarrIds = allHistoryEpisodes
.Where(e => !string.IsNullOrEmpty(e.SonarrEpisodeId))
.ToList();
Parallel.ForEach(historyEpisodesWithSonarrIds, historyEpisode => {
var sonarrEpisode = episodes.FirstOrDefault(e => e.Id.ToString().Equals(historyEpisode.SonarrEpisodeId));
if (sonarrEpisode != null){
historyEpisode.AssignSonarrEpisodeData(sonarrEpisode);
}
});
var historyEpisodeIds = new HashSet<string>(historyEpisodesWithSonarrIds.Select(e => e.SonarrEpisodeId!));
episodes.RemoveAll(e => historyEpisodeIds.Contains(e.Id.ToString()));
allHistoryEpisodes = allHistoryEpisodes
.Where(e => string.IsNullOrEmpty(e.SonarrEpisodeId))
.ToList();
}
List<HistoryEpisode> failedEpisodes = [];
Parallel.ForEach(allHistoryEpisodes, historyEpisode => {
if (string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId) || rematchAll){
// Create a copy of the episodes list for each thread
var episodesCopy = new List<SonarrEpisode>(episodes);
var episode = FindClosestMatchEpisodes(episodesCopy, historyEpisode.EpisodeTitle ?? string.Empty);
if (episode != null){
historyEpisode.AssignSonarrEpisodeData(episode);
lock (_lock){
episodes.Remove(episode);
}
} else{
lock (_lock){
failedEpisodes.Add(historyEpisode);
}
}
}
});
Parallel.ForEach(failedEpisodes, historyEpisode => {
var episode = episodes.Find(ele => {
if (ele == null){
return false;
}
var episodeNumberStr = ele.EpisodeNumber.ToString();
var seasonNumberStr = ele.SeasonNumber.ToString();
return episodeNumberStr == historyEpisode.Episode && seasonNumberStr == historyEpisode.EpisodeSeasonNum;
});
if (episode != null){
historyEpisode.AssignSonarrEpisodeData(episode);
lock (_lock){
episodes.Remove(episode);
}
} else{
var episode1 = episodes.Find(ele => {
if (ele == null){
return false;
}
return !string.IsNullOrEmpty(historyEpisode.EpisodeDescription) && !string.IsNullOrEmpty(ele.Overview) && Helpers.CalculateCosineSimilarity(ele.Overview, historyEpisode.EpisodeDescription) > 0.8;
});
if (episode1 != null){
historyEpisode.AssignSonarrEpisodeData(episode1);
lock (_lock){
episodes.Remove(episode1);
}
} else{
var episode2 = episodes.Find(ele => {
if (ele == null){
return false;
}
return ele.AbsoluteEpisodeNumber + "" == historyEpisode.Episode;
});
if (episode2 != null){
historyEpisode.AssignSonarrEpisodeData(episode2);
lock (_lock){
episodes.Remove(episode2);
}
} else{
Console.Error.WriteLine($"Could not match episode {historyEpisode.EpisodeTitle} to sonarr episode");
}
}
}
});
if (!int.TryParse(historySeries.SonarrSeriesId, out var sonarrSeriesId)){
return;
}
List<SonarrEpisode> episodes = await SonarrClient.Instance.GetEpisodes(sonarrSeriesId);
historySeries.SonarrNextAirDate = GetNextAirDate(episodes);
var allHistoryEpisodes = historySeries.Seasons
.SelectMany(historySeriesSeason => historySeriesSeason.EpisodesList)
.ToList();
var episodesById = episodes.ToDictionary(episode => episode.Id);
var usedSonarrEpisodeIds = new HashSet<int>();
var episodesToMatch = new List<HistoryEpisode>();
if (!rematchAll){
foreach (var historyEpisode in allHistoryEpisodes){
if (int.TryParse(historyEpisode.SonarrEpisodeId, out var sonarrEpisodeId) &&
episodesById.TryGetValue(sonarrEpisodeId, out var sonarrEpisode) &&
usedSonarrEpisodeIds.Add(sonarrEpisode.Id)){
historyEpisode.AssignSonarrEpisodeData(sonarrEpisode);
continue;
}
if (!string.IsNullOrEmpty(historyEpisode.SonarrEpisodeId)){
historyEpisode.ClearSonarrEpisodeData();
}
episodesToMatch.Add(historyEpisode);
}
} else{
foreach (var historyEpisode in allHistoryEpisodes){
historyEpisode.ClearSonarrEpisodeData();
episodesToMatch.Add(historyEpisode);
}
}
var titleAvailableEpisodes = episodes
.Where(episode => !usedSonarrEpisodeIds.Contains(episode.Id))
.ToList();
var titleCandidates = episodesToMatch
.AsParallel()
.Select(historyEpisode => {
var match = FindClosestMatchEpisodeWithScore(titleAvailableEpisodes, historyEpisode.EpisodeTitle ?? string.Empty);
return new{
HistoryEpisode = historyEpisode,
match.Episode,
match.Score
};
})
.Where(candidate => candidate.Episode != null)
.OrderByDescending(candidate => candidate.Score)
.ToList();
var failedEpisodes = new List<HistoryEpisode>();
var matchedHistoryEpisodes = new HashSet<HistoryEpisode>();
foreach (var candidate in titleCandidates){
if (TryAssignSonarrEpisode(candidate.HistoryEpisode, candidate.Episode, usedSonarrEpisodeIds)){
matchedHistoryEpisodes.Add(candidate.HistoryEpisode);
}
}
failedEpisodes.AddRange(episodesToMatch.Where(historyEpisode => !matchedHistoryEpisodes.Contains(historyEpisode)));
foreach (var historyEpisode in failedEpisodes.ToList()){
var episode = episodes.FirstOrDefault(ele => {
if (usedSonarrEpisodeIds.Contains(ele.Id)){
return false;
}
var episodeNumberStr = ele.EpisodeNumber.ToString();
var seasonNumberStr = ele.SeasonNumber.ToString();
return episodeNumberStr == historyEpisode.Episode && seasonNumberStr == historyEpisode.EpisodeSeasonNum;
});
if (TryAssignSonarrEpisode(historyEpisode, episode, usedSonarrEpisodeIds)){
failedEpisodes.Remove(historyEpisode);
}
}
foreach (var historyEpisode in failedEpisodes.ToList()){
var episode = episodes.FirstOrDefault(ele =>
!usedSonarrEpisodeIds.Contains(ele.Id) &&
!string.IsNullOrEmpty(historyEpisode.EpisodeDescription) &&
!string.IsNullOrEmpty(ele.Overview) &&
Helpers.CalculateCosineSimilarity(ele.Overview, historyEpisode.EpisodeDescription) > 0.8);
if (TryAssignSonarrEpisode(historyEpisode, episode, usedSonarrEpisodeIds)){
failedEpisodes.Remove(historyEpisode);
}
}
foreach (var historyEpisode in failedEpisodes.ToList()){
var episode = episodes.FirstOrDefault(ele =>
!usedSonarrEpisodeIds.Contains(ele.Id) &&
ele.AbsoluteEpisodeNumber + "" == historyEpisode.Episode);
if (TryAssignSonarrEpisode(historyEpisode, episode, usedSonarrEpisodeIds)){
failedEpisodes.Remove(historyEpisode);
}
}
foreach (var historyEpisode in failedEpisodes){
historyEpisode.ClearSonarrEpisodeData();
}
}
private static bool TryAssignSonarrEpisode(HistoryEpisode historyEpisode, SonarrEpisode? episode, HashSet<int> usedSonarrEpisodeIds){
if (episode == null || !usedSonarrEpisodeIds.Add(episode.Id)){
return false;
}
historyEpisode.AssignSonarrEpisodeData(episode);
return true;
}
public string GetNextAirDate(List<SonarrEpisode> episodes){
@ -980,24 +1064,27 @@ public class History{
}
public SonarrEpisode? FindClosestMatchEpisodes(List<SonarrEpisode> episodeList, string title){
return FindClosestMatchEpisodeWithScore(episodeList, title).Episode;
}
private (SonarrEpisode? Episode, double Score) FindClosestMatchEpisodeWithScore(List<SonarrEpisode> episodeList, string title){
if (string.IsNullOrWhiteSpace(title) || episodeList.Count == 0){
return (null, 0.0);
}
SonarrEpisode? closestMatch = null;
double highestSimilarity = 0.0;
object lockObject = new object(); // To synchronize access to shared variables
Parallel.ForEach(episodeList, episode => {
if (episode != null){
foreach (var episode in episodeList){
if (!string.IsNullOrWhiteSpace(episode.Title)){
double similarity = CalculateSimilarity(episode.Title, title);
lock (lockObject) // Ensure thread-safe access to shared variables
{
if (similarity > highestSimilarity){
highestSimilarity = similarity;
closestMatch = episode;
}
}
}
});
if (similarity <= highestSimilarity) continue;
return highestSimilarity < 0.8 ? null : closestMatch;
highestSimilarity = similarity;
closestMatch = episode;
}
}
return highestSimilarity < 0.8 ? (null, highestSimilarity) : (closestMatch, highestSimilarity);
}
public CrBrowseSeries? FindClosestMatchCrSeries(List<CrBrowseSeries> episodeList, string title){
@ -1075,7 +1162,32 @@ public class NumericStringPropertyComparer : IComparer<HistoryEpisode>{
return xDouble.CompareTo(yDouble);
}
if (TryParseEpisodeSortNumber(x?.Episode, out xDouble) &&
TryParseEpisodeSortNumber(y?.Episode, out yDouble)){
int numericCompare = xDouble.CompareTo(yDouble);
if (numericCompare != 0){
return numericCompare;
}
}
// Fall back to string comparison if not parseable as doubles
return string.Compare(x?.Episode, y?.Episode, StringComparison.Ordinal);
}
private static bool TryParseEpisodeSortNumber(string? episode, out double episodeNumber){
return double.TryParse(episode, NumberStyles.Any, CultureInfo.InvariantCulture, out episodeNumber) ||
TryParseEpisodeRangeStart(episode, out episodeNumber);
}
private static bool TryParseEpisodeRangeStart(string? episode, out double episodeNumber){
episodeNumber = 0;
if (string.IsNullOrWhiteSpace(episode)){
return false;
}
string[] parts = episode.Split('-', 2, StringSplitOptions.TrimEntries);
return parts.Length == 2 &&
double.TryParse(parts[0], NumberStyles.Any, CultureInfo.InvariantCulture, out episodeNumber) &&
double.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out _);
}
}

View file

@ -36,7 +36,7 @@ public sealed partial class ProgramManager : ObservableObject{
private bool fetchingData;
[ObservableProperty]
private bool updateAvailable = true;
private bool updateAvailable;
[ObservableProperty]
private bool finishedLoading;
@ -206,7 +206,7 @@ public sealed partial class ProgramManager : ObservableObject{
internal async Task RefreshHistoryWithNewReleases(CrunchyrollManager crunchyManager, CrDownloadOptions crunOptions){
var newEpisodesBase = await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunOptions.HistoryLang,
2000, null, true);
1000, null, true);
var newEpisodes = newEpisodesBase?.Data ?? [];
if (newEpisodesBase is{ Data.Count: > 0 }){
@ -244,7 +244,7 @@ public sealed partial class ProgramManager : ObservableObject{
releaseFeedEpisodes ??= (await crunchyManager.CrEpisode.GetNewEpisodes(
string.IsNullOrEmpty(crunchyManager.CrunOptions.HistoryLang) ? crunchyManager.DefaultLocale : crunchyManager.CrunOptions.HistoryLang,
2000, null, true))?.Data ?? [];
1000, null, true))?.Data ?? [];
var notificationSettings = settings.NotificationSettings;
var historyUpdated = false;
@ -338,9 +338,6 @@ public sealed partial class ProgramManager : ObservableObject{
try{
CrunchyrollManager.Instance.InitOptions();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
await Updater.Instance.CheckGhJsonAsync();
if (CrunchyrollManager.Instance.CrunOptions.AccentColor != null && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.AccentColor)){
if (_faTheme != null) _faTheme.CustomAccentColor = Color.Parse(CrunchyrollManager.Instance.CrunOptions.AccentColor);
}
@ -356,10 +353,17 @@ public sealed partial class ProgramManager : ObservableObject{
Application.Current.RequestedThemeVariant = ThemeVariant.Light;
}
}
await Updater.Instance.CheckGhJsonAsync();
await CrunchyrollManager.Instance.Init();
UpdateAvailable = await Updater.Instance.CheckForUpdatesAsync();
FinishedLoading = true;
QueueManager.Instance.UpdateDownloadListItems();
await WorkOffArgsTasks();

View file

@ -57,6 +57,7 @@ public sealed partial class QueueManager : ObservableObject{
private bool hasFailedItem;
public event EventHandler? QueueStateChanged;
public event EventHandler? QueuePersistenceRequested;
private readonly CrunchyrollManager crunchyrollManager;
@ -71,7 +72,10 @@ public sealed partial class QueueManager : ObservableObject{
crunchyrollManager.CrunOptions.SimultaneousProcessingJobs);
queue.CollectionChanged += UpdateItemListOnRemove;
queue.CollectionChanged += (_, _) => OnQueueStateChanged();
queue.CollectionChanged += (_, _) => {
OnQueueStateChanged();
OnQueuePersistenceRequested();
};
}
public void AddToQueue(CrunchyEpMeta item){
@ -97,6 +101,27 @@ public sealed partial class QueueManager : ObservableObject{
uiMutationQueue.Enqueue(() => queue.Refresh());
}
public void RefreshItem(CrunchyEpMeta item){
uiMutationQueue.Enqueue(() => RefreshItemCore(item));
}
public void NotifyQueueItemStateChanged(CrunchyEpMeta item){
uiMutationQueue.Enqueue(() => {
RefreshItemCore(item);
HasFailedItem = queue.Any(queueItem => queueItem.DownloadProgress.IsError);
OnQueueStateChanged();
OnQueuePersistenceRequested();
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
});
}
private void RefreshItemCore(CrunchyEpMeta item){
downloadItems.Find(item)?.Refresh();
}
public bool TryStartDownload(DownloadItemModel model){
var item = model.epMeta;
@ -210,7 +235,13 @@ public sealed partial class QueueManager : ObservableObject{
}
private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){
if (e.Action == NotifyCollectionChangedAction.Remove){
if (e.Action == NotifyCollectionChangedAction.Add){
if (e.NewItems != null){
foreach (var newItem in e.NewItems.OfType<CrunchyEpMeta>()){
downloadItems.AddOrRefresh(newItem);
}
}
} else if (e.Action == NotifyCollectionChangedAction.Remove){
if (e.OldItems != null){
foreach (var oldItem in e.OldItems.OfType<CrunchyEpMeta>()){
downloadItems.Remove(oldItem);
@ -218,9 +249,15 @@ public sealed partial class QueueManager : ObservableObject{
}
} else if (e.Action == NotifyCollectionChangedAction.Reset && queue.Count == 0){
downloadItems.Clear();
} else{
UpdateDownloadListItems();
}
HasFailedItem = queue.Any(item => item.DownloadProgress.IsError);
UpdateDownloadListItems();
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
}
public void MarkDownloadFinished(CrunchyEpMeta item, bool removeFromQueue){
@ -230,10 +267,12 @@ public sealed partial class QueueManager : ObservableObject{
if (index >= 0)
queue.RemoveAt(index);
} else{
queue.Refresh();
RefreshItemCore(item);
HasFailedItem = queue.Any(queueItem => queueItem.DownloadProgress.IsError);
}
OnQueueStateChanged();
OnQueuePersistenceRequested();
});
}
@ -279,6 +318,9 @@ public sealed partial class QueueManager : ObservableObject{
if (!crunchyrollManager.CrunOptions.AutoDownload)
return;
if (!ProgramManager.Instance.FinishedLoading)
return;
lock (autoDownloadBlockLock){
if (autoDownloadBlockedUntilUtc.HasValue && !HasPendingRetryItems()){
autoDownloadBlockedUntilUtc = null;
@ -382,8 +424,9 @@ public sealed partial class QueueManager : ObservableObject{
}
}
RefreshQueue();
UpdateDownloadListItems();
if (crunchyrollManager.CrunOptions.AutoDownload){
RequestPump();
}
} catch (OperationCanceledException){
// ignored
}
@ -392,8 +435,7 @@ public sealed partial class QueueManager : ObservableObject{
public void ScheduleRetry(CrunchyEpMeta item, TimeSpan delay, string statusText, CancellationToken cancellationToken = default){
item.DownloadProgress.ScheduleRetry(delay, statusText);
RefreshQueue();
OnQueueStateChanged();
NotifyQueueItemStateChanged(item);
ScheduleRetryWake(item, item.DownloadProgress.RetryAtUtc, cancellationToken);
}
@ -446,8 +488,7 @@ public sealed partial class QueueManager : ObservableObject{
}
item.DownloadProgress.RetryAtUtc = null;
RefreshQueue();
UpdateDownloadListItems();
NotifyQueueItemStateChanged(item);
} catch (OperationCanceledException){
// ignored
}
@ -458,6 +499,10 @@ public sealed partial class QueueManager : ObservableObject{
private void OnQueueStateChanged(){
QueueStateChanged?.Invoke(this, EventArgs.Empty);
}
private void OnQueuePersistenceRequested(){
QueuePersistenceRequested?.Invoke(this, EventArgs.Empty);
}
private void NotifyDownloadStateChanged(){
OnPropertyChanged(nameof(ActiveDownloads));

View file

@ -9,6 +9,7 @@ using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading;
using CRD.Downloader.Crunchyroll;
using Newtonsoft.Json;
@ -285,26 +286,39 @@ public class CfgManager{
}
private static object fileLock = new object();
public static void WriteJsonToFile(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);
}
string? directoryPath = Path.GetDirectoryName(pathToFile);
if (string.IsNullOrEmpty(directoryPath))
directoryPath = Environment.CurrentDirectory;
lock (fileLock){
using (var fileStream = new FileStream(pathToFile, FileMode.Create, FileAccess.Write, FileShare.None))
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{
using (var fileStream = new FileStream(tmp, FileMode.CreateNew, FileAccess.Write, FileShare.None))
using (var streamWriter = new StreamWriter(fileStream))
using (var jsonWriter = new JsonTextWriter(streamWriter){ Formatting = Formatting.Indented }){
var serializer = new JsonSerializer();
serializer.Serialize(jsonWriter, obj);
}
ReplaceFileWithRetry(tmp, pathToFile);
} catch (Exception ex){
try{
if (File.Exists(tmp)) File.Delete(tmp);
} catch{
// ignored
}
Console.Error.WriteLine($"An error occurred writing {pathToFile}: {ex.Message}");
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred: {ex.Message}");
}
}
@ -354,8 +368,11 @@ public class CfgManager{
throw new FileNotFoundException($"The file at path {pathToFile} does not exist.");
}
lock (fileLock){
using (var fileStream = new FileStream(pathToFile, FileMode.Open, FileAccess.Read))
string key = Path.GetFullPath(pathToFile);
object gate = _pathLocks.GetOrAdd(key, _ => new object());
lock (gate){
using (var fileStream = new FileStream(pathToFile, FileMode.Open, FileAccess.Read, FileShare.ReadWrite | FileShare.Delete))
using (var streamReader = new StreamReader(fileStream))
using (var jsonReader = new JsonTextReader(streamReader)){
var serializer = new JsonSerializer();
@ -369,12 +386,58 @@ public class CfgManager{
}
public static void DeleteFileIfExists(string pathToFile){
try{
if (File.Exists(pathToFile)){
File.Delete(pathToFile);
string key = Path.GetFullPath(pathToFile);
object gate = _pathLocks.GetOrAdd(key, _ => new object());
lock (gate){
try{
if (File.Exists(pathToFile)){
DeleteFileWithRetry(pathToFile);
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred while deleting the file {pathToFile}: {ex.Message}");
}
}
}
private static void ReplaceFileWithRetry(string sourcePath, string destinationPath){
const int maxAttempts = 5;
for (int attempt = 1; attempt <= maxAttempts; attempt++){
try{
if (File.Exists(destinationPath)){
File.Replace(sourcePath, destinationPath, null, ignoreMetadataErrors: true);
} else{
File.Move(sourcePath, destinationPath, overwrite: true);
}
return;
} catch (IOException) when (attempt < maxAttempts){
Thread.Sleep(100 * attempt);
} catch (UnauthorizedAccessException) when (attempt < maxAttempts){
Thread.Sleep(100 * attempt);
}
}
if (File.Exists(destinationPath)){
File.Replace(sourcePath, destinationPath, null, ignoreMetadataErrors: true);
} else{
File.Move(sourcePath, destinationPath, overwrite: true);
}
}
private static void DeleteFileWithRetry(string pathToFile){
const int maxAttempts = 5;
for (int attempt = 1; attempt <= maxAttempts; attempt++){
try{
File.Delete(pathToFile);
return;
} catch (IOException) when (attempt < maxAttempts){
Thread.Sleep(100 * attempt);
} catch (UnauthorizedAccessException) when (attempt < maxAttempts){
Thread.Sleep(100 * attempt);
}
} catch (Exception ex){
Console.Error.WriteLine($"An error occurred while deleting the file {pathToFile}: {ex.Message}");
}
}
}

View file

@ -78,6 +78,11 @@ public class HlsDownloader{
_cancellationToken.ThrowIfCancellationRequested();
string fn = _data.OutputFile ?? string.Empty;
if (_data.M3U8Json != null && _newDownloadMethode){
List<dynamic> segments = _data.M3U8Json.Segments;
return await DownloadSegmentsBufferedResumeAsync(segments, fn);
}
if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){
try{
Console.WriteLine("Resume data found! Trying to resume...");
@ -173,11 +178,6 @@ public class HlsDownloader{
_data.Parts.Completed = _data.Offset;
}
if (_newDownloadMethode){
return await DownloadSegmentsBufferedResumeAsync(segments, fn);
}
for (int p = 0; p < Math.Ceiling((double)segments.Count / _data.Threads); p++){
// Start time
_data.DateStart = DateTimeOffset.Now.ToUnixTimeMilliseconds();
@ -314,7 +314,7 @@ public class HlsDownloader{
return (Ok: false, _data.Parts);
}
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(_currentEpMeta);
await WaitWhilePausedAsync(_cancellationToken);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
@ -413,7 +413,7 @@ public class HlsDownloader{
return;
}
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(_currentEpMeta);
await WaitWhilePausedAsync(token);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
@ -432,25 +432,102 @@ public class HlsDownloader{
var totalSeg = _data.Parts.Total;
string sessionId = Path.GetFileNameWithoutExtension(fn);
string tempDir = Path.Combine(Path.GetDirectoryName(fn) ?? string.Empty, $"{sessionId}_temp");
string resumeFile = $"{fn}.new.resume";
Directory.CreateDirectory(tempDir);
string resumeFile = $"{fn}.new.resume";
int downloadedParts = 0;
int mergedParts = 0;
bool hasBufferedResume = false;
if (File.Exists(resumeFile)){
try{
var resumeData = JsonConvert.DeserializeObject<dynamic>(File.ReadAllText(resumeFile));
downloadedParts = (int?)resumeData?.DownloadedParts ?? 0;
mergedParts = (int?)resumeData?.MergedParts ?? 0;
var resumeData = JsonConvert.DeserializeObject<BufferedResumeData>(File.ReadAllText(resumeFile));
if (resumeData?.Total == totalSeg &&
resumeData.DownloadedParts >= 0 &&
resumeData.MergedParts >= 0 &&
resumeData.DownloadedParts <= totalSeg &&
resumeData.MergedParts <= resumeData.DownloadedParts){
downloadedParts = resumeData.DownloadedParts;
mergedParts = resumeData.MergedParts;
hasBufferedResume = mergedParts == 0 || File.Exists(fn);
} else{
Console.WriteLine("Buffered resume data is wrong!");
}
} catch{
// ignored
Console.WriteLine("Buffered resume data is wrong!");
}
}
if (downloadedParts > totalSeg) downloadedParts = totalSeg;
if (mergedParts > downloadedParts) mergedParts = downloadedParts;
if (File.Exists(fn) && !hasBufferedResume){
string rwts = !string.IsNullOrEmpty(_data.Override) ? _data.Override : "Y";
rwts = rwts.ToUpper();
if (rwts.StartsWith("Y")){
Console.WriteLine($"Deleting «{fn}»...");
File.Delete(fn);
Helpers.DeleteFile($"{fn}.resume");
CleanupNewDownloadMethod(tempDir, resumeFile);
Directory.CreateDirectory(tempDir);
downloadedParts = 0;
mergedParts = 0;
} else if (rwts.StartsWith("C")){
return (Ok: true, _data.Parts);
} else{
return (Ok: false, _data.Parts);
}
} else if (File.Exists(resumeFile) && !hasBufferedResume){
CleanupNewDownloadMethod(tempDir, resumeFile);
Directory.CreateDirectory(tempDir);
downloadedParts = 0;
mergedParts = 0;
hasBufferedResume = false;
}
if (hasBufferedResume && File.Exists(fn) && mergedParts > 0){
Console.WriteLine($"Adding content to «{fn}»...");
if (mergedParts == totalSeg){
Console.WriteLine("Already finished");
return (Ok: true, _data.Parts);
}
} else{
if (hasBufferedResume){
Console.WriteLine("Buffered resume data found! Trying to resume...");
}
Console.WriteLine($"Saving stream to «{fn}»...");
if (!File.Exists(fn) && segments.Count > 0 && segments[0].map != null && !_data.SkipInit){
Console.WriteLine("Download and save init part...");
Segment initSeg = new Segment();
initSeg.Uri = ObjectUtilities.GetMemberValue(segments[0].map, "uri");
initSeg.Key = ObjectUtilities.GetMemberValue(segments[0].map, "key");
initSeg.ByteRange = ObjectUtilities.GetMemberValue(segments[0].map, "byteRange");
if (ObjectUtilities.GetMemberValue(segments[0], "key") != null){
initSeg.Key = segments[0].Key;
}
try{
var initDl = await DownloadPart(initSeg, 0, 0);
await File.WriteAllBytesAsync(fn, initDl, _cancellationToken);
await File.WriteAllTextAsync(resumeFile, JsonConvert.SerializeObject(new BufferedResumeData{
DownloadedParts = 0,
MergedParts = 0,
Total = totalSeg
}), _cancellationToken);
Console.WriteLine("Init part downloaded.");
} catch (Exception e){
Console.Error.WriteLine($"Part init download error:\n\t{e.Message}");
return (false, _data.Parts);
}
} else if (segments.Count > 0 && segments[0].map != null && _data.SkipInit){
Console.WriteLine("Skipping init part can lead to broken video!");
}
}
_data.DateStart = DateTimeOffset.Now.ToUnixTimeMilliseconds();
var semaphore = new SemaphoreSlim(_data.Threads);
var downloadTasks = new List<Task>();
@ -550,7 +627,7 @@ public class HlsDownloader{
Doing = _isAudio ? "Merging Audio" : (_isVideo ? "Merging Video" : "")
};
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.RefreshItem(_currentEpMeta);
if (!QueueManager.Instance.Queue.Contains(_currentEpMeta)){
CleanupBufferedArtifacts();
@ -842,6 +919,12 @@ public class ResumeData{
public int Completed{ get; set; }
}
public class BufferedResumeData{
public int Total{ get; set; }
public int DownloadedParts{ get; set; }
public int MergedParts{ get; set; }
}
public class M3U8Json{
public dynamic Segments{ get; set; } = new List<dynamic>();
public int? MediaSequence{ get; set; }

View file

@ -28,9 +28,10 @@ public class HttpClientReq{
if (CrunchyrollManager.Instance.CrunOptions.ProxyEnabled && !string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.ProxyHost)){
handler = CreateHandler(true, CrunchyrollManager.Instance.CrunOptions.ProxySocks, CrunchyrollManager.Instance.CrunOptions.ProxyHost, CrunchyrollManager.Instance.CrunOptions.ProxyPort,
CrunchyrollManager.Instance.CrunOptions.ProxyUsername, CrunchyrollManager.Instance.CrunOptions.ProxyPassword);
CrunchyrollManager.Instance.CrunOptions.ProxyUsername, CrunchyrollManager.Instance.CrunOptions.ProxyPassword, CrunchyrollManager.Instance.CrunOptions.ProxyAllTraffic);
string scheme = CrunchyrollManager.Instance.CrunOptions.ProxySocks ? "socks5" : "http";
Console.Error.WriteLine($"Proxy is set: {scheme}://{CrunchyrollManager.Instance.CrunOptions.ProxyHost}:{CrunchyrollManager.Instance.CrunOptions.ProxyPort}");
string proxyScope = CrunchyrollManager.Instance.CrunOptions.ProxyAllTraffic ? "all traffic" : "Crunchyroll traffic only";
Console.Error.WriteLine($"Proxy is set for {proxyScope}: {scheme}://{CrunchyrollManager.Instance.CrunOptions.ProxyHost}:{CrunchyrollManager.Instance.CrunOptions.ProxyPort}");
client = new HttpClient(handler);
} else if (systemProxy != null){
Uri testUri = new Uri("https://icanhazip.com");
@ -93,7 +94,7 @@ public class HttpClientReq{
};
}
private HttpClientHandler CreateHandler(bool useProxy, bool useSocks = false, string? proxyHost = null, int proxyPort = 0, string? proxyUsername = "", string? proxyPassword = ""){
private HttpClientHandler CreateHandler(bool useProxy, bool useSocks = false, string? proxyHost = null, int proxyPort = 0, string? proxyUsername = "", string? proxyPassword = "", bool proxyAllTraffic = true){
var handler = new HttpClientHandler{
CookieContainer = new CookieContainer(),
UseCookies = true,
@ -103,15 +104,46 @@ public class HttpClientReq{
if (useProxy && proxyHost != null){
string scheme = useSocks ? "socks5" : "http";
handler.Proxy = new WebProxy($"{scheme}://{proxyHost}:{proxyPort}");
if (!string.IsNullOrEmpty(proxyUsername) && !string.IsNullOrEmpty(proxyPassword)){
handler.Proxy.Credentials = new NetworkCredential(proxyUsername, proxyPassword);
}
var proxy = new WebProxy($"{scheme}://{proxyHost}:{proxyPort}");
proxy.Credentials = !string.IsNullOrEmpty(proxyUsername) && !string.IsNullOrEmpty(proxyPassword)
? new NetworkCredential(proxyUsername, proxyPassword)
: null;
handler.Proxy = proxyAllTraffic ? proxy : new CrunchyrollOnlyProxy(proxy);
}
return handler;
}
private sealed class CrunchyrollOnlyProxy(WebProxy proxy): IWebProxy{
public ICredentials? Credentials{
get => proxy.Credentials;
set => proxy.Credentials = value;
}
public Uri GetProxy(Uri destination){
return IsCrunchyrollProxyTarget(destination) ? proxy.GetProxy(destination) : destination;
}
public bool IsBypassed(Uri host){
return !IsCrunchyrollProxyTarget(host);
}
private static bool IsCrunchyrollProxyTarget(Uri destination){
if (!destination.Scheme.Equals(Uri.UriSchemeHttp, StringComparison.OrdinalIgnoreCase) &&
!destination.Scheme.Equals(Uri.UriSchemeHttps, StringComparison.OrdinalIgnoreCase)){
return false;
}
if (destination.ToString().Equals(ApiUrls.WidevineLicenceUrl, StringComparison.OrdinalIgnoreCase)){
return false;
}
return destination.Host.Equals("crunchyroll.com", StringComparison.OrdinalIgnoreCase) ||
destination.Host.EndsWith(".crunchyroll.com", StringComparison.OrdinalIgnoreCase);
}
}
public async Task<(bool IsOk, string ResponseContent, string error, Dictionary<string, string> Headers)> SendHttpRequest(HttpRequestMessage request, bool suppressError = false, Dictionary<string, CookieCollection>? cookieStore = null,
bool allowChallengeBypass = true){
string content = string.Empty;
@ -397,5 +429,5 @@ public static class ApiUrls{
public static string authBasicMob = "Basic Ym1icmt4eXgzZDd1NmpzZnlsYTQ6QUlONEQ1VkVfY3Awd1Z6Zk5vUDBZcUhVcllGcDloU2c=";
public static readonly string MobileUserAgent = "Crunchyroll/3.81.6 Android/16";
public static readonly string FirefoxUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:137.0) Gecko/20100101 Firefox/137.0";
}
public static readonly string FirefoxUserAgent = "Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:151.0) Gecko/20100101 Firefox/151.0";
}

View file

@ -13,6 +13,7 @@ public class FFmpegCommandBuilder : CommandBuilder{
private readonly List<string> metaData = new();
private int index;
private int videoIndex;
private int audioIndex;
private bool hasVideo;
@ -55,14 +56,25 @@ public class FFmpegCommandBuilder : CommandBuilder{
Add($"-i \"{vid.Path}\"");
metaData.Add($"-map {index}:v");
metaData.Add($"-metadata:s:v:{index} title=\"{vid.Language.Name}\"");
metaData.Add($"-metadata:s:v:{videoIndex} title=\"{vid.Language.Name}\"");
AddVideoDisposition(vid);
hasVideo = true;
index++;
videoIndex++;
}
}
}
private void AddVideoDisposition(MergerInput vid){
if (Options.Defaults.Video?.Code == vid.Language.Code &&
Options.Defaults.Video != Languages.DEFAULT_lang){
metaData.Add($"-disposition:v:{videoIndex} default");
} else{
metaData.Add($"-disposition:v:{videoIndex} 0");
}
}
private void AddAudioInputs(){
foreach (var aud in Options.OnlyAudio){
if (aud.Delay is{ } delay && delay != 0){
@ -249,4 +261,4 @@ public class FFmpegCommandBuilder : CommandBuilder{
File.WriteAllLines(chapterFilePath, ffmpegChapterLines);
}
}
}

View file

@ -1,4 +1,5 @@
using System;
using System.IO;
using System.Linq;
using CRD.Utils.Muxing.Structs;
using CRD.Utils.Structs;
@ -81,7 +82,14 @@ public class MkvMergeCommandBuilder(MergerOptions options) : CommandBuilder(opti
}
private void AddVideoOnly(){
foreach (var vid in Options.OnlyVid){
var sorted = Options.OnlyVid
.OrderBy(a => {
var index = Options.DubLangList.IndexOf(a.Language.CrLocale);
return index != -1 ? index : int.MaxValue;
})
.ToList();
foreach (var vid in sorted){
if (!hasVideo || Options.KeepAllVideos){
Add("--video-tracks 0");
Add("--no-audio");
@ -119,6 +127,16 @@ public class MkvMergeCommandBuilder(MergerOptions options) : CommandBuilder(opti
private void AddTrackMetadata(string trackNum, LanguageItem lang){
Add($"--track-name {trackNum}:\"{lang.Name}\"");
Add($"--language {trackNum}:{lang.Code}");
AddDefaultVideo(trackNum, lang);
}
private void AddDefaultVideo(string trackNum, LanguageItem lang){
if (Options.Defaults.Video?.Code == lang.Code &&
Options.Defaults.Video != Languages.DEFAULT_lang){
Add($"--default-track {trackNum}");
} else{
Add($"--default-track {trackNum}:0");
}
}
private void AddAudioMetadata(string trackNum, MergerInput track){
@ -236,9 +254,14 @@ public class MkvMergeCommandBuilder(MergerOptions options) : CommandBuilder(opti
var cover = Options.Cover.FirstOrDefault();
if (cover?.Path != null){
if (!File.Exists(cover.Path)){
Console.Error.WriteLine($"Cover file not found, skipping attachment: {cover.Path}");
return;
}
Add($"--attach-file \"{cover.Path}\"");
Add("--attachment-mime-type image/png");
Add("--attachment-name cover.png");
}
}
}
}

View file

@ -15,11 +15,13 @@ public class CrunchyMuxOptions{
public bool MuxFonts{ get; set; }
public bool MuxCover{ get; set; }
public bool MuxDescription{ get; set; }
public bool ReplaceExistingFiles{ get; set; }
public string ForceMuxer{ get; set; }
public bool NoCleanup{ get; set; }
public string VideoTitle{ get; set; }
public List<string> FfmpegOptions{ get; set; } = [];
public List<string> MkvmergeOptions{ get; set; } = [];
public LanguageItem? DefaultVideo{ get; set; }
public LanguageItem? DefaultSub{ get; set; }
public LanguageItem? DefaultAudio{ get; set; }
public string CcTag{ get; set; }
@ -31,4 +33,4 @@ public class CrunchyMuxOptions{
public bool DefaultSubForcedDisplay{ get; set; }
public bool CcSubsMuxingFlag{ get; set; }
public bool SignsSubsAsForced{ get; set; }
}
}

View file

@ -29,6 +29,7 @@ public class MergerOptions{
}
public class Defaults{
public LanguageItem? Video{ get; set; }
public LanguageItem? Audio{ get; set; }
public LanguageItem? Sub{ get; set; }
}
@ -36,4 +37,4 @@ public class Defaults{
public class MuxOptions{
public List<string>? Ffmpeg{ get; set; }
public List<string>? Mkvmerge{ get; set; }
}
}

View file

@ -20,7 +20,7 @@ public class SyncingHelper{
public static async Task<(bool IsOk, int ErrorCode, double frameRate)> ExtractFrames(string videoPath, string outputDir, double offset, double duration){
var ffmpegPath = CfgManager.PathFFMPEG;
var arguments =
$"{CrunchyrollManager.Instance.CrunOptions.FfmpegHwAccelFlag}-ss {offset} -t {duration} -i \"{videoPath}\" -vf \"select='gt(scene,0.1)',showinfo\" -vsync vfr -frame_pts true \"{outputDir}\\frame%05d.jpg\"";
$"{CrunchyrollManager.Instance.CrunOptions.FfmpegHwAccelFlag}-ss {offset} -t {duration} -i \"{videoPath}\" -vf \"scale=256:144:force_original_aspect_ratio=decrease,pad=256:144:(ow-iw)/2:(oh-ih)/2,select='gt(scene,0.1)',showinfo\" -vsync vfr -frame_pts true \"{outputDir}\\frame%05d.jpg\"";
var output = "";

View file

@ -31,18 +31,22 @@ public sealed class DownloadItemModelCollection{
items.Clear();
}
public void AddOrRefresh(CrunchyEpMeta queueItem){
if (models.TryGetValue(queueItem, out var existingModel)){
existingModel.Refresh();
return;
}
var newModel = new DownloadItemModel(queueItem);
models.Add(queueItem, newModel);
items.Add(newModel);
_ = newModel.LoadImage();
}
public void SyncFromQueue(IEnumerable<CrunchyEpMeta> queueItems){
foreach (var queueItem in queueItems){
if (models.TryGetValue(queueItem, out var existingModel)){
existingModel.Refresh();
continue;
}
var newModel = new DownloadItemModel(queueItem);
models.Add(queueItem, newModel);
items.Add(newModel);
_ = newModel.LoadImage();
AddOrRefresh(queueItem);
}
}
}
}

View file

@ -17,7 +17,7 @@ public sealed class QueuePersistenceManager : IDisposable{
public QueuePersistenceManager(QueueManager queueManager){
this.queueManager = queueManager ?? throw new ArgumentNullException(nameof(queueManager));
this.queueManager.QueueStateChanged += OnQueueStateChanged;
this.queueManager.QueuePersistenceRequested += OnQueuePersistenceRequested;
}
public void RestoreQueue(){
@ -58,7 +58,7 @@ public sealed class QueuePersistenceManager : IDisposable{
}
}
private void OnQueueStateChanged(object? sender, EventArgs e){
private void OnQueuePersistenceRequested(object? sender, EventArgs e){
ScheduleSave();
}
@ -119,6 +119,6 @@ public sealed class QueuePersistenceManager : IDisposable{
saveTimer = null;
}
queueManager.QueueStateChanged -= OnQueueStateChanged;
queueManager.QueuePersistenceRequested -= OnQueuePersistenceRequested;
}
}

View file

@ -2,10 +2,12 @@
using System.Collections.Generic;
using System.Collections.Specialized;
using System.Diagnostics;
using System.Linq;
using System.Net.Http;
using System.Reflection;
using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using CRD.Downloader.Crunchyroll;
using CRD.Utils.Sonarr.Models;
@ -20,6 +22,11 @@ public class SonarrClient{
private SonarrProperties properties;
private readonly SemaphoreSlim refreshLock = new(1, 1);
private readonly TimeSpan sonarrSeriesCacheDuration = TimeSpan.FromMinutes(2);
private DateTime lastSonarrSeriesRefreshUtc = DateTime.MinValue;
private string? lastSonarrSeriesSettingsKey;
public List<SonarrSeries> SonarrSeries =[];
#region Singelton
@ -48,27 +55,64 @@ public class SonarrClient{
}
public async Task RefreshSonarr(){
await CheckSonarrSettings();
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true }){
SonarrSeries = await GetSeries();
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(true);
foreach (var historySeries in CrunchyrollManager.Instance.HistoryList){
if (!string.IsNullOrEmpty(historySeries.SonarrSeriesId)){
List<SonarrEpisode>? episodes = await GetEpisodes(int.Parse(historySeries.SonarrSeriesId));
historySeries.SonarrNextAirDate = CrunchyrollManager.Instance.History.GetNextAirDate(episodes);
}
}
if (!await refreshLock.WaitAsync(0)){
Console.WriteLine("[Sonarr Refresh] Skipped because another Sonarr refresh is already running.");
return;
}
try{
await CheckSonarrSettings();
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true }){
SonarrSeries = await GetSeriesForRefresh();
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(true);
var matchedHistorySeries = CrunchyrollManager.Instance.HistoryList
.Select(historySeries => new{
HistorySeries = historySeries,
IsValidSonarrSeriesId = int.TryParse(historySeries.SonarrSeriesId, out var sonarrSeriesId),
SonarrSeriesId = sonarrSeriesId
})
.Where(item => item.IsValidSonarrSeriesId)
.ToList();
using var throttler = new SemaphoreSlim(8);
var refreshTasks = matchedHistorySeries.Select(async item => {
await throttler.WaitAsync();
try{
var episodes = await GetEpisodes(item.SonarrSeriesId);
item.HistorySeries.SonarrNextAirDate = CrunchyrollManager.Instance.History.GetNextAirDate(episodes);
} finally{
throttler.Release();
}
});
await Task.WhenAll(refreshTasks);
}
} finally{
refreshLock.Release();
}
}
public async Task RefreshSonarrLite(){
await CheckSonarrSettings();
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true }){
SonarrSeries = await GetSeries();
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(true);
if (!await refreshLock.WaitAsync(0)){
Console.WriteLine("[Sonarr Lite Refresh] Skipped because another Sonarr refresh is already running.");
return;
}
try{
await CheckSonarrSettings();
if (CrunchyrollManager.Instance.CrunOptions.SonarrProperties is{ SonarrEnabled: true }){
SonarrSeries = await GetSeriesForRefresh();
CrunchyrollManager.Instance.History.MatchHistorySeriesWithSonarr(true);
}
} finally{
refreshLock.Release();
}
}
@ -79,6 +123,33 @@ public class SonarrClient{
apiUrl = $"http{(properties.UseSsl ? "s" : "")}://{(!string.IsNullOrEmpty(properties.Host) ? properties.Host : "localhost")}:{properties.Port}{(properties.UrlBase ?? "")}/api";
}
}
private async Task<List<SonarrSeries>> GetSeriesForRefresh(){
var settingsKey = GetSonarrSettingsKey();
if (SonarrSeries.Count > 0 &&
settingsKey == lastSonarrSeriesSettingsKey &&
DateTime.UtcNow - lastSonarrSeriesRefreshUtc < sonarrSeriesCacheDuration){
return SonarrSeries;
}
var series = await GetSeries();
lastSonarrSeriesRefreshUtc = DateTime.UtcNow;
lastSonarrSeriesSettingsKey = settingsKey;
return series;
}
private string GetSonarrSettingsKey(){
if (properties == null) return string.Empty;
return string.Join('|',
properties.UseSsl,
properties.Host ?? string.Empty,
properties.Port,
properties.UrlBase ?? string.Empty,
properties.ApiKey ?? string.Empty);
}
public async Task CheckSonarrSettings(){
@ -222,4 +293,4 @@ public class SonarrProperties(){
public bool UseSonarrNumbering{ get; set; }
public bool SonarrEnabled{ get; set; }
}
}

View file

@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.ComponentModel;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Media;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
@ -22,7 +23,18 @@ public class CalendarDay{
public List<CalendarEpisode> CalendarEpisodes{ get; set; } =[];
}
public enum CalendarHistoryDownloadState{
None,
NotDownloaded,
PartlyDownloaded,
Downloaded
}
public partial class CalendarEpisode : INotifyPropertyChanged{
private bool _isInHistory;
private bool _showHistoryMark = true;
private CalendarHistoryDownloadState _historyDownloadState;
public DateTime DateTime{ get; set; }
public bool? HasPassed{ get; set; }
public string? EpisodeName{ get; set; }
@ -40,11 +52,79 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
public string? CrSeriesID{ get; set; }
public string? CrSeasonID{ get; set; }
public string? CrEpisodeID{ get; set; }
public bool AnilistEpisode{ get; set; }
public bool FilteredOut{ get; set; }
public Locale? AudioLocale{ get; set; }
public List<CrBrowseEpisodeVersion>? Versions{ get; set; }
public string? OriginalEpisodeGuid{ get; set; }
public string? OriginalSeasonGuid{ get; set; }
public List<string> VersionGuids{ get; set; } =[];
public bool IsInHistory{
get => _isInHistory;
set{
if (_isInHistory == value){
return;
}
_isInHistory = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsInHistory)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHistoryMarkVisible)));
}
}
public bool ShowHistoryMark{
get => _showHistoryMark;
set{
if (_showHistoryMark == value){
return;
}
_showHistoryMark = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowHistoryMark)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(IsHistoryMarkVisible)));
}
}
public bool IsHistoryMarkVisible => IsInHistory && ShowHistoryMark && !AnilistEpisode;
public CalendarHistoryDownloadState HistoryDownloadState{
get => _historyDownloadState;
set{
if (_historyDownloadState == value){
return;
}
_historyDownloadState = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HistoryDownloadState)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HistoryDownloadStatusBrush)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HistoryDownloadStatusTooltip)));
}
}
public IBrush HistoryDownloadStatusBrush => HistoryDownloadState switch{
CalendarHistoryDownloadState.Downloaded => new SolidColorBrush(Color.Parse("#21a556")),
CalendarHistoryDownloadState.PartlyDownloaded => new SolidColorBrush(Color.Parse("#f78c25")),
CalendarHistoryDownloadState.NotDownloaded => Brushes.Gray,
_ => Brushes.Transparent
};
public string HistoryDownloadStatusTooltip => HistoryDownloadState switch{
CalendarHistoryDownloadState.Downloaded => "Downloaded",
CalendarHistoryDownloadState.PartlyDownloaded => "Partly downloaded",
CalendarHistoryDownloadState.NotDownloaded => "Not downloaded",
_ => string.Empty
};
public List<CalendarEpisode> CalendarEpisodes{ get; set; } =[];
@ -80,4 +160,4 @@ public partial class CalendarEpisode : INotifyPropertyChanged{
Console.Error.WriteLine("Failed to load image: " + ex.Message);
}
}
}
}

View file

@ -83,6 +83,9 @@ public class CrDownloadOptions{
[JsonProperty("download_only_with_all_selected_dubsub")]
public bool DownloadOnlyWithAllSelectedDubSub{ get; set; }
[JsonProperty("replace_existing_files")]
public bool ReplaceExistingFiles{ get; set; }
[JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; }
@ -104,6 +107,9 @@ public class CrDownloadOptions{
[JsonProperty("history_count_missing")]
public bool HistoryCountMissing{ get; set; }
[JsonProperty("history_check_partial_downloads")]
public bool HistoryCheckPartialDownloads{ get; set; }
[JsonProperty("history_include_cr_artists")]
public bool HistoryIncludeCrArtists{ get; set; }
@ -165,6 +171,9 @@ public class CrDownloadOptions{
[JsonProperty("proxy_enabled")]
public bool ProxyEnabled{ get; set; }
[JsonProperty("proxy_all_traffic")]
public bool ProxyAllTraffic{ get; set; } = true;
[JsonProperty("proxy_socks")]
public bool ProxySocks{ get; set; }
@ -247,8 +256,11 @@ public class CrDownloadOptions{
[JsonProperty("download_part_size")]
public int Partsize{ get; set; }
[JsonProperty("dub_download_delay_seconds")]
public int DubDownloadDelaySeconds{ get; set; }
[JsonProperty("download_delay_seconds")]
public int DownloadDelaySeconds{ get; set; }
[JsonProperty("download_delay_use_dub_based")]
public bool DownloadDelayUseDubBased{ get; set; }
[JsonProperty("soft_subs")]
public List<string> DlSubs{ get; set; } =[];
@ -325,6 +337,9 @@ public class CrDownloadOptions{
[JsonProperty("mux_default_sub_forced_display")]
public bool DefaultSubForcedDisplay{ get; set; }
[JsonProperty("mux_default_video")]
public string DefaultVideo{ get; set; } = "";
[JsonProperty("mux_default_dub")]
public string DefaultAudio{ get; set; } = "";
@ -379,6 +394,9 @@ public class CrDownloadOptions{
[JsonProperty("calendar_update_history")]
public bool UpdateHistoryFromCalendar{ get; set; }
[JsonProperty("calendar_show_history_mark")]
public bool CalendarShowHistoryMark{ get; set; } = true;
[JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; }
@ -388,6 +406,15 @@ public class CrDownloadOptions{
[JsonProperty("search_fetch_featured_music")]
public bool SearchFetchFeaturedMusic{ get; set; }
[JsonProperty("add_download_search_add_to_history")]
public bool AddDownloadSearchAddToHistory{ get; set; } = true;
[JsonProperty("add_download_single_episode_instant_add")]
public bool AddDownloadSingleEpisodeInstantAdd{ get; set; } = true;
[JsonProperty("add_download_default_search_enabled")]
public bool AddDownloadDefaultSearchEnabled{ get; set; }
#endregion
public void NormalizeNotificationSettings(){

View file

@ -246,9 +246,16 @@ public class CrunchyEpisode : IHistorySource{
}
public bool IsSpecialSeason(){
if (SeasonTitle.Contains("OVA", StringComparison.Ordinal) ||
SeasonTitle.Contains("Special", StringComparison.Ordinal) ||
SeasonTitle.Contains("Extra", StringComparison.Ordinal)){
var seasonSpecificTitle = SeasonTitle;
if (!string.IsNullOrWhiteSpace(SeriesTitle) &&
seasonSpecificTitle.StartsWith(SeriesTitle, StringComparison.OrdinalIgnoreCase)){
seasonSpecificTitle = seasonSpecificTitle[SeriesTitle.Length..];
}
if (Regex.IsMatch(
seasonSpecificTitle,
@"\b(OVA|Specials?|Extras?)\b",
RegexOptions.IgnoreCase | RegexOptions.CultureInvariant)){
return true;
}
@ -263,7 +270,12 @@ public class CrunchyEpisode : IHistorySource{
}
public bool IsSpecialEpisode(){
return !int.TryParse(Episode, out _);
return Episode != null && !IsRegularEpisodeNumber(Episode);
}
public static bool IsRegularEpisodeNumber(string? episode){
return !string.IsNullOrWhiteSpace(episode) &&
Regex.IsMatch(episode, @"^\d+(\.\d+)?(\s*-\s*\d+(\.\d+)?)?$");
}
public List<string> GetAnimeIds(){

View file

@ -7,6 +7,9 @@ namespace CRD.Utils.Structs.Crunchyroll;
public class StreamError{
[JsonPropertyName("error")]
public string? Error{ get; set; }
[JsonPropertyName("reason")]
public string? Reason{ get; set; }
[JsonPropertyName("activeStreams")]
public List<ActiveStream> ActiveStreams{ get; set; } = new ();

View file

@ -34,6 +34,12 @@ public class HistoryEpisode : INotifyPropertyChanged{
[JsonProperty("episode_was_downloaded")]
public bool WasDownloaded{ get; set; }
[JsonProperty("episode_downloaded_dub_lang")]
public List<string> DownloadedDubLang{ get; set; } = [];
[JsonProperty("episode_downloaded_soft_subs")]
public List<string> DownloadedSoftSubs{ get; set; } = [];
[JsonProperty("episode_tracked_series_release_notified")]
public bool TrackedSeriesReleaseNotified{ get; set; }
@ -126,14 +132,86 @@ public class HistoryEpisode : INotifyPropertyChanged{
public event PropertyChangedEventHandler? PropertyChanged;
public bool IsPartiallyDownloaded(IEnumerable<string> requestedDubs, IEnumerable<string> requestedSoftSubs){
return WasDownloaded &&
(HasMissingAvailableItem(requestedDubs, DownloadedDubLang, HistoryEpisodeAvailableDubLang) ||
HasMissingAvailableItem(requestedSoftSubs, DownloadedSoftSubs, HistoryEpisodeAvailableSoftSubs));
}
public bool HasAvailableMissingDownloadedMedia(IEnumerable<string> requestedDubs, IEnumerable<string> requestedSoftSubs){
return WasDownloaded &&
(HasMissingAvailableItem(requestedDubs, DownloadedDubLang, HistoryEpisodeAvailableDubLang) ||
HasMissingAvailableItem(requestedSoftSubs, DownloadedSoftSubs, HistoryEpisodeAvailableSoftSubs));
}
private static bool HasMissingAvailableItem(IEnumerable<string> requested, List<string> downloaded, List<string> available){
var requestedList = requested
.Where(item => !string.IsNullOrWhiteSpace(item))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (requestedList.Count == 0 ||
requestedList.Contains("none", StringComparer.OrdinalIgnoreCase) ||
downloaded.Count == 0 ||
available.Count == 0){
return false;
}
var downloadedSet = downloaded.ToHashSet(StringComparer.OrdinalIgnoreCase);
var availableSet = available.ToHashSet(StringComparer.OrdinalIgnoreCase);
var requestedAvailableItems = requestedList.Contains("all", StringComparer.OrdinalIgnoreCase)
? availableSet
: requestedList.Where(availableSet.Contains);
return requestedAvailableItems.Any(item => !downloadedSet.Contains(item));
}
private void NotifyDownloadStateChanged(){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WasDownloaded)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedDubLang)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedSoftSubs)));
}
public void RefreshDownloadState(){
NotifyDownloadStateChanged();
}
private void NotifyAvailableMediaChanged(){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HistoryEpisodeAvailableDubLang)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(HistoryEpisodeAvailableSoftSubs)));
}
public void SetDownloadedMedia(List<string> downloadedDubs, List<string> downloadedSoftSubs){
WasDownloaded = true;
DownloadedDubLang = MergeDownloadedLocales(DownloadedDubLang, downloadedDubs);
DownloadedSoftSubs = MergeDownloadedLocales(DownloadedSoftSubs, downloadedSoftSubs);
NotifyDownloadStateChanged();
}
private static List<string> MergeDownloadedLocales(IEnumerable<string> existingLocales, IEnumerable<string> newLocales){
return existingLocales
.Concat(newLocales)
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
}
public void UpdateAvailableMedia(List<string> availableDubs, List<string> availableSoftSubs){
HistoryEpisodeAvailableDubLang = availableDubs.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
HistoryEpisodeAvailableSoftSubs = availableSoftSubs.Distinct(StringComparer.OrdinalIgnoreCase).ToList();
NotifyAvailableMediaChanged();
}
public void ToggleWasDownloaded(){
WasDownloaded = !WasDownloaded;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WasDownloaded)));
ClearDownloadedMediaIfNotDownloaded();
NotifyDownloadStateChanged();
}
public void ToggleWasDownloadedSeries(HistorySeries? series){
WasDownloaded = !WasDownloaded;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WasDownloaded)));
ClearDownloadedMediaIfNotDownloaded();
NotifyDownloadStateChanged();
if (series?.Seasons != null){
foreach (var historySeason in series.Seasons){
@ -146,6 +224,14 @@ public class HistoryEpisode : INotifyPropertyChanged{
CfgManager.UpdateHistoryFile();
}
private void ClearDownloadedMediaIfNotDownloaded(){
if (WasDownloaded)
return;
DownloadedDubLang.Clear();
DownloadedSoftSubs.Clear();
}
public async Task DownloadEpisodeDefault(){
await DownloadEpisode(EpisodeDownloadMode.Default,"",false);
}
@ -184,4 +270,15 @@ public class HistoryEpisode : INotifyPropertyChanged{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SonarrSeasonEpisodeText)));
}
public void ClearSonarrEpisodeData(){
SonarrEpisodeId = null;
SonarrEpisodeNumber = null;
SonarrHasFile = false;
SonarrIsMonitored = false;
SonarrAbsolutNumber = null;
SonarrSeasonNumber = null;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SonarrSeasonEpisodeText)));
}
}

View file

@ -0,0 +1,42 @@
using System.Collections.ObjectModel;
using System.Linq;
namespace CRD.Utils.Structs.History;
public static class HistoryOverrideOptions{
public static ObservableCollection<string> GetVideoQualityList(StreamingService streamingService) =>
streamingService switch{
StreamingService.Crunchyroll => CrunchyrollVideoQualityList,
_ => CrunchyrollVideoQualityList,
};
public static ObservableCollection<string> GetDubLangList(StreamingService streamingService) =>
streamingService switch{
StreamingService.Crunchyroll => CrunchyrollDubLangList,
_ => CrunchyrollDubLangList,
};
public static ObservableCollection<string> GetSubLangList(StreamingService streamingService) =>
streamingService switch{
StreamingService.Crunchyroll => CrunchyrollSubLangList,
_ => CrunchyrollSubLangList,
};
private static ObservableCollection<string> CrunchyrollVideoQualityList{ get; } = new(){
"best",
"1080p",
"720p",
"480p",
"360p",
"240p",
"worst",
};
private static ObservableCollection<string> CrunchyrollDubLangList{ get; } = new(
Languages.languages.Select(languageItem => languageItem.CrLocale)
);
private static ObservableCollection<string> CrunchyrollSubLangList{ get; } = new(
new[]{ "all", "none" }.Concat(Languages.languages.Select(languageItem => languageItem.CrLocale))
);
}

View file

@ -45,24 +45,31 @@ public class HistorySeason : INotifyPropertyChanged{
[JsonIgnore]
public bool IsExpanded{ get; set; }
[JsonIgnore]
public StreamingService StreamingService{ get; set; } = StreamingService.Unknown;
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propertyName){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
#region Settings Override
[JsonIgnore]
public StringItem? _selectedVideoQualityItem;
public string _selectedVideoQualityItem = "";
[JsonIgnore]
private bool Loading;
[JsonIgnore]
public StringItem? SelectedVideoQualityItem{
public string SelectedVideoQualityItem{
get => _selectedVideoQualityItem;
set{
_selectedVideoQualityItem = value;
_selectedVideoQualityItem = value ?? "";
HistorySeasonVideoQualityOverride = value?.stringValue ?? "";
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedVideoQualityItem)));
HistorySeasonVideoQualityOverride = _selectedVideoQualityItem;
OnPropertyChanged(nameof(SelectedVideoQualityItem));
if (!Loading){
CfgManager.UpdateHistoryFile();
}
@ -76,31 +83,19 @@ public class HistorySeason : INotifyPropertyChanged{
public string SelectedDubs{ get; set; } = "";
[JsonIgnore]
public ObservableCollection<StringItem> SelectedSubLang{ get; set; } = new();
public ObservableCollection<string> SelectedSubLang{ get; set; } = new();
[JsonIgnore]
public ObservableCollection<StringItem> SelectedDubLang{ get; set; } = new();
public ObservableCollection<string> SelectedDubLang{ get; set; } = new();
[JsonIgnore]
public ObservableCollection<StringItem> DubLangList{ get; } = new(){
};
public ObservableCollection<string> DubLangList => HistoryOverrideOptions.GetDubLangList(StreamingService);
[JsonIgnore]
public ObservableCollection<StringItem> SubLangList{ get; } = new(){
new StringItem(){ stringValue = "all" },
new StringItem(){ stringValue = "none" },
};
public ObservableCollection<string> SubLangList => HistoryOverrideOptions.GetSubLangList(StreamingService);
[JsonIgnore]
public ObservableCollection<StringItem> VideoQualityList{ get; } = new(){
new StringItem(){ stringValue = "best" },
new StringItem(){ stringValue = "1080p" },
new StringItem(){ stringValue = "720p" },
new StringItem(){ stringValue = "480p" },
new StringItem(){ stringValue = "360p" },
new StringItem(){ stringValue = "240p" },
new StringItem(){ stringValue = "worst" },
};
public ObservableCollection<string> VideoQualityList => HistoryOverrideOptions.GetVideoQualityList(StreamingService);
private void UpdateSubAndDubString(){
HistorySeasonSoftSubsOverride.Clear();
@ -108,13 +103,13 @@ public class HistorySeason : INotifyPropertyChanged{
if (SelectedSubLang.Count != 0){
for (var i = 0; i < SelectedSubLang.Count; i++){
HistorySeasonSoftSubsOverride.Add(SelectedSubLang[i].stringValue);
HistorySeasonSoftSubsOverride.Add(SelectedSubLang[i]);
}
}
if (SelectedDubLang.Count != 0){
for (var i = 0; i < SelectedDubLang.Count; i++){
HistorySeasonDubLangOverride.Add(SelectedDubLang[i].stringValue);
HistorySeasonDubLangOverride.Add(SelectedDubLang[i]);
}
}
@ -122,8 +117,8 @@ public class HistorySeason : INotifyPropertyChanged{
SelectedSubs = string.Join(", ", HistorySeasonSoftSubsOverride) ?? "";
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedSubs)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDubs)));
OnPropertyChanged(nameof(SelectedSubs));
OnPropertyChanged(nameof(SelectedDubs));
CfgManager.UpdateHistoryFile();
}
@ -133,18 +128,14 @@ public class HistorySeason : INotifyPropertyChanged{
}
public void Init(){
SelectedSubLang.CollectionChanged -= Changes;
SelectedDubLang.CollectionChanged -= Changes;
Loading = true;
if (!(SubLangList.Count > 2 || DubLangList.Count > 0)){
foreach (var languageItem in Languages.languages){
SubLangList.Add(new StringItem{ stringValue = languageItem.CrLocale });
DubLangList.Add(new StringItem{ stringValue = languageItem.CrLocale });
}
}
SelectedVideoQualityItem = VideoQualityList.FirstOrDefault(HistorySeasonVideoQualityOverride.Equals) ?? "";
SelectedVideoQualityItem = VideoQualityList.FirstOrDefault(a => HistorySeasonVideoQualityOverride.Equals(a.stringValue)) ?? new StringItem(){ stringValue = "" };
var softSubLang = SubLangList.Where(a => HistorySeasonSoftSubsOverride.Contains(a.stringValue)).ToList();
var dubLang = DubLangList.Where(a => HistorySeasonDubLangOverride.Contains(a.stringValue)).ToList();
var softSubLang = SubLangList.Where(HistorySeasonSoftSubsOverride.Contains).ToList();
var dubLang = DubLangList.Where(HistorySeasonDubLangOverride.Contains).ToList();
SelectedSubLang.Clear();
foreach (var listBoxItem in softSubLang){
@ -173,18 +164,18 @@ public class HistorySeason : INotifyPropertyChanged{
}
DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes)));
OnPropertyChanged(nameof(DownloadedEpisodes));
CfgManager.UpdateHistoryFile();
}
public void UpdateDownloaded(){
DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes)));
OnPropertyChanged(nameof(DownloadedEpisodes));
CfgManager.UpdateHistoryFile();
}
public void UpdateDownloadedSilent(){
DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes)));
OnPropertyChanged(nameof(DownloadedEpisodes));
}
}
}

View file

@ -77,6 +77,10 @@ public class HistorySeries : INotifyPropertyChanged{
public event PropertyChangedEventHandler? PropertyChanged;
private void OnPropertyChanged(string propertyName){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName));
}
[JsonIgnore]
public Bitmap? ThumbnailImage{ get; set; }
@ -95,7 +99,7 @@ public class HistorySeries : INotifyPropertyChanged{
set{
if (_editModeEnabled != value){
_editModeEnabled = value;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(EditModeEnabled)));
OnPropertyChanged(nameof(EditModeEnabled));
}
}
}
@ -115,16 +119,16 @@ public class HistorySeries : INotifyPropertyChanged{
private bool Loading;
[JsonIgnore]
public StringItem? _selectedVideoQualityItem;
public string _selectedVideoQualityItem = "";
[JsonIgnore]
public StringItem? SelectedVideoQualityItem{
public string SelectedVideoQualityItem{
get => _selectedVideoQualityItem;
set{
_selectedVideoQualityItem = value;
_selectedVideoQualityItem = value ?? "";
HistorySeriesVideoQualityOverride = value?.stringValue ?? "";
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedVideoQualityItem)));
HistorySeriesVideoQualityOverride = _selectedVideoQualityItem;
OnPropertyChanged(nameof(SelectedVideoQualityItem));
if (!Loading){
CfgManager.UpdateHistoryFile();
}
@ -138,32 +142,20 @@ public class HistorySeries : INotifyPropertyChanged{
public string SelectedDubs{ get; set; } = "";
[JsonIgnore]
public ObservableCollection<StringItem> SelectedSubLang{ get; set; } = new();
public ObservableCollection<string> SelectedSubLang{ get; set; } = new();
[JsonIgnore]
public ObservableCollection<StringItem> SelectedDubLang{ get; set; } = new();
public ObservableCollection<string> SelectedDubLang{ get; set; } = new();
[JsonIgnore]
public ObservableCollection<StringItem> DubLangList{ get; } = new(){
};
public ObservableCollection<string> DubLangList => HistoryOverrideOptions.GetDubLangList(SeriesStreamingService);
[JsonIgnore]
public ObservableCollection<StringItem> SubLangList{ get; } = new(){
new StringItem(){ stringValue = "all" },
new StringItem(){ stringValue = "none" },
};
public ObservableCollection<string> SubLangList => HistoryOverrideOptions.GetSubLangList(SeriesStreamingService);
[JsonIgnore]
public ObservableCollection<StringItem> VideoQualityList{ get; } = new(){
new StringItem(){ stringValue = "best" },
new StringItem(){ stringValue = "1080p" },
new StringItem(){ stringValue = "720p" },
new StringItem(){ stringValue = "480p" },
new StringItem(){ stringValue = "360p" },
new StringItem(){ stringValue = "240p" },
new StringItem(){ stringValue = "worst" },
};
public ObservableCollection<string> VideoQualityList => HistoryOverrideOptions.GetVideoQualityList(SeriesStreamingService);
private void UpdateSubAndDubString(){
HistorySeriesSoftSubsOverride.Clear();
@ -171,21 +163,21 @@ public class HistorySeries : INotifyPropertyChanged{
if (SelectedSubLang.Count != 0){
for (var i = 0; i < SelectedSubLang.Count; i++){
HistorySeriesSoftSubsOverride.Add(SelectedSubLang[i].stringValue);
HistorySeriesSoftSubsOverride.Add(SelectedSubLang[i]);
}
}
if (SelectedDubLang.Count != 0){
for (var i = 0; i < SelectedDubLang.Count; i++){
HistorySeriesDubLangOverride.Add(SelectedDubLang[i].stringValue);
HistorySeriesDubLangOverride.Add(SelectedDubLang[i]);
}
}
SelectedDubs = string.Join(", ", HistorySeriesDubLangOverride) ?? "";
SelectedSubs = string.Join(", ", HistorySeriesSoftSubsOverride) ?? "";
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedSubs)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SelectedDubs)));
OnPropertyChanged(nameof(SelectedSubs));
OnPropertyChanged(nameof(SelectedDubs));
CfgManager.UpdateHistoryFile();
}
@ -195,18 +187,14 @@ public class HistorySeries : INotifyPropertyChanged{
}
public void Init(){
SelectedSubLang.CollectionChanged -= Changes;
SelectedDubLang.CollectionChanged -= Changes;
Loading = true;
if (!(SubLangList.Count > 2 || DubLangList.Count > 0)){
foreach (var languageItem in Languages.languages){
SubLangList.Add(new StringItem{ stringValue = languageItem.CrLocale });
DubLangList.Add(new StringItem{ stringValue = languageItem.CrLocale });
}
}
SelectedVideoQualityItem = VideoQualityList.FirstOrDefault(HistorySeriesVideoQualityOverride.Equals) ?? "";
SelectedVideoQualityItem = VideoQualityList.FirstOrDefault(a => HistorySeriesVideoQualityOverride.Equals(a.stringValue)) ?? new StringItem(){ stringValue = "" };
var softSubLang = SubLangList.Where(a => HistorySeriesSoftSubsOverride.Contains(a.stringValue)).ToList();
var dubLang = DubLangList.Where(a => HistorySeriesDubLangOverride.Contains(a.stringValue)).ToList();
var softSubLang = SubLangList.Where(HistorySeriesSoftSubsOverride.Contains).ToList();
var dubLang = DubLangList.Where(HistorySeriesDubLangOverride.Contains).ToList();
SelectedSubLang.Clear();
foreach (var listBoxItem in softSubLang){
@ -225,10 +213,18 @@ public class HistorySeries : INotifyPropertyChanged{
SelectedDubLang.CollectionChanged += Changes;
UpdateSeriesFolderPath();
InitSeasons();
Loading = false;
}
private void InitSeasons(){
foreach (var season in Seasons){
season.StreamingService = SeriesStreamingService;
season.Init();
}
}
#endregion
public async Task LoadImage(){
@ -239,7 +235,7 @@ public class HistorySeries : INotifyPropertyChanged{
ThumbnailImage = await Helpers.LoadImage(ThumbnailImageUrl);
IsImageLoaded = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThumbnailImage)));
OnPropertyChanged(nameof(ThumbnailImage));
} catch (Exception ex){
Console.Error.WriteLine("Failed to load image: " + ex.Message);
}
@ -247,7 +243,7 @@ public class HistorySeries : INotifyPropertyChanged{
public void UpdateNewEpisodes(){
NewEpisodes = EnumerateEpisodes().Count();
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes)));
OnPropertyChanged(nameof(NewEpisodes));
}
public async Task AddNewMissingToDownloads(bool checkQueueForId = false){
@ -282,7 +278,7 @@ public class HistorySeries : INotifyPropertyChanged{
if (skipUnmonitored && sonarrEnabled && !ep.SonarrIsMonitored)
continue;
if (ShouldCountEpisode(ep, useSonarr, countMissing, false))
if (ShouldCountEpisode(season, ep, useSonarr, countMissing, false))
yield return ep;
}
}
@ -298,44 +294,90 @@ public class HistorySeries : INotifyPropertyChanged{
if (ep.SpecialEpisode){
if (historyAddSpecials &&
ShouldCountEpisode(ep, useSonarr, countMissing, false)){
ShouldCountEpisode(season, ep, useSonarr, countMissing, false)){
yield return ep;
}
continue;
}
if (ShouldCountEpisode(ep, useSonarr, countMissing, foundWatched)){
if (ShouldCountEpisode(season, ep, useSonarr, countMissing, foundWatched)){
yield return ep;
} else{
foundWatched = true;
if (!historyAddSpecials && !countMissing)
break;
if (ep.WasDownloaded && !IsPartialDownloadActionable(season, ep)){
foundWatched = true;
}
}
}
if (foundWatched && !historyAddSpecials && !countMissing)
break;
}
}
private bool ShouldCountEpisode(HistoryEpisode episode, bool useSonarr, bool countMissing, bool foundWatched){
private bool ShouldCountEpisode(HistorySeason season, HistoryEpisode episode, bool useSonarr, bool countMissing, bool foundWatched){
if (useSonarr)
return !string.IsNullOrEmpty(episode.SonarrEpisodeId) && !episode.SonarrHasFile;
return !episode.WasDownloaded && (!foundWatched || countMissing);
return IsPartialDownloadActionable(season, episode) ||
!episode.WasDownloaded && (!foundWatched || countMissing);
}
public void SetFetchingData(){
FetchingData = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData)));
private bool IsPartialDownloadActionable(HistorySeason season, HistoryEpisode episode){
if (!CrunchyrollManager.Instance.CrunOptions.HistoryCheckPartialDownloads){
return false;
}
var requestedDubs = GetEffectiveDubLang(this, season);
var requestedSoftSubs = GetEffectiveSoftSubs(this, season, episode);
return episode.HasAvailableMissingDownloadedMedia(requestedDubs, requestedSoftSubs);
}
public static IEnumerable<string> GetEffectiveDubLang(HistorySeries? series, HistorySeason? season){
if (season?.HistorySeasonDubLangOverride.Count > 0){
return season.HistorySeasonDubLangOverride;
}
if (series?.HistorySeriesDubLangOverride.Count > 0){
return series.HistorySeriesDubLangOverride;
}
return series?.SeriesStreamingService == StreamingService.Crunchyroll
? CrunchyrollManager.Instance.CrunOptions.DubLang
: [];
}
public static IEnumerable<string> GetEffectiveSoftSubs(HistorySeries? series, HistorySeason? season, HistoryEpisode episode){
IEnumerable<string> requested;
if (season?.HistorySeasonSoftSubsOverride.Count > 0){
requested = season.HistorySeasonSoftSubsOverride;
} else if (series?.HistorySeriesSoftSubsOverride.Count > 0){
requested = series.HistorySeriesSoftSubsOverride;
} else{
requested = series?.SeriesStreamingService == StreamingService.Crunchyroll
? CrunchyrollManager.Instance.CrunOptions.DlSubs
: [];
}
var requestedList = requested.ToList();
if (requestedList.Contains("none", StringComparer.OrdinalIgnoreCase)){
return [];
}
return requestedList.Contains("all", StringComparer.OrdinalIgnoreCase)
? episode.HistoryEpisodeAvailableSoftSubs
: requestedList;
}
public void SetFetchingData(bool fetchingData = true){
FetchingData = fetchingData;
OnPropertyChanged(nameof(FetchingData));
}
public async Task<bool> FetchData(string? seasonId){
Console.WriteLine($"Fetching Data for: {SeriesTitle}");
FetchingData = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData)));
OnPropertyChanged(nameof(FetchingData));
var isOk = true;
switch (SeriesType){
@ -366,11 +408,11 @@ public class HistorySeries : INotifyPropertyChanged{
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesTitle)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesDescription)));
OnPropertyChanged(nameof(SeriesTitle));
OnPropertyChanged(nameof(SeriesDescription));
UpdateNewEpisodes();
FetchingData = false;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(FetchingData)));
OnPropertyChanged(nameof(FetchingData));
return isOk;
}
@ -379,7 +421,7 @@ public class HistorySeries : INotifyPropertyChanged{
HistorySeason? objectToRemove = Seasons.FirstOrDefault(se => se.SeasonId == season) ?? null;
if (objectToRemove != null){
Seasons.Remove(objectToRemove);
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Seasons)));
OnPropertyChanged(nameof(Seasons));
CfgManager.UpdateHistoryFile();
}
}
@ -417,7 +459,7 @@ public class HistorySeries : INotifyPropertyChanged{
if (!string.IsNullOrEmpty(SeriesDownloadPath) && Directory.Exists(SeriesDownloadPath)){
SeriesFolderPath = SeriesDownloadPath;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
OnPropertyChanged(nameof(SeriesFolderPathExists));
return;
}
@ -431,7 +473,7 @@ public class HistorySeries : INotifyPropertyChanged{
if (!string.IsNullOrEmpty(parentFolder) && Directory.Exists(parentFolder)){
SeriesFolderPath = parentFolder;
SeriesFolderPathExists = true;
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
OnPropertyChanged(nameof(SeriesFolderPathExists));
return;
}
} catch (Exception e){
@ -441,14 +483,14 @@ public class HistorySeries : INotifyPropertyChanged{
// Auto generated path
if (string.IsNullOrEmpty(SeriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
OnPropertyChanged(nameof(SeriesFolderPathExists));
return;
}
var seriesTitle = FileNameManager.CleanupFilename(SeriesTitle);
if (string.IsNullOrEmpty(seriesTitle)){
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
OnPropertyChanged(nameof(SeriesFolderPathExists));
return;
}
@ -464,6 +506,6 @@ public class HistorySeries : INotifyPropertyChanged{
SeriesFolderPathExists = true;
}
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(SeriesFolderPathExists)));
OnPropertyChanged(nameof(SeriesFolderPathExists));
}
}

View file

@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using Avalonia.Data.Converters;
using Avalonia.Media;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Structs.History;
namespace CRD.Utils.UI;
public class UiHistoryDownloadStatusConverter : IMultiValueConverter{
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture){
var episode = values.Count > 0 && values[0] is HistoryEpisode ep ? ep : null;
var series = values.Count > 1 && values[1] is HistorySeries hs ? hs : null;
var season = values.Count > 2 && values[2] is HistorySeason hsn ? hsn : null;
var isTooltip = string.Equals(parameter?.ToString(), "Tooltip", StringComparison.OrdinalIgnoreCase);
if (episode == null || !episode.WasDownloaded){
return isTooltip ? "Mark as downloaded" : Brushes.Gray;
}
var requestedDubs = Enumerable.Empty<string>();
var requestedSoftSubs = Enumerable.Empty<string>();
var isPartial = false;
if (CrunchyrollManager.Instance.CrunOptions.HistoryCheckPartialDownloads){
requestedDubs = HistorySeries.GetEffectiveDubLang(series, season);
requestedSoftSubs = HistorySeries.GetEffectiveSoftSubs(series, season, episode);
isPartial = episode.IsPartiallyDownloaded(requestedDubs, requestedSoftSubs);
}
if (isTooltip){
return BuildTooltip(episode, requestedDubs, requestedSoftSubs, isPartial);
}
return isPartial ? new SolidColorBrush(Color.Parse("#f78c25")) : new SolidColorBrush(Color.Parse("#21a556"));
}
public IList<object> ConvertBack(object value, Type[] targetTypes, object parameter, CultureInfo culture)
=> throw new NotImplementedException();
private static string BuildTooltip(
HistoryEpisode episode,
IEnumerable<string> requestedDubs,
IEnumerable<string> requestedSoftSubs,
bool isPartial){
if (!isPartial){
return "Downloaded";
}
var missingDubs = GetMissingLocales(
requestedDubs,
episode.DownloadedDubLang,
episode.HistoryEpisodeAvailableDubLang);
var missingSubs = GetMissingLocales(
requestedSoftSubs,
episode.DownloadedSoftSubs,
episode.HistoryEpisodeAvailableSoftSubs);
var lines = new List<string>{ "Downloaded, but missing:" };
if (missingDubs.Count > 0){
lines.Add($"Dubs: {string.Join(", ", missingDubs)}");
}
if (missingSubs.Count > 0){
lines.Add($"Subs: {string.Join(", ", missingSubs)}");
}
return string.Join(Environment.NewLine, lines);
}
private static List<string> GetMissingLocales(
IEnumerable<string> requested,
IEnumerable<string> downloaded,
IEnumerable<string> available){
var requestedList = requested
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
if (requestedList.Count == 0 ||
requestedList.Contains("none", StringComparer.OrdinalIgnoreCase)){
return [];
}
var downloadedSet = downloaded.ToHashSet(StringComparer.OrdinalIgnoreCase);
var availableList = available
.Where(locale => !string.IsNullOrWhiteSpace(locale))
.Distinct(StringComparer.OrdinalIgnoreCase)
.ToList();
var requestedAvailableLocales = requestedList.Contains("all", StringComparer.OrdinalIgnoreCase)
? availableList
: requestedList.Where(locale => availableList.Contains(locale, StringComparer.OrdinalIgnoreCase));
return requestedAvailableLocales
.Where(locale => !downloadedSet.Contains(locale))
.OrderBy(locale => locale, StringComparer.OrdinalIgnoreCase)
.ToList();
}
}

View file

@ -5,67 +5,73 @@ using System.Collections.Specialized;
using System.ComponentModel;
using System.Linq;
using System.Runtime;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
using Avalonia.Controls;
using Avalonia.Media.Imaging;
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using CRD.Downloader;
using CRD.Downloader.Crunchyroll;
using CRD.Utils;
using CRD.Utils.Files;
using CRD.Utils.Structs;
using CRD.Utils.Structs.Crunchyroll.Music;
using CRD.Views;
using ReactiveUI;
// ReSharper disable InconsistentNaming
namespace CRD.ViewModels;
public partial class AddDownloadPageViewModel : ViewModelBase{
[ObservableProperty]
private string _urlInput = "";
private string urlInput = "";
[ObservableProperty]
private string _buttonText = "Enter Url";
private string buttonText = "Enter Url";
[ObservableProperty]
private string _buttonTextSelectSeason = "Select Season";
private string buttonTextSelectSeason = "Select Season";
[ObservableProperty]
private bool _addAllEpisodes;
private bool addAllEpisodes;
[ObservableProperty]
private bool _buttonEnabled;
private bool buttonEnabled;
[ObservableProperty]
private bool _allButtonEnabled;
private bool allButtonEnabled;
[ObservableProperty]
private bool _showLoading;
private bool showLoading;
[ObservableProperty]
private bool _searchEnabled;
private bool searchEnabled;
[ObservableProperty]
private bool _searchVisible = true;
private bool defaultSearchEnabled;
[ObservableProperty]
private bool _slectSeasonVisible;
private bool addSearchResultsToHistory = true;
[ObservableProperty]
private bool _searchPopupVisible;
private bool singleEpisodeInstantAdd = true;
[ObservableProperty]
private bool searchVisible = true;
[ObservableProperty]
private bool slectSeasonVisible;
[ObservableProperty]
private bool searchPopupVisible;
public ObservableCollection<ItemModel> Items{ get; set; } = new();
public ObservableCollection<CrBrowseSeries> SearchItems{ get; set; } = new();
public ObservableCollection<ItemModel> SelectedItems{ get; set; } = new();
[ObservableProperty]
public CrBrowseSeries _selectedSearchItem;
private CrBrowseSeries selectedSearchItem;
[ObservableProperty]
public ComboBoxItem _currentSelectedSeason;
private ComboBoxItem currentSelectedSeason;
public ObservableCollection<ComboBoxItem> SeasonList{ get; set; } = new();
@ -79,8 +85,22 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
private bool CurrentSeasonFullySelected;
private string currentSingleEpisodeId = "";
private string currentSingleEpisodeLocale = "";
private bool settingsLoaded;
public string SearchWatermark => SearchEnabled
? "Search for a series title"
: "Enter series or episode URL";
public AddDownloadPageViewModel(){
var options = CrunchyrollManager.Instance.CrunOptions;
AddSearchResultsToHistory = options.AddDownloadSearchAddToHistory;
SingleEpisodeInstantAdd = options.AddDownloadSingleEpisodeInstantAdd;
DefaultSearchEnabled = options.AddDownloadDefaultSearchEnabled;
SearchEnabled = DefaultSearchEnabled;
SelectedItems.CollectionChanged += OnSelectedItemsChanged;
settingsLoaded = true;
}
private async Task UpdateSearch(string value){
@ -168,6 +188,31 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
partial void OnSearchEnabledChanged(bool value){
ButtonText = SearchEnabled ? "Select Searched Series" : "Enter Url";
ButtonEnabled = false;
RaisePropertyChanged(nameof(SearchWatermark));
}
partial void OnDefaultSearchEnabledChanged(bool value){
UpdateAddDownloadSettings();
}
partial void OnAddSearchResultsToHistoryChanged(bool value){
UpdateAddDownloadSettings();
}
partial void OnSingleEpisodeInstantAddChanged(bool value){
UpdateAddDownloadSettings();
}
private void UpdateAddDownloadSettings(){
if (!settingsLoaded){
return;
}
var options = CrunchyrollManager.Instance.CrunOptions;
options.AddDownloadSearchAddToHistory = AddSearchResultsToHistory;
options.AddDownloadSingleEpisodeInstantAdd = SingleEpisodeInstantAdd;
options.AddDownloadDefaultSearchEnabled = DefaultSearchEnabled;
CfgManager.WriteCrSettings();
}
#region OnButtonPress
@ -181,6 +226,10 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
AddSelectedMusicVideosToQueue();
}
if (!string.IsNullOrEmpty(currentSingleEpisodeId)){
await AddSelectedSingleEpisodeToQueue();
}
if (currentSeriesList != null){
await AddSelectedEpisodesToQueue();
}
@ -243,6 +292,8 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
private void ResetState(){
currentMusicVideoList = null;
currentSingleEpisodeId = "";
currentSingleEpisodeLocale = "";
UrlInput = "";
selectedEpisodes.Clear();
SelectedItems.Clear();
@ -252,7 +303,8 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
episodesBySeason.Clear();
AllButtonEnabled = false;
AddAllEpisodes = false;
ButtonText = "Enter Url";
SearchEnabled = DefaultSearchEnabled;
ButtonText = SearchEnabled ? "Select Searched Series" : "Enter Url";
ButtonEnabled = false;
SearchVisible = true;
SlectSeasonVisible = false;
@ -268,8 +320,8 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
var matchResult = ExtractLocaleAndIdFromUrl();
if (matchResult is ({ } locale, { } id)){
switch (GetUrlType()){
if (matchResult is ({ } locale, { } id, { } urlType)){
switch (urlType){
case CrunchyUrlType.Artist:
await HandleArtistUrlAsync(locale, id);
break;
@ -280,7 +332,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
HandleConcertUrl(id);
break;
case CrunchyUrlType.Episode:
HandleEpisodeUrl(locale, id);
await HandleEpisodeUrlAsync(locale, id);
break;
case CrunchyUrlType.Series:
await HandleSeriesUrlAsync(locale, id);
@ -292,23 +344,69 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
}
private (string locale, string id)? ExtractLocaleAndIdFromUrl(){
var match = Regex.Match(UrlInput, @"^(?:https?:\/\/[^/]+)?(?:\/([a-z]{2}))?\/(?:[^/]+\/)?(artist|watch|series)(?:\/(musicvideo|concert))?\/([^/]+)(?:\/[^/]*)?$");
private (string locale, string id, CrunchyUrlType urlType)? ExtractLocaleAndIdFromUrl(){
return TryExtractCrunchyUrlParts(UrlInput);
}
return match.Success
? (match.Groups[1].Value ?? "", match.Groups[4].Value)
: null;
internal static (string locale, string id, CrunchyUrlType urlType)? TryExtractCrunchyUrlParts(string? urlInput){
if (string.IsNullOrWhiteSpace(urlInput)){
return null;
}
var input = urlInput.Trim();
var path = Uri.TryCreate(input, UriKind.Absolute, out var uri)
? uri.AbsolutePath
: input.Split(['?', '#'], 2)[0];
var segments = path
.Split('/', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries)
.ToList();
var typeIndex = segments.FindIndex(IsCrunchyUrlTypeSegment);
if (typeIndex < 0){
return null;
}
var typeSegment = segments[typeIndex];
var idIndex = typeIndex + 1;
var urlType = typeSegment switch{
"artist" => CrunchyUrlType.Artist,
"series" => CrunchyUrlType.Series,
"watch" when idIndex < segments.Count && segments[idIndex] == "musicvideo" => CrunchyUrlType.MusicVideo,
"watch" when idIndex < segments.Count && segments[idIndex] == "concert" => CrunchyUrlType.Concert,
"watch" => CrunchyUrlType.Episode,
_ => CrunchyUrlType.Unknown,
};
if (urlType is CrunchyUrlType.MusicVideo or CrunchyUrlType.Concert){
idIndex++;
}
if (idIndex >= segments.Count || string.IsNullOrWhiteSpace(segments[idIndex])){
return null;
}
var locale = typeIndex > 0 && IsLocaleSegment(segments[0])
? segments[0]
: "";
return (locale, segments[idIndex], urlType);
}
private CrunchyUrlType GetUrlType(){
return UrlInput switch{
_ when UrlInput.Contains("/artist/") => CrunchyUrlType.Artist,
_ when UrlInput.Contains("/watch/musicvideo/") => CrunchyUrlType.MusicVideo,
_ when UrlInput.Contains("/watch/concert/") => CrunchyUrlType.Concert,
_ when UrlInput.Contains("/watch/") => CrunchyUrlType.Episode,
_ when UrlInput.Contains("/series/") => CrunchyUrlType.Series,
_ => CrunchyUrlType.Unknown,
};
return TryExtractCrunchyUrlParts(UrlInput)?.urlType ?? CrunchyUrlType.Unknown;
}
private static bool IsCrunchyUrlTypeSegment(string segment){
return segment is "artist" or "watch" or "series";
}
private static bool IsLocaleSegment(string segment){
return segment.Length == 2 && segment.All(char.IsAsciiLetterLower)
|| segment.Length == 5
&& segment[..2].All(char.IsAsciiLetterLower)
&& segment[2] == '-'
&& segment[3..].All(char.IsAsciiLetterUpper);
}
private async Task HandleArtistUrlAsync(string locale, string id){
@ -336,10 +434,88 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
ResetState();
}
private void HandleEpisodeUrl(string locale, string id){
_ = CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(
id, DetermineLocale(locale),
CrunchyrollManager.Instance.CrunOptions.DubLang, true);
private async Task HandleEpisodeUrlAsync(string locale, string id){
var resolvedLocale = DetermineLocale(locale);
if (SingleEpisodeInstantAdd){
_ = CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(
id, resolvedLocale,
CrunchyrollManager.Instance.CrunOptions.DubLang, AddSearchResultsToHistory);
ResetState();
return;
}
await ShowSingleEpisodeForSelectionAsync(id, resolvedLocale);
}
private async Task ShowSingleEpisodeForSelectionAsync(string id, string locale){
SetLoadingState(true);
var episode = await CrunchyrollManager.Instance.CrEpisode.ParseEpisodeById(id, locale);
CrunchyRollEpisodeData? episodeData = null;
if (episode != null){
episodeData = await CrunchyrollManager.Instance.CrEpisode.EpisodeData(episode, AddSearchResultsToHistory);
}
SetLoadingState(false);
if (episode == null || episodeData?.EpisodeAndLanguages.Variants.Count == 0){
MessageBus.Current.SendMessage(new ToastMessage("Failed to get episode", ToastType.Error, 2));
return;
}
currentSingleEpisodeId = id;
currentSingleEpisodeLocale = locale;
currentSeriesList = null;
currentMusicVideoList = null;
Items.Clear();
SelectedItems.Clear();
selectedEpisodes.Clear();
SeasonList.Clear();
episodesBySeason.Clear();
var baseEpisode = episodeData.EpisodeAndLanguages.Variants.First().Item;
var availableAudios = episodeData.EpisodeAndLanguages.Variants
.Select(variant => variant.Lang.CrLocale)
.Distinct()
.ToList();
var season = "S" + baseEpisode.GetSeasonNum();
var episodeNumber = episodeData.Key.StartsWith("E") || episodeData.Key.StartsWith("SP")
? episodeData.Key
: "E" + episodeData.Key;
var duration = TimeSpan.FromMilliseconds(baseEpisode.DurationMs);
var itemModel = new ItemModel(
baseEpisode.Id,
baseEpisode.GetImageUrl(),
baseEpisode.Description,
$"{(int)duration.TotalMinutes}:{duration.Seconds:D2}",
baseEpisode.GetEpisodeTitle(),
season,
episodeNumber,
episodeData.Key.StartsWith('E') ? episodeData.Key[1..] : episodeData.Key,
availableAudios,
baseEpisode.EpisodeType);
episodesBySeason[season] = [itemModel];
SeasonList.Add(new ComboBoxItem{ Content = season });
CurrentSelectedSeason = SeasonList.First();
AllButtonEnabled = false;
AddAllEpisodes = false;
SearchVisible = false;
SlectSeasonVisible = false;
ButtonText = "Select Episode";
ButtonEnabled = false;
}
private async Task AddSelectedSingleEpisodeToQueue(){
if (AddAllEpisodes || SelectedItems.Count > 0 || selectedEpisodes.Count > 0){
await CrunchyrollManager.Instance.CrQueue.CrAddEpisodeToQueue(
currentSingleEpisodeId,
currentSingleEpisodeLocale,
CrunchyrollManager.Instance.CrunOptions.DubLang,
AddSearchResultsToHistory);
}
ResetState();
}
@ -348,7 +524,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
var list = await CrunchyrollManager.Instance.CrSeries.ListSeriesId(
id, DetermineLocale(locale),
new CrunchyMultiDownload(CrunchyrollManager.Instance.CrunOptions.DubLang, true), true);
new CrunchyMultiDownload(CrunchyrollManager.Instance.CrunOptions.DubLang, true), true, AddSearchResultsToHistory);
if (CrunchyrollManager.Instance.CrunOptions.SearchFetchFeaturedMusic){
var musicList = await CrunchyrollManager.Instance.CrMusic.ParseFeaturedMusicVideoByIdAsync(id, DetermineLocale(locale), true);
@ -409,8 +585,10 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
private void PopulateEpisodesBySeason(){
var seasonKeys = BuildSeasonGroupKeys(currentSeriesList?.List ?? Enumerable.Empty<Episode>());
foreach (var episode in currentSeriesList?.List ?? Enumerable.Empty<Episode>()){
var seasonKey = "S" + episode.Season;
var seasonKey = seasonKeys[episode];
var itemModel = new ItemModel(
episode.Id, episode.Img, episode.Description, episode.Time, episode.Name, seasonKey,
episode.EpisodeNum.StartsWith("SP") ? episode.EpisodeNum : "E" + episode.EpisodeNum,
@ -429,6 +607,28 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
}
}
private static Dictionary<Episode, string> BuildSeasonGroupKeys(IEnumerable<Episode> episodes){
var episodeList = episodes.ToList();
var seasonIdIndexesBySeason = episodeList
.GroupBy(episode => episode.Season)
.ToDictionary(
seasonGroup => seasonGroup.Key,
seasonGroup => seasonGroup
.Select(episode => episode.Id)
.Distinct()
.Select((seasonId, index) => new{ seasonId, index })
.ToDictionary(item => item.seasonId, item => item.index + 1));
return episodeList.ToDictionary(episode => episode, episode => {
var baseSeasonKey = "S" + episode.Season;
var seasonIdIndexes = seasonIdIndexesBySeason[episode.Season];
return seasonIdIndexes.Count > 1
? $"{baseSeasonKey} ({seasonIdIndexes[episode.Id]})"
: baseSeasonKey;
});
}
private string DetermineLocale(string locale){
return string.IsNullOrEmpty(locale)
? (string.IsNullOrEmpty(CrunchyrollManager.Instance.CrunOptions.HistoryLang)
@ -555,8 +755,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
SearchVisible = true;
SlectSeasonVisible = false;
ShowLoading = false;
SearchEnabled = false; // disable and enable for button text
SearchEnabled = true;
ButtonText = SearchEnabled ? "Select Searched Series" : "Enter Url";
}
private void UpdateUiForSearchSelection(){
@ -577,7 +776,7 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
return await CrunchyrollManager.Instance.CrSeries.ListSeriesId(
seriesId,
locale,
new CrunchyMultiDownload(CrunchyrollManager.Instance.CrunOptions.DubLang, true), true);
new CrunchyMultiDownload(CrunchyrollManager.Instance.CrunOptions.DubLang, true), true, AddSearchResultsToHistory);
}
private async Task SearchPopulateEpisodesBySeason(string seriesId){
@ -594,8 +793,10 @@ public partial class AddDownloadPageViewModel : ViewModelBase{
GCSettings.LargeObjectHeapCompactionMode = GCLargeObjectHeapCompactionMode.CompactOnce;
GC.Collect();
var seasonKeys = BuildSeasonGroupKeys(currentSeriesList.List);
foreach (var episode in currentSeriesList.List){
var seasonKey = "S" + episode.Season;
var seasonKey = seasonKeys[episode];
var episodeModel = new ItemModel(
episode.Id,
episode.Img,
@ -717,4 +918,4 @@ public class ItemModel(string id, string imageUrl, string description, string ti
ImageBitmap = await Helpers.LoadImage(url, 208, 117);
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImageBitmap)));
}
}
}

View file

@ -35,6 +35,9 @@ public partial class CalendarPageViewModel : ViewModelBase{
[ObservableProperty]
private bool _updateHistoryFromCalendar;
[ObservableProperty]
private bool _showHistoryMark;
[ObservableProperty]
private bool _hideDubs;
@ -78,6 +81,7 @@ public partial class CalendarPageViewModel : ViewModelBase{
HideDubs = CrunchyrollManager.Instance.CrunOptions.CalendarHideDubs;
ShowUpcomingEpisodes = CrunchyrollManager.Instance.CrunOptions.CalendarShowUpcomingEpisodes;
UpdateHistoryFromCalendar = CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar;
ShowHistoryMark = CrunchyrollManager.Instance.CrunOptions.CalendarShowHistoryMark;
ComboBoxItem? dubfilter = CalendarDubFilter.FirstOrDefault(a => a.Content != null && (string)a.Content == CrunchyrollManager.Instance.CrunOptions.CalendarDubFilter) ?? null;
CurrentCalendarDubFilter = dubfilter ?? CalendarDubFilter[0];
@ -302,5 +306,29 @@ public partial class CalendarPageViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar = value;
CfgManager.WriteCrSettings();
}
partial void OnShowHistoryMarkChanged(bool value){
if (loading){
return;
}
CrunchyrollManager.Instance.CrunOptions.CalendarShowHistoryMark = value;
CfgManager.WriteCrSettings();
RefreshVisibleHistoryMarks(value);
}
private void RefreshVisibleHistoryMarks(bool value){
foreach (var calendarEpisode in CalendarDays.SelectMany(day => day.CalendarEpisodes)){
SetHistoryMarkVisibility(calendarEpisode, value);
}
}
private static void SetHistoryMarkVisibility(CalendarEpisode calendarEpisode, bool value){
calendarEpisode.ShowHistoryMark = value;
foreach (var childEpisode in calendarEpisode.CalendarEpisodes){
SetHistoryMarkVisibility(childEpisode, value);
}
}
}
}

View file

@ -279,7 +279,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ShowPauseIcon)));
QueueManager.Instance.ReleaseDownloadSlot(epMeta);
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.NotifyQueueItemStateChanged(epMeta);
return;
}
@ -317,7 +317,7 @@ public partial class DownloadItemModel : INotifyPropertyChanged{
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed)));
PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat)));
QueueManager.Instance.RefreshQueue();
QueueManager.Instance.NotifyQueueItemStateChanged(epMeta);
StartDownload();
}

View file

@ -285,8 +285,15 @@ public partial class SeriesPageViewModel : ViewModelBase{
[RelayCommand]
public async Task RefreshSonarrEpisodeMatch(){
await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(true, SelectedSeries);
CfgManager.UpdateHistoryFile();
SelectedSeries.SetFetchingData();
await Task.Yield();
try{
await Task.Run(async () => await CrunchyrollManager.Instance.History.MatchHistoryEpisodesWithSonarr(true, SelectedSeries));
CfgManager.UpdateHistoryFile();
} finally{
SelectedSeries.SetFetchingData(false);
}
}
[RelayCommand]
@ -372,4 +379,4 @@ public partial class SeriesPageViewModel : ViewModelBase{
_ => Symbol.ClosedCaption
};
}
}
}

View file

@ -93,7 +93,7 @@ public partial class UpdateViewModel : ViewModelBase{
private int textSize = 16;
public void LoadChangelog(){
string changelogPath = "CHANGELOG.md";
string changelogPath = Path.Combine(AppContext.BaseDirectory, "CHANGELOG.md");;
if (!File.Exists(changelogPath)){
ChangelogBlocks.Clear();

View file

@ -46,6 +46,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool historyCountMissing;
[ObservableProperty]
private bool historyCheckPartialDownloads;
[ObservableProperty]
private bool historyIncludeCrArtists;
@ -94,6 +97,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool downloadOnlyWithAllSelectedDubSub;
[ObservableProperty]
private bool replaceExistingFiles;
[ObservableProperty]
private bool downloadAllowEarlyStart;
@ -251,6 +257,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool proxyEnabled;
[ObservableProperty]
private bool proxyAllTraffic;
[ObservableProperty]
private bool proxySocks;
@ -433,12 +442,14 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
ProxyEnabled = options.ProxyEnabled;
ProxyAllTraffic = options.ProxyAllTraffic;
ProxySocks = options.ProxySocks;
ProxyHost = options.ProxyHost ?? "";
ProxyUsername = options.ProxyUsername ?? "";
ProxyPassword = options.ProxyPassword ?? "";
ProxyPort = options.ProxyPort;
HistoryCountMissing = options.HistoryCountMissing;
HistoryCheckPartialDownloads = options.HistoryCheckPartialDownloads;
HistoryIncludeCrArtists = options.HistoryIncludeCrArtists;
HistoryRemoveMissingEpisodes = options.HistoryRemoveMissingEpisodes;
HistoryAddSpecials = options.HistoryAddSpecials;
@ -453,6 +464,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
DownloadMethodeNew = options.DownloadMethodeNew;
DownloadAllowEarlyStart = options.DownloadAllowEarlyStart;
DownloadOnlyWithAllSelectedDubSub = options.DownloadOnlyWithAllSelectedDubSub;
ReplaceExistingFiles = options.ReplaceExistingFiles;
PersistQueue = options.PersistQueue;
RetryAttempts = Math.Clamp((options.RetryAttempts), 1, 10);
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
@ -530,6 +542,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.DownloadMethodeNew = DownloadMethodeNew;
settings.DownloadAllowEarlyStart = DownloadAllowEarlyStart;
settings.DownloadOnlyWithAllSelectedDubSub = DownloadOnlyWithAllSelectedDubSub;
settings.ReplaceExistingFiles = ReplaceExistingFiles;
settings.PersistQueue = PersistQueue;
settings.BackgroundImageBlurRadius = Math.Clamp((BackgroundImageBlurRadius ?? 0), 0, 40);
@ -542,6 +555,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.DownloadToTempFolder = DownloadToTempFolder;
settings.HistoryCountMissing = HistoryCountMissing;
settings.HistoryCheckPartialDownloads = HistoryCheckPartialDownloads;
settings.HistoryAddSpecials = HistoryAddSpecials;
settings.HistoryIncludeCrArtists = HistoryIncludeCrArtists;
settings.HistoryRemoveMissingEpisodes = HistoryRemoveMissingEpisodes;
@ -558,6 +572,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
QueueManager.Instance.SetProcessingLimit(settings.SimultaneousProcessingJobs);
settings.ProxyEnabled = ProxyEnabled;
settings.ProxyAllTraffic = ProxyAllTraffic;
settings.ProxySocks = ProxySocks;
settings.ProxyHost = ProxyHost;
settings.ProxyPort = Math.Clamp((int)(ProxyPort ?? 0), 0, 65535);
@ -1204,10 +1219,6 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
Parallel.ForEach(historyList, historySeries => {
historySeries.Init();
foreach (var historySeriesSeason in historySeries.Seasons){
historySeriesSeason.Init();
}
});
} else{
CrunchyrollManager.Instance.HistoryList = new ObservableCollection<HistorySeries>();
@ -1222,6 +1233,15 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
}
}
if (settingsLoaded && e.PropertyName is nameof(HistoryCheckPartialDownloads)){
foreach (var historySeries in CrunchyrollManager.Instance.HistoryList){
historySeries.UpdateNewEpisodes();
foreach (var episode in historySeries.Seasons.SelectMany(season => season.EpisodesList)){
episode.RefreshDownloadState();
}
}
}
if (!string.IsNullOrEmpty(BackgroundImagePath) && e.PropertyName is nameof(BackgroundImageBlurRadius) or nameof(BackgroundImageOpacity)){
Helpers.SetBackgroundImage(BackgroundImagePath, BackgroundImageOpacity, BackgroundImageBlurRadius);
}

View file

@ -30,8 +30,17 @@
<StackPanel Grid.Row="0" Orientation="Vertical">
<TextBox x:Name="SearchBar" Watermark="Enter series or episode url" Text="{Binding UrlInput}" Margin="10"
VerticalAlignment="Top" />
<Grid Margin="10" ColumnDefinitions="*,Auto">
<TextBox x:Name="SearchBar" Grid.Column="0" Watermark="{Binding SearchWatermark}" Text="{Binding UrlInput}"
VerticalAlignment="Top" />
<ToggleButton x:Name="AddDownloadSettings" Grid.Column="1" Margin="8 0 0 0" HorizontalContentAlignment="Stretch">
<controls:SymbolIcon Symbol="Settings" FontSize="18" />
<ToolTip.Tip>
<TextBlock Text="Add download settings" FontSize="15" />
</ToolTip.Tip>
</ToggleButton>
</Grid>
<Popup IsLightDismissEnabled="True"
MaxWidth="{Binding Bounds.Width, ElementName=SearchBar}"
@ -116,6 +125,81 @@
</Border>
</Popup>
<Popup IsLightDismissEnabled="True"
MaxWidth="360"
MaxHeight="600"
IsOpen="{Binding IsChecked, ElementName=AddDownloadSettings, Mode=TwoWay}"
Placement="Bottom"
PlacementTarget="{Binding ElementName=AddDownloadSettings}">
<Border BorderThickness="1"
CornerRadius="8"
Padding="12"
Background="{DynamicResource ComboBoxDropDownBackground}">
<StackPanel Width="320" Spacing="8">
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Add searched series to history" VerticalAlignment="Center" />
<controls:SymbolIcon Grid.Column="1"
Symbol="Help"
FontSize="14"
Opacity="0.72"
Margin="8 0 10 0"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Listing episodes from this page updates History when global History is enabled."
FontSize="13"
TextWrapping="Wrap"
MaxWidth="280" />
</ToolTip.Tip>
</controls:SymbolIcon>
<CheckBox Grid.Column="2"
IsChecked="{Binding AddSearchResultsToHistory}"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Single episode URLs" VerticalAlignment="Center" />
<controls:SymbolIcon Grid.Column="1"
Symbol="Help"
FontSize="14"
Opacity="0.72"
Margin="8 0 10 0"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Add episode URLs directly, or disable this to show a selectable episode card first."
FontSize="13"
TextWrapping="Wrap"
MaxWidth="280" />
</ToolTip.Tip>
</controls:SymbolIcon>
<CheckBox Grid.Column="2"
IsChecked="{Binding SingleEpisodeInstantAdd}"
VerticalAlignment="Center" />
</Grid>
<Grid ColumnDefinitions="*,Auto,Auto">
<TextBlock Grid.Column="0" Text="Default search mode" VerticalAlignment="Center" />
<controls:SymbolIcon Grid.Column="1"
Symbol="Help"
FontSize="14"
Opacity="0.72"
Margin="8 0 10 0"
VerticalAlignment="Center">
<ToolTip.Tip>
<TextBlock Text="Open this page in title search mode instead of URL mode."
FontSize="13"
TextWrapping="Wrap"
MaxWidth="280" />
</ToolTip.Tip>
</controls:SymbolIcon>
<CheckBox Grid.Column="2"
IsChecked="{Binding DefaultSearchEnabled}"
VerticalAlignment="Center" />
</Grid>
</StackPanel>
</Border>
</Popup>
</StackPanel>
@ -233,4 +317,4 @@
</Grid>
</UserControl>
</UserControl>

View file

@ -103,6 +103,9 @@
<CheckBox IsChecked="{Binding UpdateHistoryFromCalendar}"
Content="Update History from Calendar" Margin="5 5 0 0">
</CheckBox>
<CheckBox IsChecked="{Binding ShowHistoryMark}"
Content="Show History mark" Margin="5 5 0 0">
</CheckBox>
</StackPanel>
</controls:SettingsExpander.Footer>
@ -226,6 +229,18 @@
</Viewbox>
</Canvas>
</StackPanel>
<StackPanel VerticalAlignment="Bottom" HorizontalAlignment="Left"
IsVisible="{Binding IsHistoryMarkVisible}" Margin="5,0,0,5">
<Grid>
<Ellipse Width="25" Height="25"
Fill="{Binding HistoryDownloadStatusBrush}" />
<controls:SymbolIcon Symbol="Checkmark"
FontSize="18" />
<ToolTip.Tip>
<TextBlock Text="{Binding HistoryDownloadStatusTooltip}" FontSize="15" />
</ToolTip.Tip>
</Grid>
</StackPanel>
</Grid>
<TextBlock HorizontalAlignment="Center" TextAlignment="Center" Text="{Binding SeasonName}"
@ -256,4 +271,4 @@
</Grid>
</UserControl>
</UserControl>

View file

@ -20,6 +20,7 @@
<ui:UiListHasElementsConverter x:Key="UiListHasElementsConverter" />
<ui:UiSeriesSeasonConverter x:Key="UiSeriesSeasonConverter" />
<ui:UiEnumToBoolConverter x:Key="EnumToBoolConverter" />
<ui:UiHistoryDownloadStatusConverter x:Key="UiHistoryDownloadStatusConverter" />
</UserControl.Resources>
@ -94,7 +95,8 @@
<Popup IsLightDismissEnabled="True"
IsOpen="{Binding IsSearchOpen, Mode=TwoWay}"
Placement="BottomEdgeAlignedRight"
PlacementTarget="{Binding ElementName=DropdownButtonSearch}">
PlacementTarget="{Binding ElementName=DropdownButtonSearch}"
Opened="SearchPopup_OnOpened">
<Border BorderThickness="1" Background="{DynamicResource ComboBoxDropDownBackground}">
<StackPanel Orientation="Horizontal" Margin="10">
@ -623,7 +625,7 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedVideoQualityItem.stringValue, FallbackValue=''}"
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedVideoQualityItem, FallbackValue=''}"
VerticalAlignment="Center" />
<Path Grid.Column="1" Data="M 0,1 L 4,4 L 8,1" Stroke="White" StrokeThickness="1"
VerticalAlignment="Center" Margin="5,0,5,0" Stretch="Uniform" Width="8" />
@ -638,13 +640,7 @@
<ListBox x:Name="ListBoxQulitiesSelection" SelectionMode="Single,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding VideoQualityList , Mode=OneWay}"
SelectedItem="{Binding SelectedVideoQualityItem}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItem="{Binding SelectedVideoQualityItem}" />
</Border>
</Popup>
</StackPanel>
@ -677,13 +673,7 @@
<ListBox x:Name="ListBoxDubsSelection" SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding DubLangList, Mode=OneWay}"
SelectedItems="{Binding SelectedDubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedDubLang}" />
</Border>
</Popup>
</StackPanel>
@ -713,13 +703,7 @@
<ListBox SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding SubLangList, Mode=OneWay}"
SelectedItems="{Binding SelectedSubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedSubLang}" />
</Border>
</Popup>
</StackPanel>
@ -802,8 +786,27 @@
TextWrapping="NoWrap"
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
HorizontalAlignment="Left"
Background="Transparent"
IsVisible="{Binding HistoryEpisodeAvailableDubLang, Converter={StaticResource UiListHasElementsConverter}}">
<ToolTip.Tip>
<Grid ColumnDefinitions="Auto,*" MaxWidth="600">
<TextBlock Grid.Column="0" Text="Subs: " />
<ui:HighlightingTextBlock
Grid.Column="1"
Items="{Binding HistoryEpisodeAvailableSoftSubs}"
Series="{Binding $parent[ScrollViewer].((history:HistorySeries)DataContext)}"
Season="{Binding $parent[controls:SettingsExpander].((history:HistorySeason)DataContext)}"
StreamingService="Crunchyroll"
CheckDubs="False"
FontSize="12"
Opacity="0.8"
FontStyle="Italic"
TextWrapping="Wrap" />
</Grid>
</ToolTip.Tip>
<TextBlock FontStyle="Italic"
FontSize="12"
Opacity="0.8" Text="Dubs: ">
@ -868,11 +871,47 @@
Command="{Binding ToggleWasDownloadedSeries}"
CommandParameter="{Binding $parent[ScrollViewer].((history:HistorySeries)DataContext)}">
<Grid>
<Ellipse Width="25" Height="25"
Fill="#21a556" />
<Ellipse Width="25" Height="25">
<Ellipse.Fill>
<MultiBinding Converter="{StaticResource UiHistoryDownloadStatusConverter}">
<Binding Path="." />
<Binding Path="$parent[ScrollViewer].((history:HistorySeries)DataContext)" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext)" />
<Binding Path="WasDownloaded" />
<Binding Path="DownloadedDubLang" />
<Binding Path="DownloadedSoftSubs" />
<Binding Path="HistoryEpisodeAvailableDubLang" />
<Binding Path="HistoryEpisodeAvailableSoftSubs" />
<Binding Path="$parent[ScrollViewer].((history:HistorySeries)DataContext).SelectedDubs" />
<Binding Path="$parent[ScrollViewer].((history:HistorySeries)DataContext).SelectedSubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedDubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedSubs" />
</MultiBinding>
</Ellipse.Fill>
</Ellipse>
<controls:SymbolIcon Symbol="Checkmark"
FontSize="18" />
</Grid>
<ToolTip.Tip>
<TextBlock FontSize="15">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource UiHistoryDownloadStatusConverter}" ConverterParameter="Tooltip">
<Binding Path="." />
<Binding Path="$parent[ScrollViewer].((history:HistorySeries)DataContext)" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext)" />
<Binding Path="WasDownloaded" />
<Binding Path="DownloadedDubLang" />
<Binding Path="DownloadedSoftSubs" />
<Binding Path="HistoryEpisodeAvailableDubLang" />
<Binding Path="HistoryEpisodeAvailableSoftSubs" />
<Binding Path="$parent[ScrollViewer].((history:HistorySeries)DataContext).SelectedDubs" />
<Binding Path="$parent[ScrollViewer].((history:HistorySeries)DataContext).SelectedSubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedDubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedSubs" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</ToolTip.Tip>
</Button>
<Button Margin="0 0 5 0" FontStyle="Italic"
@ -1103,7 +1142,7 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedVideoQualityItem.stringValue, FallbackValue=''}"
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedVideoQualityItem, FallbackValue=''}"
VerticalAlignment="Center" />
<Path Grid.Column="1" Data="M 0,1 L 4,4 L 8,1" Stroke="White" StrokeThickness="1"
VerticalAlignment="Center" Margin="5,0,5,0" Stretch="Uniform" Width="8" />
@ -1118,13 +1157,7 @@
<ListBox x:Name="ListBoxQulitiesSelection" SelectionMode="Single,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding VideoQualityList , Mode=OneWay}"
SelectedItem="{Binding SelectedVideoQualityItem}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItem="{Binding SelectedVideoQualityItem}" />
</Border>
</Popup>
</StackPanel>
@ -1157,13 +1190,7 @@
<ListBox x:Name="ListBoxDubsSelection" SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding DubLangList}"
SelectedItems="{Binding SelectedDubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedDubLang}" />
</Border>
</Popup>
</StackPanel>
@ -1193,13 +1220,7 @@
<ListBox SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding SubLangList}"
SelectedItems="{Binding SelectedSubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedSubLang}" />
</Border>
</Popup>
</StackPanel>

View file

@ -1,6 +1,8 @@
using Avalonia;
using System;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Interactivity;
using Avalonia.Threading;
using CRD.ViewModels;
namespace CRD.Views;
@ -23,4 +25,11 @@ public partial class HistoryPageView : UserControl{
if (SeriesListBox.Scroll != null) SeriesListBox.Scroll.Offset = viewModel.LastScrollOffset;
}
}
}
private void SearchPopup_OnOpened(object? sender, EventArgs e){
Dispatcher.UIThread.Post(() => {
SearchBar.Focus();
SearchBar.SelectAll();
}, DispatcherPriority.Input);
}
}

View file

@ -15,6 +15,7 @@
<ui:UiEmptyToDefaultConverter x:Key="EmptyToDefault" />
<ui:UiListHasElementsConverter x:Key="UiListHasElementsConverter" />
<ui:UiEnumToBoolConverter x:Key="EnumToBoolConverter" />
<ui:UiHistoryDownloadStatusConverter x:Key="UiHistoryDownloadStatusConverter" />
</UserControl.Resources>
@ -235,7 +236,7 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedSeries.SelectedVideoQualityItem.stringValue, FallbackValue=''}"
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedSeries.SelectedVideoQualityItem, FallbackValue=''}"
VerticalAlignment="Center" />
<Path Grid.Column="1" Data="M 0,1 L 4,4 L 8,1" Stroke="White" StrokeThickness="1"
VerticalAlignment="Center" Margin="5,0,5,0" Stretch="Uniform" Width="8" />
@ -247,16 +248,10 @@
Placement="Bottom"
PlacementTarget="{Binding ElementName=OverrideDropdownButtonQuality}">
<Border BorderThickness="1" Background="{DynamicResource ComboBoxDropDownBackground}">
<ListBox x:Name="ListBoxQulitiesSelection" SelectionMode="Single,Toggle" Width="210"
<ListBox x:Name="ListBoxQulitiesSelection" SelectionMode="Single,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding SelectedSeries.VideoQualityList , Mode=OneWay}"
SelectedItem="{Binding SelectedSeries.SelectedVideoQualityItem}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItem="{Binding SelectedSeries.SelectedVideoQualityItem}" />
</Border>
</Popup>
</StackPanel>
@ -289,13 +284,7 @@
<ListBox x:Name="ListBoxDubsSelection" SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding SelectedSeries.DubLangList , Mode=OneWay}"
SelectedItems="{Binding SelectedSeries.SelectedDubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedSeries.SelectedDubLang}" />
</Border>
</Popup>
</StackPanel>
@ -325,13 +314,7 @@
<ListBox SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding SelectedSeries.SubLangList , Mode=OneWay}"
SelectedItems="{Binding SelectedSeries.SelectedSubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedSeries.SelectedSubLang}" />
</Border>
</Popup>
</StackPanel>
@ -443,8 +426,27 @@
TextTrimming="CharacterEllipsis" />
<StackPanel Orientation="Horizontal" VerticalAlignment="Center"
IsVisible="{Binding HistoryEpisodeAvailableDubLang, Converter={StaticResource UiListHasElementsConverter}}">
<StackPanel Orientation="Horizontal"
VerticalAlignment="Center"
HorizontalAlignment="Left"
Background="Transparent"
IsVisible="{Binding HistoryEpisodeAvailableDubLang, Converter={StaticResource UiListHasElementsConverter}}">
<ToolTip.Tip>
<Grid ColumnDefinitions="Auto,*" MaxWidth="600">
<TextBlock Grid.Column="0" Text="Subs: " />
<ui:HighlightingTextBlock
Grid.Column="1"
Items="{Binding HistoryEpisodeAvailableSoftSubs}"
Series="{Binding $parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries}"
Season="{Binding $parent[controls:SettingsExpander].((history:HistorySeason)DataContext)}"
StreamingService="Crunchyroll"
CheckDubs="False"
FontSize="12"
Opacity="0.8"
FontStyle="Italic"
TextWrapping="Wrap" />
</Grid>
</ToolTip.Tip>
<TextBlock FontStyle="Italic"
FontSize="12"
Opacity="0.8" Text="Dubs: ">
@ -462,15 +464,6 @@
</StackPanel>
<!-- <StackPanel Orientation="Horizontal" VerticalAlignment="Center"> -->
<!-- <TextBlock FontStyle="Italic" -->
<!-- FontSize="12" -->
<!-- Opacity="0.8" Text="Subs: "> -->
<!-- </TextBlock> -->
<!-- <TextBlock FontStyle="Italic" -->
<!-- FontSize="12" -->
<!-- Opacity="0.8" Text="{Binding HistoryEpisodeAvailableSoftSubs, Converter={StaticResource UiListToStringConverter}}" /> -->
<!-- </StackPanel> -->
</StackPanel>
@ -543,9 +536,46 @@
Command="{Binding $parent[controls:SettingsExpander].((history:HistorySeason)DataContext).UpdateDownloaded}"
CommandParameter="{Binding EpisodeId}">
<Grid>
<Ellipse Width="25" Height="25" Fill="#21a556" />
<Ellipse Width="25" Height="25">
<Ellipse.Fill>
<MultiBinding Converter="{StaticResource UiHistoryDownloadStatusConverter}">
<Binding Path="." />
<Binding Path="$parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext)" />
<Binding Path="WasDownloaded" />
<Binding Path="DownloadedDubLang" />
<Binding Path="DownloadedSoftSubs" />
<Binding Path="HistoryEpisodeAvailableDubLang" />
<Binding Path="HistoryEpisodeAvailableSoftSubs" />
<Binding Path="$parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries.SelectedDubs" />
<Binding Path="$parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries.SelectedSubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedDubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedSubs" />
</MultiBinding>
</Ellipse.Fill>
</Ellipse>
<controls:SymbolIcon Symbol="Checkmark" FontSize="18" />
</Grid>
<ToolTip.Tip>
<TextBlock FontSize="15">
<TextBlock.Text>
<MultiBinding Converter="{StaticResource UiHistoryDownloadStatusConverter}" ConverterParameter="Tooltip">
<Binding Path="." />
<Binding Path="$parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext)" />
<Binding Path="WasDownloaded" />
<Binding Path="DownloadedDubLang" />
<Binding Path="DownloadedSoftSubs" />
<Binding Path="HistoryEpisodeAvailableDubLang" />
<Binding Path="HistoryEpisodeAvailableSoftSubs" />
<Binding Path="$parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries.SelectedDubs" />
<Binding Path="$parent[UserControl].((vm:SeriesPageViewModel)DataContext).SelectedSeries.SelectedSubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedDubs" />
<Binding Path="$parent[controls:SettingsExpander].((history:HistorySeason)DataContext).SelectedSubs" />
</MultiBinding>
</TextBlock.Text>
</TextBlock>
</ToolTip.Tip>
</Button>
<Button Margin="0 0 5 0" FontStyle="Italic" HorizontalAlignment="Right"
@ -768,7 +798,7 @@
<ColumnDefinition Width="*" />
<ColumnDefinition Width="Auto" />
</Grid.ColumnDefinitions>
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedVideoQualityItem.stringValue, FallbackValue=''}"
<TextBlock HorizontalAlignment="Center" Text="{Binding SelectedVideoQualityItem, FallbackValue=''}"
VerticalAlignment="Center" />
<Path Grid.Column="1" Data="M 0,1 L 4,4 L 8,1" Stroke="White" StrokeThickness="1"
VerticalAlignment="Center" Margin="5,0,5,0" Stretch="Uniform" Width="8" />
@ -783,13 +813,7 @@
<ListBox x:Name="ListBoxQulitiesSelection" SelectionMode="Single,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding VideoQualityList , Mode=OneWay}"
SelectedItem="{Binding SelectedVideoQualityItem}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItem="{Binding SelectedVideoQualityItem}" />
</Border>
</Popup>
</StackPanel>
@ -822,13 +846,7 @@
<ListBox x:Name="ListBoxDubsSelection" SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding DubLangList , Mode=OneWay}"
SelectedItems="{Binding SelectedDubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedDubLang}" />
</Border>
</Popup>
</StackPanel>
@ -858,13 +876,7 @@
<ListBox SelectionMode="Multiple,Toggle" Width="210"
MaxHeight="400"
ItemsSource="{Binding SubLangList , Mode=OneWay}"
SelectedItems="{Binding SelectedSubLang}">
<ListBox.ItemTemplate>
<DataTemplate DataType="{x:Type structs:StringItem}">
<TextBlock Text="{Binding stringValue}"></TextBlock>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
SelectedItems="{Binding SelectedSubLang}" />
</Border>
</Popup>
</StackPanel>
@ -919,4 +931,4 @@
<controls:ProgressRing Width="100" Height="100" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Grid>
</Grid>
</UserControl>
</UserControl>

View file

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

View file

@ -39,6 +39,12 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="History Check Partial Downloads" Description="Downloaded episodes missing selected dubs/subs are marked orange and counted again when the missing media becomes available">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistoryCheckPartialDownloads}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="History Include CR Artists" Description="Add Crunchyroll artists (music) to the history">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding HistoryIncludeCrArtists}"> </CheckBox>
@ -144,6 +150,12 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Replace Existing Files" Description="When enabled, downloads overwrite an existing final output file instead of creating a numbered copy">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding ReplaceExistingFiles}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Max Download Speed"
Description="Download in Kb/s - 0 is full speed">
<controls:SettingsExpanderItem.Footer>
@ -540,6 +552,12 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Proxy All Traffic">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding ProxyAllTraffic}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Use Socks5 Proxy">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding ProxySocks}"> </CheckBox>