- Added option to **update history from the calendar**

- Added **search for currently shown series** in the history view
- Changed **authentication tokens**
- Fixed **download info updates** where the resolution was shown incorrectly
This commit is contained in:
Elwador 2026-01-31 19:11:58 +01:00
parent 6abbc129b6
commit 973c45ce5c
15 changed files with 776 additions and 282 deletions

View file

@ -234,8 +234,16 @@ public class CalendarManager{
var newEpisodesBase = await CrunchyrollManager.Instance.CrEpisode.GetNewEpisodes(CrunchyrollManager.Instance.CrunOptions.HistoryLang, 2000, null, true);
if (newEpisodesBase is{ Data.Count: > 0 }){
var newEpisodes = newEpisodesBase.Data;
var newEpisodes = newEpisodesBase.Data ?? [];
if (CrunchyrollManager.Instance.CrunOptions.UpdateHistoryFromCalendar){
try{
await CrunchyrollManager.Instance.History.UpdateWithEpisode(newEpisodes);
} catch (Exception e){
Console.Error.WriteLine("Failed to update History from calendar");
}
}
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false;

View file

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

View file

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

View file

@ -1551,8 +1551,10 @@ public class CrunchyrollManager{
string qualityConsoleLog = sb.ToString();
Console.WriteLine(qualityConsoleLog);
data.AvailableQualities = qualityConsoleLog;
if (!options.DlVideoOnce || string.IsNullOrEmpty(data.AvailableQualities)){
data.AvailableQualities = qualityConsoleLog;
}
Console.WriteLine("Stream URL:" + chosenVideoSegments.segments[0].uri.Split(new[]{ ",.urlset" }, StringSplitOptions.None)[0]);

View file

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

View file

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

View file

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

View file

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

View file

@ -310,6 +310,9 @@ public class CrDownloadOptions{
[JsonProperty("calendar_show_upcoming_episodes")]
public bool CalendarShowUpcomingEpisodes{ get; set; }
[JsonProperty("calendar_update_history")]
public bool UpdateHistoryFromCalendar{ get; set; }
[JsonProperty("stream_endpoint_settings")]
public CrAuthSettings? StreamEndpoint{ get; set; }

View file

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

View file

@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommunityToolkit.Mvvm.ComponentModel;
using CRD.Utils.Structs.History;
using CRD.Views;
@ -51,9 +52,18 @@ public class LanguageItem{
public string Language{ get; set; }
}
public readonly record struct EpisodeVariant(CrunchyEpisode Item, LanguageItem Lang);
public class EpisodeAndLanguage{
public List<CrunchyEpisode> Items{ get; set; }
public List<LanguageItem> Langs{ get; set; }
public List<EpisodeVariant> Variants{ get; set; } = new();
public bool AddUnique(CrunchyEpisode item, LanguageItem lang){
if (Variants.Any(v => v.Lang.CrLocale == lang.CrLocale))
return false;
Variants.Add(new EpisodeVariant(item, lang));
return true;
}
}
public class CrunchyMultiDownload(List<string> dubLang, bool? all = null, bool? but = null, List<string>? e = null, string? s = null){

View file

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

View file

@ -44,17 +44,17 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem? _selectedView;
public ObservableCollection<ComboBoxItem> ViewsList{ get; } =[];
public ObservableCollection<ComboBoxItem> ViewsList{ get; } = [];
[ObservableProperty]
private SortingListElement? _selectedSorting;
public ObservableCollection<SortingListElement> SortingList{ get; } =[];
public ObservableCollection<SortingListElement> SortingList{ get; } = [];
[ObservableProperty]
private FilterListElement? _selectedFilter;
public ObservableCollection<FilterListElement> FilterList{ get; } =[];
public ObservableCollection<FilterListElement> FilterList{ get; } = [];
[ObservableProperty]
private double _posterWidth;
@ -115,7 +115,16 @@ public partial class HistoryPageViewModel : ViewModelBase{
[ObservableProperty]
private static string _progressText;
[ObservableProperty]
private string _searchInput;
[ObservableProperty]
private bool _isSearchOpen;
[ObservableProperty]
public bool _isSearchActiveClosed;
#region Table Mode
[ObservableProperty]
@ -125,8 +134,8 @@ public partial class HistoryPageViewModel : ViewModelBase{
public Symbol _selectedDownloadIcon = Symbol.ClosedCaption;
#endregion
public Vector LastScrollOffset { get; set; } = Vector.Zero;
public Vector LastScrollOffset{ get; set; } = Vector.Zero;
public HistoryPageViewModel(){
ProgramManager = ProgramManager.Instance;
@ -324,11 +333,32 @@ public partial class HistoryPageViewModel : ViewModelBase{
filteredItems.RemoveAll(item => item.SeriesType == SeriesType.Series);
}
if (!string.IsNullOrWhiteSpace(SearchInput)){
var tokens = SearchInput
.Split(' ', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries);
filteredItems.RemoveAll(item => {
var title = item.SeriesTitle ?? string.Empty;
return tokens.Any(t => title.IndexOf(t, StringComparison.OrdinalIgnoreCase) < 0);
});
}
FilteredItems.Clear();
FilteredItems.AddRange(filteredItems);
}
partial void OnSearchInputChanged(string value){
ApplyFilter();
}
partial void OnIsSearchOpenChanged(bool value){
IsSearchActiveClosed = !string.IsNullOrEmpty(SearchInput) && !IsSearchOpen;
}
partial void OnScaleValueChanged(double value){
double t = (ScaleValue - 0.5) / (1 - 0.5);
@ -374,6 +404,10 @@ public partial class HistoryPageViewModel : ViewModelBase{
}
}
[RelayCommand]
public void ClearSearchCommand(){
SearchInput = "";
}
[RelayCommand]
public void NavToSeries(){
@ -539,7 +573,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
await episode.DownloadEpisode();
}
}
[RelayCommand]
public async Task DownloadEpisodeOnlyOptions(HistoryEpisode episode){
var downloadMode = SelectedDownloadMode;
@ -606,7 +640,7 @@ public partial class HistoryPageViewModel : ViewModelBase{
public void ToggleInactive(){
CfgManager.UpdateHistoryFile();
}
partial void OnSelectedDownloadModeChanged(EpisodeDownloadMode value){
SelectedDownloadIcon = SelectedDownloadMode switch{
EpisodeDownloadMode.OnlyVideo => Symbol.Video,

View file

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

View file

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