- Added **toggle to also download description audio** for selected dubs

- Added **automatic history backups** retained for up to 5 days
- Improved **season tab** to display series more effectively
- Improved **history saving** for increased data safety
- Removed **"None" option** from the hardsub selection popup
This commit is contained in:
Elwador 2025-11-06 20:42:25 +01:00
parent 8a5c51900b
commit dc570bf420
28 changed files with 1130 additions and 416 deletions

View file

@ -208,6 +208,7 @@ public class CalendarManager{
//EpisodeAirDate
foreach (var crBrowseEpisode in newEpisodes){
bool filtered = false;
DateTime episodeAirDate = crBrowseEpisode.EpisodeMetadata.EpisodeAirDate.Kind == DateTimeKind.Utc
? crBrowseEpisode.EpisodeMetadata.EpisodeAirDate.ToLocalTime()
: crBrowseEpisode.EpisodeMetadata.EpisodeAirDate;
@ -257,13 +258,13 @@ public class CalendarManager{
(crBrowseEpisode.EpisodeMetadata.SeasonTitle.EndsWith("Dub)") || crBrowseEpisode.EpisodeMetadata.SeasonTitle.EndsWith("Audio)")) &&
(string.IsNullOrEmpty(dubFilter) || dubFilter == "none" || (crBrowseEpisode.EpisodeMetadata.AudioLocale != null && crBrowseEpisode.EpisodeMetadata.AudioLocale.GetEnumMemberValue() != dubFilter))){
//|| crBrowseEpisode.EpisodeMetadata.AudioLocale != Locale.JaJp
continue;
filtered = true;
}
if (!string.IsNullOrEmpty(dubFilter) && dubFilter != "none"){
if (crBrowseEpisode.EpisodeMetadata.AudioLocale != null && crBrowseEpisode.EpisodeMetadata.AudioLocale.GetEnumMemberValue() != dubFilter){
continue;
filtered = true;
}
}
@ -274,6 +275,12 @@ public class CalendarManager{
if (calendarDay != null){
CalendarEpisode calEpisode = new CalendarEpisode();
string? seasonTitle = string.IsNullOrEmpty(crBrowseEpisode.EpisodeMetadata.SeasonTitle)
? crBrowseEpisode.EpisodeMetadata.SeriesTitle
: Regex.IsMatch(crBrowseEpisode.EpisodeMetadata.SeasonTitle, @"^Season\s+\d+$", RegexOptions.IgnoreCase)
? $"{crBrowseEpisode.EpisodeMetadata.SeriesTitle} {crBrowseEpisode.EpisodeMetadata.SeasonTitle}"
: crBrowseEpisode.EpisodeMetadata.SeasonTitle;
calEpisode.DateTime = targetDate;
calEpisode.HasPassed = DateTime.Now > targetDate;
calEpisode.EpisodeName = crBrowseEpisode.Title;
@ -282,12 +289,14 @@ public class CalendarManager{
calEpisode.ThumbnailUrl = crBrowseEpisode.Images.Thumbnail?.FirstOrDefault()?.FirstOrDefault()?.Source ?? ""; //https://www.crunchyroll.com/i/coming_soon_beta_thumb.jpg
calEpisode.IsPremiumOnly = crBrowseEpisode.EpisodeMetadata.IsPremiumOnly;
calEpisode.IsPremiere = crBrowseEpisode.EpisodeMetadata.Episode == "1";
calEpisode.SeasonName = crBrowseEpisode.EpisodeMetadata.SeasonTitle;
calEpisode.SeasonName = seasonTitle;
calEpisode.EpisodeNumber = crBrowseEpisode.EpisodeMetadata.Episode;
calEpisode.CrSeriesID = crBrowseEpisode.EpisodeMetadata.SeriesId;
calEpisode.FilteredOut = filtered;
calEpisode.AudioLocale = crBrowseEpisode.EpisodeMetadata.AudioLocale;
var existingEpisode = calendarDay.CalendarEpisodes
.FirstOrDefault(e => e.SeasonName == calEpisode.SeasonName);
.FirstOrDefault(e => e.SeasonName == calEpisode.SeasonName && e.AudioLocale == calEpisode.AudioLocale);
if (existingEpisode != null){
if (!int.TryParse(existingEpisode.EpisodeNumber, out _)){
@ -330,8 +339,8 @@ public class CalendarManager{
if (ProgramManager.Instance.AnilistUpcoming.ContainsKey(calendarDay.DateTime.ToString("yyyy-MM-dd"))){
var list = ProgramManager.Instance.AnilistUpcoming[calendarDay.DateTime.ToString("yyyy-MM-dd")];
foreach (var calendarEpisode in list.Where(calendarEpisode => calendarDay.DateTime.Date.Day == calendarEpisode.DateTime.Date.Day)
.Where(calendarEpisode => calendarDay.CalendarEpisodes.All(ele => ele.CrSeriesID != calendarEpisode.CrSeriesID && ele.SeasonName != calendarEpisode.SeasonName))){
foreach (var calendarEpisode in list.Where(calendarEpisodeAnilist => calendarDay.DateTime.Date.Day == calendarEpisodeAnilist.DateTime.Date.Day)
.Where(calendarEpisodeAnilist => calendarDay.CalendarEpisodes.All(ele => ele.CrSeriesID != calendarEpisodeAnilist.CrSeriesID && ele.SeasonName != calendarEpisodeAnilist.SeasonName))){
calendarDay.CalendarEpisodes.Add(calendarEpisode);
}
}
@ -342,6 +351,7 @@ public class CalendarManager{
foreach (var weekCalendarDay in week.CalendarDays){
if (weekCalendarDay.CalendarEpisodes.Count > 0)
weekCalendarDay.CalendarEpisodes = weekCalendarDay.CalendarEpisodes
.Where(e => !e.FilteredOut)
.OrderBy(e => e.AnilistEpisode) // False first, then true
.ThenBy(e => e.DateTime)
.ThenBy(e => e.SeasonName)

View file

@ -11,6 +11,7 @@ using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
using System.Xml;
using CRD.Downloader.Crunchyroll.Utils;
using CRD.Utils;
using CRD.Utils.DRM;
using CRD.Utils.Ffmpeg_Encoding;
@ -50,6 +51,7 @@ public class CrunchyrollManager{
public string DefaultLocale = "en-US";
public CrAuthSettings DefaultAndroidAuthSettings = new CrAuthSettings();
public JsonSerializerSettings? SettingsJsonSerializerSettings = new(){
NullValueHandling = NullValueHandling.Ignore,
@ -119,6 +121,7 @@ public class CrunchyrollManager{
options.Timeout = 15000;
options.DubLang = new List<string>(){ "ja-JP" };
options.SimultaneousDownloads = 2;
options.SimultaneousProcessingJobs = 2;
// options.AccentColor = Colors.SlateBlue.ToString();
options.Theme = "System";
options.SelectedCalendarLanguage = "en-us";
@ -128,6 +131,8 @@ public class CrunchyrollManager{
options.StreamEndpoint = "web/firefox";
options.SubsAddScaledBorder = ScaledBorderAndShadowSelection.DontAdd;
options.HistoryLang = DefaultLocale;
options.FixCccSubtitles = true;
options.ConvertVtt2Ass = true;
options.BackgroundImageOpacity = 0.5;
options.BackgroundImageBlurRadius = 10;
@ -194,24 +199,26 @@ public class CrunchyrollManager{
CfgManager.DisableLogMode();
}
DefaultAndroidAuthSettings = new CrAuthSettings(){
Endpoint = "android/phone",
Authorization = "Basic YmY3MHg2aWhjYzhoZ3p3c2J2eGk6eDJjc3BQZXQzWno1d0pDdEpyVUNPSVM5Ynpad1JDcGM=",
UserAgent = "Crunchyroll/3.90.0 Android/16 okhttp/4.12.0",
Device_name = "CPH2449",
Device_type = "OnePlus CPH2449"
};
CrunOptions.StreamEndpoint = "tv/android_tv";
CrAuthEndpoint1.AuthSettings = new CrAuthSettings(){
Endpoint = "tv/android_tv",
Authorization = "Basic Y2I5bnpybWh0MzJ2Z3RleHlna286S1V3bU1qSlh4eHVyc0hJVGQxenZsMkMyeVFhUW84TjQ=",
UserAgent = "ANDROIDTV/3.42.1_22273 Android/16",
Authorization = "Basic ZGsxYndzemRyc3lkeTR1N2xvenE6bDl0SU1BdTlzTGc4ZjA4ajlfQkQ4eWZmQmZTSms0R0o=",
UserAgent = "ANDROIDTV/3.47.0_22277 Android/16",
Device_name = "Android TV",
Device_type = "Android TV"
};
if (CrunOptions.StreamEndpointSecondSettings == null){
CrunOptions.StreamEndpointSecondSettings = new CrAuthSettings(){
Endpoint = "android/phone",
Authorization = "Basic YmY3MHg2aWhjYzhoZ3p3c2J2eGk6eDJjc3BQZXQzWno1d0pDdEpyVUNPSVM5Ynpad1JDcGM=",
UserAgent = "Crunchyroll/3.90.0 Android/16 okhttp/4.12.0",
Device_name = "CPH2449",
Device_type = "OnePlus CPH2449"
};
CrunOptions.StreamEndpointSecondSettings = DefaultAndroidAuthSettings;
}
CrAuthEndpoint2.AuthSettings = CrunOptions.StreamEndpointSecondSettings;
@ -312,43 +319,121 @@ public class CrunchyrollManager{
return false;
}
if (options.DownloadAllowEarlyStart){
QueueManager.Instance.DecrementDownloads();
}
if (options.SkipMuxing == false){
bool syncError = false;
bool muxError = false;
var notSyncedDubs = "";
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Muxing"
};
QueueManager.Instance.Queue.Refresh();
if (options.MuxFonts){
await FontsManager.Instance.GetFontsAsync();
try{
if (options.DownloadAllowEarlyStart){
QueueManager.Instance.DecrementDownloads();
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Waiting for Muxing/Encoding"
};
QueueManager.Instance.Queue.Refresh();
await QueueManager.Instance.activeProcessingJobs.WaitAsync(data.Cts.Token);
}
var fileNameAndPath = options.DownloadToTempFolder
? Path.Combine(res.TempFolderPath ?? string.Empty, res.FileName ?? string.Empty)
: Path.Combine(res.FolderPath ?? string.Empty, res.FileName ?? string.Empty);
if (options is{ DlVideoOnce: false, KeepDubsSeperate: true }){
var groupByDub = Helpers.GroupByLanguageWithSubtitles(res.Data);
var mergers = new List<Merger>();
foreach (var keyValue in groupByDub){
var result = await MuxStreams(keyValue.Value,
if (options.SkipMuxing == false){
bool syncError = false;
bool muxError = false;
var notSyncedDubs = "";
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Muxing"
};
QueueManager.Instance.Queue.Refresh();
if (options.MuxFonts){
await FontsManager.Instance.GetFontsAsync();
}
var fileNameAndPath = options.DownloadToTempFolder
? Path.Combine(res.TempFolderPath ?? string.Empty, res.FileName ?? string.Empty)
: Path.Combine(res.FolderPath ?? string.Empty, res.FileName ?? string.Empty);
if (options is{ DlVideoOnce: false, KeepDubsSeperate: true }){
var groupByDub = Helpers.GroupByLanguageWithSubtitles(res.Data);
var mergers = new List<Merger>();
foreach (var keyValue in groupByDub){
var result = await MuxStreams(keyValue.Value,
new CrunchyMuxOptions{
DubLangList = options.DubLang,
SubLangList = options.DlSubs,
FfmpegOptions = options.FfmpegOptions,
SkipSubMux = options.SkipSubsMux,
Output = fileNameAndPath + $".{keyValue.Value.First().Lang.Locale}",
Mp4 = options.Mp4,
Mp3 = options.AudioOnlyToMp3,
MuxFonts = options.MuxFonts,
MuxCover = options.MuxCover,
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
DefaultAudio = Languages.FindLang(options.DefaultAudio),
DefaultSub = Languages.FindLang(options.DefaultSub),
MkvmergeOptions = options.MkvmergeOptions,
ForceMuxer = options.Force,
SyncTiming = options.SyncTiming,
CcTag = options.CcTag,
KeepAllVideos = true,
MuxDescription = options.IncludeVideoDescription,
DlVideoOnce = options.DlVideoOnce,
DefaultSubSigns = options.DefaultSubSigns,
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
CcSubsMuxingFlag = options.CcSubsMuxingFlag,
SignsSubsAsForced = options.SignsSubsAsForced,
},
fileNameAndPath + $".{keyValue.Value.First().Lang.Locale}", data);
if (result is{ merger: not null, isMuxed: true }){
mergers.Add(result.merger);
}
if (!result.isMuxed && !data.OnlySubs){
muxError = true;
}
if (result.syncError){
syncError = true;
}
}
foreach (var merger in mergers){
merger.CleanUp();
if (options.IsEncodeEnabled){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Encoding"
};
QueueManager.Instance.Queue.Refresh();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null) await Helpers.RunFFmpegWithPresetAsync(merger.options.Output, preset, data);
}
if (options.DownloadToTempFolder){
await MoveFromTempFolder(merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, merger.options.Subtitles);
}
}
} else{
var result = await MuxStreams(res.Data,
new CrunchyMuxOptions{
DubLangList = options.DubLang,
SubLangList = options.DlSubs,
FfmpegOptions = options.FfmpegOptions,
SkipSubMux = options.SkipSubsMux,
Output = fileNameAndPath + $".{keyValue.Value.First().Lang.Locale}",
Output = fileNameAndPath,
Mp4 = options.Mp4,
Mp3 = options.AudioOnlyToMp3,
MuxFonts = options.MuxFonts,
@ -370,25 +455,17 @@ public class CrunchyrollManager{
CcSubsMuxingFlag = options.CcSubsMuxingFlag,
SignsSubsAsForced = options.SignsSubsAsForced,
},
fileNameAndPath + $".{keyValue.Value.First().Lang.Locale}", data);
fileNameAndPath, data);
syncError = result.syncError;
notSyncedDubs = result.notSyncedDubs;
muxError = !result.isMuxed && !data.OnlySubs;
if (result is{ merger: not null, isMuxed: true }){
mergers.Add(result.merger);
result.merger.CleanUp();
}
if (!result.isMuxed){
muxError = true;
}
if (result.syncError){
syncError = true;
}
}
foreach (var merger in mergers){
merger.CleanUp();
if (options.IsEncodeEnabled){
if (options.IsEncodeEnabled && !muxError){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
@ -400,117 +477,63 @@ public class CrunchyrollManager{
QueueManager.Instance.Queue.Refresh();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null) await Helpers.RunFFmpegWithPresetAsync(merger.options.Output, preset, data);
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.options.Output, preset, data);
}
if (options.DownloadToTempFolder){
await MoveFromTempFolder(merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, res.Data.Where(e => e.Type == DownloadMediaType.Subtitle));
await MoveFromTempFolder(result.merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, result.merger?.options.Subtitles ?? []);
}
}
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Done = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "")
};
if (CrunOptions.RemoveFinishedDownload && !syncError){
QueueManager.Instance.Queue.Remove(data);
}
} else{
var result = await MuxStreams(res.Data,
new CrunchyMuxOptions{
DubLangList = options.DubLang,
SubLangList = options.DlSubs,
FfmpegOptions = options.FfmpegOptions,
SkipSubMux = options.SkipSubsMux,
Output = fileNameAndPath,
Mp4 = options.Mp4,
Mp3 = options.AudioOnlyToMp3,
MuxFonts = options.MuxFonts,
MuxCover = options.MuxCover,
VideoTitle = res.VideoTitle,
Novids = options.Novids,
NoCleanup = options.Nocleanup,
DefaultAudio = Languages.FindLang(options.DefaultAudio),
DefaultSub = Languages.FindLang(options.DefaultSub),
MkvmergeOptions = options.MkvmergeOptions,
ForceMuxer = options.Force,
SyncTiming = options.SyncTiming,
CcTag = options.CcTag,
KeepAllVideos = true,
MuxDescription = options.IncludeVideoDescription,
DlVideoOnce = options.DlVideoOnce,
DefaultSubSigns = options.DefaultSubSigns,
DefaultSubForcedDisplay = options.DefaultSubForcedDisplay,
CcSubsMuxingFlag = options.CcSubsMuxingFlag,
SignsSubsAsForced = options.SignsSubsAsForced,
},
fileNameAndPath, data);
syncError = result.syncError;
notSyncedDubs = result.notSyncedDubs;
muxError = !result.isMuxed;
if (result is{ merger: not null, isMuxed: true }){
result.merger.CleanUp();
}
if (options.IsEncodeEnabled && !muxError){
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Encoding"
};
QueueManager.Instance.Queue.Refresh();
var preset = FfmpegEncoding.GetPreset(options.EncodingPresetName ?? string.Empty);
if (preset != null && result.merger != null) await Helpers.RunFFmpegWithPresetAsync(result.merger.options.Output, preset, data);
}
Console.WriteLine("Skipping mux");
res.Data.ForEach(file => Helpers.DeleteFile(file.Path + ".resume"));
if (options.DownloadToTempFolder){
await MoveFromTempFolder(result.merger, data, options, res.TempFolderPath ?? CfgManager.PathTEMP_DIR, res.Data.Where(e => e.Type == DownloadMediaType.Subtitle));
}
}
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Done = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = (muxError ? "Muxing Failed" : "Done") + (syncError ? $" - Couldn't sync dubs ({notSyncedDubs})" : "")
};
if (CrunOptions.RemoveFinishedDownload && !syncError){
QueueManager.Instance.Queue.Remove(data);
}
} else{
Console.WriteLine("Skipping mux");
res.Data.ForEach(file => Helpers.DeleteFile(file.Path + ".resume"));
if (options.DownloadToTempFolder){
if (string.IsNullOrEmpty(res.TempFolderPath) || !Directory.Exists(res.TempFolderPath)){
Console.WriteLine("Invalid or non-existent temp folder path.");
} else{
// Move files
foreach (var downloadedMedia in res.Data){
await MoveFile(downloadedMedia.Path ?? string.Empty, res.TempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
if (string.IsNullOrEmpty(res.TempFolderPath) || !Directory.Exists(res.TempFolderPath)){
Console.WriteLine("Invalid or non-existent temp folder path.");
} else{
// Move files
foreach (var downloadedMedia in res.Data){
await MoveFile(downloadedMedia.Path ?? string.Empty, res.TempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
}
}
}
}
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Done = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Done - Skipped muxing"
};
data.DownloadProgress = new DownloadProgress(){
IsDownloading = true,
Done = true,
Percent = 100,
Time = 0,
DownloadSpeed = 0,
Doing = "Done - Skipped muxing"
};
if (CrunOptions.RemoveFinishedDownload){
QueueManager.Instance.Queue.Remove(data);
if (CrunOptions.RemoveFinishedDownload){
QueueManager.Instance.Queue.Remove(data);
}
}
} catch (OperationCanceledException){
// expected when removed/canceled
} finally{
if (options.DownloadAllowEarlyStart) QueueManager.Instance.activeProcessingJobs.Release();
}
if (!options.DownloadAllowEarlyStart){
QueueManager.Instance.IncrementDownloads();
QueueManager.Instance.DecrementDownloads();
}
QueueManager.Instance.Queue.Refresh();
@ -525,6 +548,7 @@ public class CrunchyrollManager{
}
if (QueueManager.Instance.Queue.Count == 0 || QueueManager.Instance.Queue.All(e => e.DownloadProgress.Done)){
QueueManager.Instance.ResetDownloads();
try{
var audioPath = CrunOptions.DownloadFinishedSoundPath;
if (!string.IsNullOrEmpty(audioPath)){
@ -545,7 +569,7 @@ public class CrunchyrollManager{
#region Temp Files Move
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, IEnumerable<DownloadedMedia> subtitles){
private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List<SubtitleInput> subtitles){
if (!options.DownloadToTempFolder) return;
data.DownloadProgress = new DownloadProgress{
@ -568,7 +592,7 @@ public class CrunchyrollManager{
// Move the subtitle files
foreach (var downloadedMedia in subtitles){
await MoveFile(downloadedMedia.Path ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
await MoveFile(downloadedMedia.File ?? string.Empty, tempFolderPath, data.DownloadPath ?? CfgManager.PathVIDEOS_DIR, options);
}
}
@ -671,7 +695,8 @@ public class CrunchyrollManager{
SubLangList = options.SubLangList,
OnlyVid = data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(),
SkipSubMux = options.SkipSubMux,
OnlyAudio = data.Where(a => a.Type == DownloadMediaType.Audio).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate }).ToList(),
OnlyAudio = data.Where(a => a.Type is DownloadMediaType.Audio or DownloadMediaType.AudioRoleDescription).Select(a => new MergerInput
{ Language = a.Lang, Path = a.Path ?? string.Empty, Bitrate = a.bitrate, IsAudioRoleDescription = (a.Type is DownloadMediaType.AudioRoleDescription) }).ToList(),
Output = $"{filename}.{(muxToMp3 ? "mp3" : options.Mp4 ? "mp4" : "mkv")}",
Subtitles = data.Where(a => a.Type == DownloadMediaType.Subtitle).Select(a => new SubtitleInput
{ File = a.Path ?? string.Empty, Language = a.Language, ClosedCaption = a.Cc ?? false, Signs = a.Signs ?? false, RelatedVideoDownloadMedia = a.RelatedVideoDownloadMedia }).ToList(),
@ -878,8 +903,46 @@ public class CrunchyrollManager{
if (data.Data is{ Count: > 0 }){
options.Partsize = options.Partsize > 0 ? options.Partsize : 1;
if (options.DownloadDescriptionAudio){
var alreadyAdr = new HashSet<string>(
data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err")
);
bool HasDescriptionRole(IEnumerable<string>? roles) =>
roles?.Any(r => string.Equals(r, "description", StringComparison.OrdinalIgnoreCase)) == true;
var toDuplicate = data.Data
.Where(m => !m.IsAudioRoleDescription)
.Where(m => !alreadyAdr.Contains(m.Lang?.CrLocale ?? "err"))
.Where(m => m.Versions?.Any(v => (v.AudioLocale == (m.Lang?.CrLocale ?? "err"))
&& HasDescriptionRole(v.roles)) == true)
.ToList();
var additions = toDuplicate.Select(m => new CrunchyEpMetaData{
MediaId = m.MediaId,
Lang = m.Lang,
Playback = m.Playback,
Versions = m.Versions,
IsSubbed = m.IsSubbed,
IsDubbed = m.IsDubbed,
IsAudioRoleDescription = true
}).ToList();
data.Data.AddRange(additions);
}
var rank = options.DubLang
.Select((val, i) => new{ val, i })
.ToDictionary(x => x.val, x => x.i, StringComparer.OrdinalIgnoreCase);
var sortedMetaData = data.Data
.OrderBy(metaData => options.DubLang.IndexOf(metaData.Lang?.CrLocale ?? string.Empty) != -1 ? options.DubLang.IndexOf(metaData.Lang?.CrLocale ?? string.Empty) : int.MaxValue)
.OrderBy(m => {
var key = m.Lang?.CrLocale ?? string.Empty;
return rank.TryGetValue(key, out var r) ? r : int.MaxValue; // unknown locales last
})
.ThenBy(m => m.IsAudioRoleDescription) // false first, then true
.ToList();
data.Data = sortedMetaData;
@ -953,18 +1016,18 @@ public class CrunchyrollManager{
if (options.Chapters && !data.OnlySubs){
await ParseChapters(mediaGuid, compiledChapters);
if (compiledChapters.Count == 0 && primaryVersion.MediaGuid != null && mediaGuid != primaryVersion.MediaGuid){
if (compiledChapters.Count == 0 && !string.IsNullOrEmpty(primaryVersion.Guid) && mediaGuid != primaryVersion.Guid){
Console.Error.WriteLine("Chapters empty trying to get original version chapters - might not match with video");
await ParseChapters(primaryVersion.MediaGuid, compiledChapters);
await ParseChapters(primaryVersion.Guid, compiledChapters);
}
}
#endregion
var fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music);
var fetchPlaybackData = await FetchPlaybackData(CrAuthEndpoint1, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription);
(bool IsOk, PlaybackData pbData, string error) fetchPlaybackData2 = default;
if (CrAuthEndpoint2.Profile.Username != "???"){
fetchPlaybackData2 = await FetchPlaybackData(CrAuthEndpoint2, mediaId, mediaGuid, data.Music);
fetchPlaybackData2 = await FetchPlaybackData(CrAuthEndpoint2, mediaId, mediaGuid, data.Music, epMeta.IsAudioRoleDescription);
}
if (!fetchPlaybackData.IsOk){
@ -1058,7 +1121,7 @@ public class CrunchyrollManager{
if (pbStreams?.Keys != null){
var pb = pbStreams.Select(v => {
if (v.Value is{ IsHardsubbed: true, HardsubLocale: not null } && v.Value.HardsubLocale != Locale.DefaulT && !hsLangs.Contains(v.Value.HardsubLang.CrLocale)){
if (v.Key != "none" && v.Value is{ IsHardsubbed: true, HardsubLocale: not null } && v.Value.HardsubLocale != Locale.DefaulT && !hsLangs.Contains(v.Value.HardsubLang.CrLocale)){
hsLangs.Add(v.Value.HardsubLang.CrLocale);
}
@ -1309,22 +1372,27 @@ public class CrunchyrollManager{
language = item.language,
bandwidth = item.bandwidth,
audioSamplingRate = item.audioSamplingRate,
resolutionText = $"{Math.Round(item.bandwidth / 1000.0)}kB/s"
resolutionText = $"{Math.Round(item.bandwidth / 1000.0)}kB/s",
resolutionTextSnap = $"{Helpers.SnapToAudioBucket(Helpers.ToKbps(item.bandwidth))}kB/s",
}).ToList();
// Video: Remove duplicates by resolution (width, height), keep highest bandwidth, then sort
// ---------- VIDEO: dedupe & sort ----------
videos = videos
.GroupBy(v => new{ v.quality.width, v.quality.height })
.GroupBy(v => new{ v.quality.height, WB = Helpers.WidthBucket(v.quality.width, v.quality.height) })
.Select(g => g.OrderByDescending(v => v.bandwidth).First())
.OrderBy(v => v.quality.width)
.OrderBy(v => v.quality.height)
.ThenBy(v => v.bandwidth)
.ToList();
// Audio: Remove duplicates, then sort by bandwidth
// ---------- AUDIO: dedupe & sort ----------
audios = audios
.GroupBy(a => new{ a.bandwidth, a.language })
.Select(g => g.OrderByDescending(x => x.audioSamplingRate).First())
.OrderBy(a => a.bandwidth)
.Select(a => new{ Item = a, Lang = string.IsNullOrWhiteSpace(a.language?.CrLocale) ? "und" : a.language.CrLocale, Bucket = Helpers.SnapToAudioBucket(Helpers.ToKbps(a.bandwidth)) })
.GroupBy(x => new{ x.Lang, x.Bucket })
.Select(g => g.OrderByDescending(x => x.Item.@default)
.ThenByDescending(x => x.Item.audioSamplingRate)
.ThenByDescending(x => x.Item.bandwidth)
.First().Item)
.OrderBy(a => Helpers.ToKbps(a.bandwidth))
.ThenBy(a => a.audioSamplingRate)
.ToList();
@ -1363,7 +1431,7 @@ public class CrunchyrollManager{
} else if (options.QualityAudio == "worst"){
chosenAudioQuality = 1;
} else{
var tempIndex = audios.FindIndex(a => a.resolutionText == options.QualityAudio);
var tempIndex = audios.FindIndex(a => a.resolutionTextSnap == options.QualityAudio);
if (tempIndex < 0){
chosenAudioQuality = audios.Count;
} else{
@ -1386,7 +1454,7 @@ public class CrunchyrollManager{
foreach (var server in streamServers){
Console.WriteLine($"\t{server}");
}
var sb = new StringBuilder();
sb.AppendLine("Available Video Qualities:");
for (int i = 0; i < videos.Count; i++){
@ -1401,7 +1469,7 @@ public class CrunchyrollManager{
variables.Add(new Variable("height", chosenVideoSegments.quality.height, false));
variables.Add(new Variable("width", chosenVideoSegments.quality.width, false));
if (string.IsNullOrEmpty(data.Resolution)) data.Resolution = chosenVideoSegments.quality.height + "p";
LanguageItem? lang = Languages.languages.FirstOrDefault(a => a.CrLocale == curStream.AudioLang.CrLocale);
if (lang == null){
@ -1423,10 +1491,8 @@ public class CrunchyrollManager{
string qualityConsoleLog = sb.ToString();
Console.WriteLine(qualityConsoleLog);
data.AvailableQualities = qualityConsoleLog;
Console.WriteLine("Stream URL:" + chosenVideoSegments.segments[0].uri.Split(new[]{ ",.urlset" }, StringSplitOptions.None)[0]);
fileName = Path.Combine(FileNameManager.ParseFileName(options.FileName, variables, options.Numbers, options.FileNameWhitespaceSubstitute, options.Override).ToArray());
@ -1460,7 +1526,7 @@ public class CrunchyrollManager{
}
//string outFile = Path.Combine(FileNameManager.ParseFileName(options.FileName + "." + (epMeta.Lang?.CrLocale ?? lang.Value.Name), variables, options.Numbers, options.Override).ToArray());
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale);
string outFile = fileName + "." + (epMeta.Lang?.CrLocale ?? lang.CrLocale) + (epMeta.IsAudioRoleDescription ? ".AD" : "");
string tempFile = Path.Combine(FileNameManager
.ParseFileName($"temp-{(!string.IsNullOrEmpty(currentVersion.Guid) ? currentVersion.Guid : currentMediaId)}", variables, options.Numbers, options.FileNameWhitespaceSubstitute,
@ -1647,8 +1713,8 @@ public class CrunchyrollManager{
commandVideo, tempTsFileWorkDir);
if (!decryptVideo.IsOk){
Console.Error.WriteLine($"Decryption failed with exit code {decryptVideo.ErrorCode}");
MainWindow.Instance.ShowError($"Decryption failed with exit code {decryptVideo.ErrorCode}");
Console.Error.WriteLine($"Decryption failed with exit code {decryptVideo.ErrorCode}\n" + (shaka ? "Downgrade to Shaka-Packager v2.6.1 or use mp4decrypt" : ""));
MainWindow.Instance.ShowError($"Decryption failed with exit code {decryptVideo.ErrorCode}\n" + (shaka ? "Downgrade to Shaka-Packager v2.6.1 or use mp4decrypt" : ""));
try{
File.Move($"{tempTsFile}.video.enc.m4s", $"{tsFile}.video.enc.m4s");
} catch (IOException ex){
@ -1660,7 +1726,7 @@ public class CrunchyrollManager{
Data = files,
Error = dlFailed,
FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(fileDir, fileName)) : "./unknown",
ErrorText = "Decryption failed"
ErrorText = (shaka ? "[SHAKA]" : "[MP4Decrypt]") + " Decryption failed"
};
}
@ -1718,7 +1784,8 @@ public class CrunchyrollManager{
commandAudio, tempTsFileWorkDir);
if (!decryptAudio.IsOk){
Console.Error.WriteLine($"Decryption failed with exit code {decryptAudio.ErrorCode}");
Console.Error.WriteLine($"Decryption failed with exit code {decryptAudio.ErrorCode}\n" + (shaka ? "Downgrade to Shaka-Packager v2.6.1 or use mp4decrypt" : ""));
MainWindow.Instance.ShowError($"Decryption failed with exit code {decryptAudio.ErrorCode}\n" + (shaka ? "Downgrade to Shaka-Packager v2.6.1 or use mp4decrypt" : ""));
try{
File.Move($"{tempTsFile}.audio.enc.m4s", $"{tsFile}.audio.enc.m4s");
} catch (IOException ex){
@ -1730,7 +1797,7 @@ public class CrunchyrollManager{
Data = files,
Error = dlFailed,
FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(fileDir, fileName)) : "./unknown",
ErrorText = "Decryption failed"
ErrorText = (shaka ? "[SHAKA]" : "[MP4Decrypt]") + " Decryption failed"
};
}
@ -1761,7 +1828,7 @@ public class CrunchyrollManager{
}
files.Add(new DownloadedMedia{
Type = DownloadMediaType.Audio,
Type = epMeta.IsAudioRoleDescription ? DownloadMediaType.AudioRoleDescription : DownloadMediaType.Audio,
Path = $"{tsFile}.audio.m4s",
Lang = lang,
IsPrimary = isPrimary,
@ -1791,7 +1858,7 @@ public class CrunchyrollManager{
if (audioDownloaded){
files.Add(new DownloadedMedia{
Type = DownloadMediaType.Audio,
Type = epMeta.IsAudioRoleDescription ? DownloadMediaType.AudioRoleDescription : DownloadMediaType.Audio,
Path = $"{tsFile}.audio.m4s",
Lang = lang,
IsPrimary = isPrimary,
@ -2045,25 +2112,13 @@ public class CrunchyrollManager{
if (subsAssReqResponse.IsOk){
if (subsItem.format == "ass"){
var sBodySplit = subsAssReqResponse.ResponseContent.Split(new[]{ "\r\n" }, StringSplitOptions.None).ToList();
subsAssReqResponse.ResponseContent =
SubtitleUtils.CleanAssAndEnsureScriptInfo(subsAssReqResponse.ResponseContent, options, langItem);
if (sBodySplit.Count > 2){
if (options.SubsAddScaledBorder == ScaledBorderAndShadowSelection.ScaledBorderAndShadowYes){
sBodySplit.Insert(2, "ScaledBorderAndShadow: yes");
} else if (options.SubsAddScaledBorder == ScaledBorderAndShadowSelection.ScaledBorderAndShadowNo){
sBodySplit.Insert(2, "ScaledBorderAndShadow: no");
}
}
subsAssReqResponse.ResponseContent = string.Join("\r\n", sBodySplit);
if (sBodySplit.Count > 1){
sxData.Title = sBodySplit[1].Replace("Title: ", "");
sxData.Title = $"{langItem.Language} / {sxData.Title}";
var keysList = FontsManager.ExtractFontsFromAss(subsAssReqResponse.ResponseContent);
sxData.Fonts = FontsManager.Instance.GetDictFromKeyList(keysList);
}
} else if (subsItem.format == "vtt"){
sxData.Title = $"{langItem.Name}";
var keysList = FontsManager.ExtractFontsFromAss(subsAssReqResponse.ResponseContent);
sxData.Fonts = FontsManager.Instance.GetDictFromKeyList(keysList);
} else if (subsItem.format == "vtt" && options.ConvertVtt2Ass){
var assBuilder = new StringBuilder();
assBuilder.AppendLine("[Script Info]");
@ -2090,30 +2145,50 @@ public class CrunchyrollManager{
// Parse the VTT content
string normalizedContent = subsAssReqResponse.ResponseContent.Replace("\r\n", "\n").Replace("\r", "\n");
var blocks = normalizedContent.Split(new[]{ "\n\n" }, StringSplitOptions.RemoveEmptyEntries);
Regex timePattern = new Regex(@"(?<start>\d{2}:\d{2}:\d{2}\.\d{3})\s-->\s(?<end>\d{2}:\d{2}:\d{2}\.\d{3})");
var timePattern = new Regex(
@"^(?<start>(?:\d{2}:)?\d{2}:\d{2}\.\d{3})\s*-->\s*(?<end>(?:\d{2}:)?\d{2}:\d{2}\.\d{3})(?:\s+.+)?$",
RegexOptions.Compiled);
foreach (var block in blocks){
// Split each block into lines
var lines = block.Split(new[]{ '\n' }, StringSplitOptions.RemoveEmptyEntries);
var lines = normalizedContent.Split('\n');
int i = 0;
if (lines.Length < 3) continue; // Skip blocks that don't have enough lines
while (i < lines.Length){
var line = lines[i].TrimEnd();
if (string.IsNullOrWhiteSpace(line) || line.Equals("WEBVTT", StringComparison.OrdinalIgnoreCase) || line.Equals("STYLE", StringComparison.OrdinalIgnoreCase)){
i++;
continue;
}
// Match the first line to get the time codes
Match match = timePattern.Match(lines[1]);
int timeLineIndex = -1;
if (match.Success){
string startTime = Helpers.ConvertTimeFormat(match.Groups["start"].Value);
string endTime = Helpers.ConvertTimeFormat(match.Groups["end"].Value);
if (timePattern.IsMatch(line)){
timeLineIndex = i;
} else if (i + 1 < lines.Length && timePattern.IsMatch(lines[i + 1].TrimEnd())){
timeLineIndex = i + 1;
} else{
i++;
continue;
}
// Join the remaining lines for dialogue, using \N for line breaks
string dialogue = string.Join("\\N", lines.Skip(2));
var match = timePattern.Match(lines[timeLineIndex].TrimEnd());
string startAss = Helpers.ConvertTimeFormat(match.Groups["start"].Value);
string endAss = Helpers.ConvertTimeFormat(match.Groups["end"].Value);
int textStart = timeLineIndex + 1;
var textLines = new List<string>();
while (textStart < lines.Length && !string.IsNullOrWhiteSpace(lines[textStart])){
textLines.Add(lines[textStart].TrimEnd());
textStart++;
}
if (textLines.Count > 0){
string dialogue = string.Join("\\N", textLines);
dialogue = Helpers.ConvertVTTStylesToASS(dialogue);
// Append dialogue to ASS
assBuilder.AppendLine($"Dialogue: 0,{startTime},{endTime},Default,,0000,0000,0000,,{dialogue}");
assBuilder.AppendLine($"Dialogue: 0,{startAss},{endAss},Default,,0000,0000,0000,,{dialogue}");
}
i = textStart + 1;
}
subsAssReqResponse.ResponseContent = assBuilder.ToString();
@ -2273,7 +2348,7 @@ public class CrunchyrollManager{
#region Fetch Playback Data
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music){
private async Task<(bool IsOk, PlaybackData pbData, string error)> FetchPlaybackData(CrAuth authEndpoint, string mediaId, string mediaGuidId, bool music, bool auioRoleDesc){
var temppbData = new PlaybackData{
Total = 0,
Data = new Dictionary<string, StreamDetails>()
@ -2281,7 +2356,7 @@ public class CrunchyrollManager{
await authEndpoint.RefreshToken(true);
var playbackEndpoint = $"{ApiUrls.Playback}/{(music ? "music/" : "")}{mediaGuidId}/{authEndpoint.AuthSettings.Endpoint}/play?queue=false";
var playbackEndpoint = $"{ApiUrls.Playback}/{(music ? "music/" : "")}{mediaGuidId}/{authEndpoint.AuthSettings.Endpoint}/play{(auioRoleDesc ? "?audioRole=description" : "")}";
var playbackRequestResponse = await SendPlaybackRequestAsync(playbackEndpoint, authEndpoint);
if (!playbackRequestResponse.IsOk){
@ -2292,7 +2367,7 @@ public class CrunchyrollManager{
temppbData = await ProcessPlaybackResponseAsync(playbackRequestResponse.ResponseContent, mediaId, mediaGuidId, authEndpoint);
} else{
Console.WriteLine("Request Stream URLs FAILED! Attempting fallback");
playbackEndpoint = $"{ApiUrls.Playback}/{(music ? "music/" : "")}{mediaGuidId}/web/firefox/play";
playbackEndpoint = $"{ApiUrls.Playback}/{(music ? "music/" : "")}{mediaGuidId}/web/firefox/play{(auioRoleDesc ? "?audioRole=description" : "")}";
playbackRequestResponse = await SendPlaybackRequestAsync(playbackEndpoint, authEndpoint);
if (!playbackRequestResponse.IsOk){

View file

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

View file

@ -35,21 +35,33 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _downloadAudio = true;
[ObservableProperty]
private bool _downloadDescriptionAudio = true;
[ObservableProperty]
private bool _downloadChapters = true;
[ObservableProperty]
private bool _addScaledBorderAndShadow;
[ObservableProperty]
private bool _fixCccSubtitles;
[ObservableProperty]
private bool _subsDownloadDuplicate;
[ObservableProperty]
private bool _includeSignSubs;
[ObservableProperty]
private bool _includeCcSubs;
[ObservableProperty]
private bool _convertVtt2Ass;
[ObservableProperty]
private bool _showVtt2AssSettings;
[ObservableProperty]
private ComboBoxItem _selectedScaledBorderAndShadow;
@ -63,13 +75,13 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private bool _muxToMp4;
[ObservableProperty]
private bool _muxToMp3;
[ObservableProperty]
private bool _muxFonts;
[ObservableProperty]
private bool _muxCover;
@ -102,7 +114,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private string _fileName = "";
[ObservableProperty]
private string _fileNameWhitespaceSubstitute = "";
@ -138,22 +150,28 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
[ObservableProperty]
private ComboBoxItem _selectedStreamEndpoint;
[ObservableProperty]
private ComboBoxItem _SelectedStreamEndpointSecondary;
[ObservableProperty]
private string _endpointAuthorization = "";
[ObservableProperty]
private string _endpointClientId = "";
[ObservableProperty]
private string _endpointUserAgent = "";
[ObservableProperty]
private string _endpointDeviceName = "";
[ObservableProperty]
private string _endpointDeviceType = "";
[ObservableProperty]
private bool _isLoggingIn;
[ObservableProperty]
private bool _endpointNotSignedWarning;
@ -187,6 +205,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public ObservableCollection<ComboBoxItem> AudioQualityList{ get; } =[
new(){ Content = "best" },
new(){ Content = "192kB/s" },
new(){ Content = "128kB/s" },
new(){ Content = "96kB/s" },
new(){ Content = "64kB/s" },
@ -240,7 +259,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
new(){ Content = "tv/vidaa" },
new(){ Content = "tv/android_tv" },
];
public ObservableCollection<ComboBoxItem> StreamEndpointsSecondary{ get; } =[
new(){ Content = "" },
// new(){ Content = "web/firefox" },
@ -331,16 +350,17 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == (options.StreamEndpointSecondSettings?.Endpoint ?? "")) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
EndpointAuthorization = options.StreamEndpointSecondSettings?.Authorization ?? string.Empty;
EndpointClientId = options.StreamEndpointSecondSettings?.Client_ID ?? string.Empty;
EndpointUserAgent = options.StreamEndpointSecondSettings?.UserAgent ?? string.Empty;
EndpointDeviceName = options.StreamEndpointSecondSettings?.Device_name ?? string.Empty;
EndpointDeviceType = options.StreamEndpointSecondSettings?.Device_type ?? string.Empty;
if (CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???"){
EndpointNotSignedWarning = true;
}
FFmpegHWAccel.AddRange(GetAvailableHWAccelOptions());
StringItemWithDisplayName? hwAccellFlag = FFmpegHWAccel.FirstOrDefault(a => a.value == options.FfmpegHwAccelFlag) ?? null;
@ -351,7 +371,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
.Where(a => options.DlSubs.Contains(a.Content))
.OrderBy(a => options.DlSubs.IndexOf(a.Content))
.ToList();
SelectedSubLang.Clear();
foreach (var listBoxItem in softSubLang){
SelectedSubLang.Add(listBoxItem);
@ -361,7 +381,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
.Where(a => options.DubLang.Contains(a.Content))
.OrderBy(a => options.DubLang.IndexOf(a.Content))
.ToList();
SelectedDubLang.Clear();
foreach (var listBoxItem in dubLang){
SelectedDubLang.Add(listBoxItem);
@ -370,6 +390,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
AddScaledBorderAndShadow = options.SubsAddScaledBorder is ScaledBorderAndShadowSelection.ScaledBorderAndShadowNo or ScaledBorderAndShadowSelection.ScaledBorderAndShadowYes;
SelectedScaledBorderAndShadow = GetScaledBorderAndShadowFromOptions(options);
FixCccSubtitles = options.FixCccSubtitles;
ConvertVtt2Ass = options.ConvertVtt2Ass;
SubsDownloadDuplicate = options.SubsDownloadDuplicate;
MarkAsWatched = options.MarkAsWatched;
DownloadFirstAvailableDub = options.DownloadFirstAvailableDub;
@ -388,6 +410,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
IncludeCcSubs = options.IncludeCcSubs;
DownloadVideo = !options.Novids;
DownloadAudio = !options.Noaudio;
DownloadDescriptionAudio = options.DownloadDescriptionAudio;
DownloadVideoForEveryDub = !options.DlVideoOnce;
KeepDubsSeparate = options.KeepDubsSeperate;
DownloadChapters = options.Chapters;
@ -428,6 +451,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
var subs = SelectedSubLang.Select(item => item.Content?.ToString());
SelectedSubs = string.Join(", ", subs) ?? "";
ShowVtt2AssSettings = IncludeCcSubs && ConvertVtt2Ass;
SelectedSubLang.CollectionChanged += Changes;
SelectedDubLang.CollectionChanged += Changes;
@ -443,6 +468,8 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
}
CrunchyrollManager.Instance.CrunOptions.SubsDownloadDuplicate = SubsDownloadDuplicate;
CrunchyrollManager.Instance.CrunOptions.ConvertVtt2Ass = ConvertVtt2Ass;
CrunchyrollManager.Instance.CrunOptions.FixCccSubtitles = FixCccSubtitles;
CrunchyrollManager.Instance.CrunOptions.MarkAsWatched = MarkAsWatched;
CrunchyrollManager.Instance.CrunOptions.DownloadFirstAvailableDub = DownloadFirstAvailableDub;
CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi = UseCrBetaApi;
@ -457,6 +484,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
CrunchyrollManager.Instance.CrunOptions.VideoTitle = FileTitle;
CrunchyrollManager.Instance.CrunOptions.Novids = !DownloadVideo;
CrunchyrollManager.Instance.CrunOptions.Noaudio = !DownloadAudio;
CrunchyrollManager.Instance.CrunOptions.DownloadDescriptionAudio = DownloadDescriptionAudio;
CrunchyrollManager.Instance.CrunOptions.DlVideoOnce = !DownloadVideoForEveryDub;
CrunchyrollManager.Instance.CrunOptions.KeepDubsSeperate = KeepDubsSeparate;
CrunchyrollManager.Instance.CrunOptions.Chapters = DownloadChapters;
@ -489,7 +517,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
string descLang = SelectedDescriptionLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.DescriptionLang = descLang != "default" ? descLang : CrunchyrollManager.Instance.DefaultLocale;
CrunchyrollManager.Instance.CrunOptions.Hslang = SelectedHSLang.Content + "";
CrunchyrollManager.Instance.CrunOptions.DefaultAudio = SelectedDefaultDubLang.Content + "";
@ -501,12 +529,15 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
var endpointSettings = new CrAuthSettings();
endpointSettings.Endpoint = SelectedStreamEndpointSecondary.Content + "";
endpointSettings.Authorization = EndpointAuthorization;
endpointSettings.Client_ID = EndpointClientId;
endpointSettings.UserAgent = EndpointUserAgent;
endpointSettings.Device_name = EndpointDeviceName;
endpointSettings.Device_type = EndpointDeviceType;
CrunchyrollManager.Instance.CrunOptions.StreamEndpointSecondSettings = endpointSettings;
CrunchyrollManager.Instance.CrAuthEndpoint2.AuthSettings = endpointSettings;
List<string> dubLangs = new List<string>();
foreach (var listBoxItem in SelectedDubLang){
@ -609,6 +640,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
}
UpdateSettings();
ShowVtt2AssSettings = IncludeCcSubs && ConvertVtt2Ass;
if (e.PropertyName is nameof(History)){
if (CrunchyrollManager.Instance.CrunOptions.History){
@ -669,13 +701,14 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
public void ResetEndpointSettings(){
ComboBoxItem? streamEndpointSecondar = StreamEndpointsSecondary.FirstOrDefault(a => a.Content != null && (string)a.Content == ("android/phone")) ?? null;
SelectedStreamEndpointSecondary = streamEndpointSecondar ?? StreamEndpointsSecondary[0];
EndpointAuthorization = "Basic YmY3MHg2aWhjYzhoZ3p3c2J2eGk6eDJjc3BQZXQzWno1d0pDdEpyVUNPSVM5Ynpad1JDcGM=";
EndpointUserAgent = "Crunchyroll/3.90.0 Android/16 okhttp/4.12.0";
EndpointDeviceName = "CPH2449";
EndpointDeviceType = "OnePlus CPH2449";
EndpointAuthorization = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Authorization;
EndpointClientId = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Client_ID;
EndpointUserAgent = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.UserAgent;
EndpointDeviceName = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_name;
EndpointDeviceType = CrunchyrollManager.Instance.DefaultAndroidAuthSettings.Device_type;
}
[RelayCommand]
public async Task Login(){
var dialog = new ContentDialog(){
@ -690,9 +723,10 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
};
_ = await dialog.ShowAsync();
IsLoggingIn = true;
await viewModel.LoginCompleted;
IsLoggingIn = false;
EndpointNotSignedWarning = CrunchyrollManager.Instance.CrAuthEndpoint2.Profile.Username == "???";
}
private List<StringItemWithDisplayName> GetAvailableHWAccelOptions(){
@ -706,7 +740,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
process.StartInfo.CreateNoWindow = true;
string output = string.Empty;
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
output += e.Data + Environment.NewLine;
@ -714,7 +748,7 @@ public partial class CrunchyrollSettingsViewModel : ViewModelBase{
};
process.Start();
process.BeginOutputReadLine();
// process.BeginErrorReadLine();

View file

@ -56,6 +56,12 @@
</controls:SettingsExpander.Footer>
<controls:SettingsExpanderItem Content="Download AD for Selected Dubs" Description="Downloads audio description tracks matching the selected dub languages (if available).">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding DownloadDescriptionAudio}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
</controls:SettingsExpander>
@ -105,6 +111,12 @@
</StackPanel>
</controls:SettingsExpander.Footer>
<controls:SettingsExpanderItem Content="Fix Ccc Subtitles" Description="Automatically adjusts subtitle styles that were created with ClosedCaptionConverter">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding FixCccSubtitles}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Download Duplicate" Description="Download subtitles from all dubs where they're available">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding SubsDownloadDuplicate}"> </CheckBox>
@ -187,7 +199,13 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding IncludeCcSubs}" Content="CC Subtitles" Description="Font">
<controls:SettingsExpanderItem IsVisible="{Binding IncludeCcSubs}" Content="Convert CC Subtitles to ASS" Description="When enabled, closed-caption WEBVTT subtitles are converted into ASS format">
<controls:SettingsExpanderItem.Footer>
<CheckBox IsChecked="{Binding ConvertVtt2Ass}"> </CheckBox>
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding ShowVtt2AssSettings}" Content="CC Subtitles" Description="Font">
<controls:SettingsExpanderItem.Footer>
<TextBox HorizontalAlignment="Left" MinWidth="250"
Text="{Binding CCSubsFont}" />
@ -254,6 +272,12 @@
Text="{Binding EndpointAuthorization}" />
</StackPanel>
<StackPanel Margin="0,5">
<TextBlock Text="Client Id" />
<TextBox Name="ClientIdTextBox" HorizontalAlignment="Left" MinWidth="250"
Text="{Binding EndpointClientId}" />
</StackPanel>
<StackPanel Margin="0,5">
<TextBlock Text="User Agent" />
<TextBox Name="UserAgentTextBox" HorizontalAlignment="Left" MinWidth="250"
@ -286,6 +310,12 @@
<TextBlock Text="Login" HorizontalAlignment="Center" TextWrapping="Wrap" FontSize="12" />
</StackPanel>
</Button>
<controls:ProgressRing Width="24" Height="24"
Margin="8,0,0,0"
IsActive="{Binding IsLoggingIn}"
IsVisible="{Binding IsLoggingIn}" />
<controls:SymbolIcon Symbol="CloudOff"
IsVisible="{Binding EndpointNotSignedWarning}"
Foreground="OrangeRed"

View file

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

View file

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

View file

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

View file

@ -51,8 +51,9 @@ public class Helpers{
clone.Headers.TryAddWithoutValidation(header.Key, header.Value);
}
foreach (var property in originalRequest.Properties){
clone.Properties.Add(property);
foreach (var kvp in originalRequest.Options){
var key = new HttpRequestOptionsKey<object?>(kvp.Key);
clone.Options.Set(key, kvp.Value);
}
return clone;
@ -71,15 +72,31 @@ public class Helpers{
return JsonConvert.DeserializeObject<T>(json);
}
public static int ToKbps(int bps) => (int)Math.Round(bps / 1000.0);
public static int SnapToAudioBucket(int kbps){
int[] buckets ={ 64, 96, 128, 192,256 };
return buckets.OrderBy(b => Math.Abs(b - kbps)).First();
}
public static int WidthBucket(int width, int height){
int expected = (int)Math.Round(height * 16 / 9.0);
int tol = Math.Max(8, (int)(expected * 0.02)); // ~2% or ≥8 px
return Math.Abs(width - expected) <= tol ? expected : width;
}
public static string ConvertTimeFormat(string time){
var timeParts = time.Split(':', '.');
int hours = int.Parse(timeParts[0]);
int minutes = int.Parse(timeParts[1]);
int seconds = int.Parse(timeParts[2]);
int milliseconds = int.Parse(timeParts[3]);
public static string ConvertTimeFormat(string vttTime){
if (TimeSpan.TryParseExact(vttTime, @"hh\:mm\:ss\.fff", null, out var ts) ||
TimeSpan.TryParseExact(vttTime, @"mm\:ss\.fff", null, out ts)){
var totalCentiseconds = (int)Math.Round(ts.TotalMilliseconds / 10.0, MidpointRounding.AwayFromZero);
var hours = totalCentiseconds / 360000; // 100 cs * 60 * 60
var rem = totalCentiseconds % 360000;
var mins = rem / 6000;
rem %= 6000;
var secs = rem / 100;
var cs = rem % 100;
return $"{hours}:{mins:00}:{secs:00}.{cs:00}";
}
return $"{hours}:{minutes:D2}:{seconds:D2}.{milliseconds / 10:D2}";
return "0:00:00.00";
}
public static string ConvertVTTStylesToASS(string dialogue){
@ -391,6 +408,7 @@ public class Helpers{
process.StartInfo.RedirectStandardError = true;
process.StartInfo.UseShellExecute = false;
process.StartInfo.CreateNoWindow = true;
process.EnableRaisingEvents = true;
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
@ -411,7 +429,28 @@ public class Helpers{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
await process.WaitForExitAsync();
using var reg = data?.Cts.Token.Register(() => {
try{
if (!process.HasExited)
process.Kill(true);
} catch{
// ignored
}
});
try{
await process.WaitForExitAsync(data.Cts.Token);
} catch (OperationCanceledException){
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
// ignored
}
}
return (IsOk: false, ErrorCode: -2);
}
bool isSuccess = process.ExitCode == 0;
@ -423,7 +462,14 @@ public class Helpers{
File.Move(tempOutputFilePath, inputFilePath);
} else{
// If something went wrong, delete the temporary output file
File.Delete(tempOutputFilePath);
if (File.Exists(tempOutputFilePath)){
try{
File.Delete(tempOutputFilePath);
} catch{
/* ignore */
}
}
Console.Error.WriteLine("FFmpeg processing failed.");
Console.Error.WriteLine($"Command: {ffmpegCommand}");
}
@ -572,6 +618,12 @@ public class Helpers{
string cNumber = match.Groups[2].Value; // Extract the C number if present
string pNumber = match.Groups[3].Value; // Extract the P number if present
if (int.TryParse(sNumber, out int sNumericBig)){
// Reject invalid S numbers (>= 1000)
if (sNumericBig >= 1000)
return null;
}
if (!string.IsNullOrEmpty(cNumber)){
// Case for C: Return S + . + C
return $"{sNumber}.{cNumber}";
@ -645,10 +697,10 @@ public class Helpers{
group.Add(descriptionMedia[0]);
}
}
//Find and add Cover media to each group
var coverMedia = allMedia.Where(media => media.Type == DownloadMediaType.Cover).ToList();
if (coverMedia.Count > 0){
foreach (var group in languageGroups.Values){
group.Add(coverMedia[0]);
@ -860,7 +912,7 @@ public class Helpers{
} else{
throw new PlatformNotSupportedException();
}
try{
using (var process = new Process()){
process.StartInfo.FileName = shutdownCmd;
@ -875,13 +927,13 @@ public class Helpers{
Console.Error.WriteLine($"{e.Data}");
}
};
process.OutputDataReceived += (sender, e) => {
if (!string.IsNullOrEmpty(e.Data)){
Console.Error.WriteLine(e.Data);
Console.Error.WriteLine(e.Data);
}
};
process.Start();
process.BeginOutputReadLine();
@ -892,11 +944,9 @@ public class Helpers{
if (process.ExitCode != 0){
Console.Error.WriteLine($"Shutdown failed with exit code {process.ExitCode}");
}
}
} catch (Exception ex){
Console.Error.WriteLine($"Failed to start shutdown process: {ex.Message}");
}
}
}

View file

@ -33,9 +33,9 @@ public class HttpClientReq{
}
#endregion
private HttpClient client;
public HttpClientReq(){
IWebProxy systemProxy = WebRequest.DefaultWebProxy;
@ -137,6 +137,8 @@ public class HttpClientReq{
response.EnsureSuccessStatusCode();
CaptureResponseCookies(response, request.RequestUri!, cookieStore);
return (IsOk: true, ResponseContent: content, error: "");
} catch (Exception e){
// Console.Error.WriteLine($"Error: {e} \n Response: {(content.Length < 500 ? content : "error to long")}");
@ -148,6 +150,30 @@ public class HttpClientReq{
}
}
private void CaptureResponseCookies(HttpResponseMessage response, Uri requestUri, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return;
}
if (response.Headers.TryGetValues("Set-Cookie", out var cookieHeaders)){
string domain = requestUri.Host.StartsWith("www.") ? requestUri.Host.Substring(4) : requestUri.Host;
foreach (var header in cookieHeaders){
var cookies = header.Split(';', StringSplitOptions.RemoveEmptyEntries);
var nameValue = cookies[0].Split('=', 2);
if (nameValue.Length != 2) continue;
var cookie = new Cookie(nameValue[0].Trim(), nameValue[1].Trim()){
Domain = domain,
Path = "/"
};
AddCookie(domain, cookie, cookieStore);
}
}
}
private void AttachCookies(HttpRequestMessage request, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return;
@ -177,6 +203,19 @@ public class HttpClientReq{
}
}
public string? GetCookieValue(string domain, string cookieName, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return null;
}
if (cookieStore.TryGetValue(domain, out var cookies)){
var cookie = cookies.Cast<Cookie>().FirstOrDefault(c => c.Name == cookieName);
return cookie?.Value;
}
return null;
}
public void AddCookie(string domain, Cookie cookie, Dictionary<string, CookieCollection>? cookieStore){
if (cookieStore == null){
return;
@ -237,7 +276,7 @@ public static class ApiUrls{
public static string Cms => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2/cms";
public static string Content => (CrunchyrollManager.Instance.CrunOptions.UseCrBetaApi ? ApiBeta : ApiN) + "/content/v2";
public static string Playback => "https://cr-play-service.prd.crunchyrollsvc.com/v2";
public static string Playback => "https://cr-play-service.prd.crunchyrollsvc.com/v3";
//https://www.crunchyroll.com/playback/v2
//https://cr-play-service.prd.crunchyrollsvc.com/v2

View file

@ -167,19 +167,31 @@ public class Merger{
}
}
// var sortedAudio = options.OnlyAudio
// .OrderBy(sub => options.DubLangList.IndexOf(sub.Language.CrLocale) != -1 ? options.DubLangList.IndexOf(sub.Language.CrLocale) : int.MaxValue)
// .ToList();
var rank = options.DubLangList
.Select((val, i) => new{ val, i })
.ToDictionary(x => x.val, x => x.i, StringComparer.OrdinalIgnoreCase);
var sortedAudio = options.OnlyAudio
.OrderBy(sub => options.DubLangList.IndexOf(sub.Language.CrLocale) != -1 ? options.DubLangList.IndexOf(sub.Language.CrLocale) : int.MaxValue)
.OrderBy(m => {
var key = m.Language?.CrLocale ?? string.Empty;
return rank.TryGetValue(key, out var r) ? r : int.MaxValue; // unknown locales last
})
.ThenBy(m => m.IsAudioRoleDescription) // false first, then true
.ToList();
foreach (var aud in sortedAudio){
string trackName = aud.Language.Name;
string trackName = aud.Language.Name + (aud.IsAudioRoleDescription ? " [AD]" : "");
args.Add("--audio-tracks 0");
args.Add("--no-video");
args.Add($"--track-name 0:\"{trackName}\"");
args.Add($"--language 0:{aud.Language.Code}");
if (options.Defaults.Audio.Code == aud.Language.Code){
if (options.Defaults.Audio.Code == aud.Language.Code && !aud.IsAudioRoleDescription){
args.Add("--default-track 0");
} else{
args.Add("--default-track 0:0");
@ -450,7 +462,7 @@ public class MergerInput{
public LanguageItem Language{ get; set; }
public int? Duration{ get; set; }
public int? Delay{ get; set; }
public bool? IsPrimary{ get; set; }
public bool IsAudioRoleDescription{ get; set; }
public int? Bitrate{ get; set; }
}

View file

@ -51,6 +51,7 @@ public class VideoItem: VideoPlaylist{
public class AudioItem: AudioPlaylist{
public string resolutionText{ get; set; }
public string resolutionTextSnap{ get; set; }
}
public class Quality{

View file

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

View file

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

View file

@ -10,7 +10,7 @@ public class CrDownloadOptions{
[JsonProperty("shutdown_when_queue_empty")]
public bool ShutdownWhenQueueEmpty{ get; set; }
[JsonProperty("auto_download")]
public bool AutoDownload{ get; set; }
@ -22,22 +22,25 @@ public class CrDownloadOptions{
[JsonProperty("retry_delay")]
public int RetryDelay{ get; set; }
[JsonProperty("retry_attempts")]
public int RetryAttempts{ get; set; }
[JsonIgnore]
public string Force{ get; set; } = "";
[JsonProperty("download_methode_new")]
public bool DownloadMethodeNew{ get; set; }
[JsonProperty("download_allow_early_start")]
public bool DownloadAllowEarlyStart{ get; set; }
[JsonProperty("simultaneous_downloads")]
public int SimultaneousDownloads{ get; set; }
[JsonProperty("simultaneous_processing_jobs")]
public int SimultaneousProcessingJobs{ get; set; }
[JsonProperty("theme")]
public string Theme{ get; set; } = "";
@ -49,11 +52,11 @@ public class CrDownloadOptions{
[JsonProperty("download_finished_play_sound")]
public bool DownloadFinishedPlaySound{ get; set; }
[JsonProperty("download_finished_sound_path")]
public string? DownloadFinishedSoundPath{ get; set; }
[JsonProperty("background_image_opacity")]
public double BackgroundImageOpacity{ get; set; }
@ -71,9 +74,9 @@ public class CrDownloadOptions{
[JsonProperty("history")]
public bool History{ get; set; }
[JsonProperty("history_count_missing")]
public bool HistoryCountMissing { get; set; }
public bool HistoryCountMissing{ get; set; }
[JsonProperty("history_include_cr_artists")]
public bool HistoryIncludeCrArtists{ get; set; }
@ -136,6 +139,9 @@ public class CrDownloadOptions{
#region Crunchyroll Settings
[JsonProperty("cr_download_description_audio")]
public bool DownloadDescriptionAudio{ get; set; }
[JsonProperty("cr_mark_as_watched")]
public bool MarkAsWatched{ get; set; }
@ -165,7 +171,7 @@ public class CrDownloadOptions{
[JsonProperty("file_name_whitespace_substitute")]
public string FileNameWhitespaceSubstitute{ get; set; } = "";
[JsonProperty("file_name")]
public string FileName{ get; set; } = "";
@ -181,6 +187,9 @@ public class CrDownloadOptions{
[JsonIgnore]
public bool SkipSubs{ get; set; }
[JsonProperty("subs_fix_ccc_subs")]
public bool FixCccSubtitles{ get; set; }
[JsonProperty("mux_skip_subs")]
public bool SkipSubsMux{ get; set; }
@ -189,7 +198,7 @@ public class CrDownloadOptions{
[JsonProperty("subs_download_duplicate")]
public bool SubsDownloadDuplicate{ get; set; }
[JsonProperty("include_signs_subs")]
public bool IncludeSignsSubs{ get; set; }
@ -199,6 +208,9 @@ public class CrDownloadOptions{
[JsonProperty("include_cc_subs")]
public bool IncludeCcSubs{ get; set; }
[JsonProperty("convert_cc_vtt_subs_to_ass")]
public bool ConvertVtt2Ass{ get; set; }
[JsonProperty("cc_subs_font")]
public string? CcSubsFont{ get; set; }
@ -207,13 +219,13 @@ public class CrDownloadOptions{
[JsonProperty("mux_mp4")]
public bool Mp4{ get; set; }
[JsonProperty("mux_audio_only_to_mp3")]
public bool AudioOnlyToMp3 { get; set; }
public bool AudioOnlyToMp3{ get; set; }
[JsonProperty("mux_fonts")]
public bool MuxFonts{ get; set; }
[JsonProperty("mux_cover")]
public bool MuxCover{ get; set; }
@ -258,7 +270,7 @@ public class CrDownloadOptions{
[JsonProperty("mux_sync_dubs")]
public bool SyncTiming{ get; set; }
[JsonProperty("mux_sync_hwaccel")]
public string? FfmpegHwAccelFlag{ get; set; }
@ -294,9 +306,9 @@ public class CrDownloadOptions{
[JsonProperty("stream_endpoint")]
public string? StreamEndpoint{ get; set; }
[JsonProperty("stream_endpoint_secondary_settings")]
public CrAuthSettings? StreamEndpointSecondSettings { get; set; }
public CrAuthSettings? StreamEndpointSecondSettings{ get; set; }
[JsonProperty("search_fetch_featured_music")]
public bool SearchFetchFeaturedMusic{ get; set; }

View file

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

View file

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

View file

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

View file

@ -14,10 +14,12 @@ public class AuthData{
public class CrAuthSettings{
public string Endpoint{ get; set; }
public string Client_ID{ get; set; }
public string Authorization{ get; set; }
public string UserAgent{ get; set; }
public string Device_type{ get; set; }
public string Device_name{ get; set; }
}
public class DrmAuthData{

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -54,9 +54,12 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
[ObservableProperty]
private double? _simultaneousDownloads;
[ObservableProperty]
private double? _simultaneousProcessingJobs;
[ObservableProperty]
private bool _downloadMethodeNew;
[ObservableProperty]
private bool _downloadAllowEarlyStart;
@ -280,6 +283,7 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
RetryDelay = Math.Clamp((options.RetryDelay), 1, 30);
DownloadToTempFolder = options.DownloadToTempFolder;
SimultaneousDownloads = options.SimultaneousDownloads;
SimultaneousProcessingJobs = options.SimultaneousProcessingJobs;
LogMode = options.LogMode;
ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null;
@ -320,6 +324,9 @@ public partial class GeneralSettingsViewModel : ViewModelBase{
settings.HistoryCountSonarr = HistoryCountSonarr;
settings.DownloadSpeedLimit = Math.Clamp((int)(DownloadSpeed ?? 0), 0, 1000000000);
settings.SimultaneousDownloads = Math.Clamp((int)(SimultaneousDownloads ?? 0), 1, 10);
settings.SimultaneousProcessingJobs = Math.Clamp((int)(SimultaneousProcessingJobs ?? 0), 1, 10);
QueueManager.Instance.SetLimit(settings.SimultaneousProcessingJobs);
settings.ProxyEnabled = ProxyEnabled;
settings.ProxySocks = ProxySocks;

View file

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

View file

@ -178,7 +178,7 @@
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Simultaneous Downloads">
<controls:SettingsExpanderItem Content="Parallel Downloads">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0" Maximum="10"
Value="{Binding SimultaneousDownloads}"
@ -186,6 +186,15 @@
HorizontalAlignment="Stretch" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem IsVisible="{Binding DownloadAllowEarlyStart}" Content="Parallel Processing Jobs" Description="The maximum number of completed downloads that can be processed simultaneously (encoding, muxing, moving)">
<controls:SettingsExpanderItem.Footer>
<controls:NumberBox Minimum="0" Maximum="10"
Value="{Binding SimultaneousProcessingJobs}"
SpinButtonPlacementMode="Inline"
HorizontalAlignment="Stretch" />
</controls:SettingsExpanderItem.Footer>
</controls:SettingsExpanderItem>
<controls:SettingsExpanderItem Content="Play completion sound" Description="Enables a notification sound to be played when all downloads have finished">