diff --git a/CRD/Downloader/CalendarManager.cs b/CRD/Downloader/CalendarManager.cs index 6c1dc15..07dde67 100644 --- a/CRD/Downloader/CalendarManager.cs +++ b/CRD/Downloader/CalendarManager.cs @@ -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) diff --git a/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs b/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs index 5c96626..3dad234 100644 --- a/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs +++ b/CRD/Downloader/Crunchyroll/CrunchyrollManager.cs @@ -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(){ "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(); - 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(); + 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 subtitles){ + private async Task MoveFromTempFolder(Merger? merger, CrunchyEpMeta data, CrDownloadOptions options, string tempFolderPath, List 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( + data.Data.Where(x => x.IsAudioRoleDescription).Select(x => x.Lang?.CrLocale ?? "err") + ); + + bool HasDescriptionRole(IEnumerable? 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(@"(?\d{2}:\d{2}:\d{2}\.\d{3})\s-->\s(?\d{2}:\d{2}:\d{2}\.\d{3})"); + var timePattern = new Regex( + @"^(?(?:\d{2}:)?\d{2}:\d{2}\.\d{3})\s*-->\s*(?(?:\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(); + 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() @@ -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){ diff --git a/CRD/Downloader/Crunchyroll/Utils/SubtitleUtils.cs b/CRD/Downloader/Crunchyroll/Utils/SubtitleUtils.cs new file mode 100644 index 0000000..75d5704 --- /dev/null +++ b/CRD/Downloader/Crunchyroll/Utils/SubtitleUtils.cs @@ -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 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(); + + 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 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"); + } +} \ No newline at end of file diff --git a/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs b/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs index adef593..1260a66 100644 --- a/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs +++ b/CRD/Downloader/Crunchyroll/ViewModels/CrunchyrollSettingsViewModel.cs @@ -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 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 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 dubLangs = new List(); 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 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(); diff --git a/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml b/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml index e5ab5d2..e7545e2 100644 --- a/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml +++ b/CRD/Downloader/Crunchyroll/Views/CrunchyrollSettingsView.axaml @@ -56,6 +56,12 @@ + + + + + + @@ -105,6 +111,12 @@ + + + + + + @@ -187,7 +199,13 @@ - + + + + + + + @@ -254,6 +272,12 @@ Text="{Binding EndpointAuthorization}" /> + + + + + + + + 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; + } + } } \ No newline at end of file diff --git a/CRD/Utils/Enums/EnumCollection.cs b/CRD/Utils/Enums/EnumCollection.cs index 62432b0..0d1301c 100644 --- a/CRD/Utils/Enums/EnumCollection.cs +++ b/CRD/Utils/Enums/EnumCollection.cs @@ -173,6 +173,9 @@ public enum DownloadMediaType{ [EnumMember(Value = "Audio")] Audio, + + [EnumMember(Value = "AudioRoleDescription")] + AudioRoleDescription, [EnumMember(Value = "Chapters")] Chapters, diff --git a/CRD/Utils/Files/CfgManager.cs b/CRD/Utils/Files/CfgManager.cs index 22fca1d..076ad6e 100644 --- a/CRD/Utils/Files/CfgManager.cs +++ b/CRD/Utils/Files/CfgManager.cs @@ -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 _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: ..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 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(string filePath){ - // var deserializer = new DeserializerBuilder() - // .Build(); - // - // using (var reader = new StreamReader(filePath)){ - // return deserializer.Deserialize(reader); - // } - // } public static T? ReadJsonFromFile(string pathToFile) where T : class{ try{ diff --git a/CRD/Utils/Helpers.cs b/CRD/Utils/Helpers.cs index 34e6733..9d3dff8 100644 --- a/CRD/Utils/Helpers.cs +++ b/CRD/Utils/Helpers.cs @@ -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(kvp.Key); + clone.Options.Set(key, kvp.Value); } return clone; @@ -71,15 +72,31 @@ public class Helpers{ return JsonConvert.DeserializeObject(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}"); } - } } \ No newline at end of file diff --git a/CRD/Utils/Http/HttpClientReq.cs b/CRD/Utils/Http/HttpClientReq.cs index 3d58557..e3119c7 100644 --- a/CRD/Utils/Http/HttpClientReq.cs +++ b/CRD/Utils/Http/HttpClientReq.cs @@ -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? 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? cookieStore){ if (cookieStore == null){ return; @@ -177,6 +203,19 @@ public class HttpClientReq{ } } + public string? GetCookieValue(string domain, string cookieName, Dictionary? cookieStore){ + if (cookieStore == null){ + return null; + } + + if (cookieStore.TryGetValue(domain, out var cookies)){ + var cookie = cookies.Cast().FirstOrDefault(c => c.Name == cookieName); + return cookie?.Value; + } + + return null; + } + public void AddCookie(string domain, Cookie cookie, Dictionary? 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 diff --git a/CRD/Utils/Muxing/Merger.cs b/CRD/Utils/Muxing/Merger.cs index 5efd26f..48becca 100644 --- a/CRD/Utils/Muxing/Merger.cs +++ b/CRD/Utils/Muxing/Merger.cs @@ -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; } } diff --git a/CRD/Utils/Parser/MPDTransformer.cs b/CRD/Utils/Parser/MPDTransformer.cs index f5f17ea..0fb0dbe 100644 --- a/CRD/Utils/Parser/MPDTransformer.cs +++ b/CRD/Utils/Parser/MPDTransformer.cs @@ -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{ diff --git a/CRD/Utils/Structs/AnilistUpcoming.cs b/CRD/Utils/Structs/AnilistUpcoming.cs index b766720..a65c7f0 100644 --- a/CRD/Utils/Structs/AnilistUpcoming.cs +++ b/CRD/Utils/Structs/AnilistUpcoming.cs @@ -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? ExternalLinks{ get; set; } public List 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; diff --git a/CRD/Utils/Structs/CalendarStructs.cs b/CRD/Utils/Structs/CalendarStructs.cs index 3ce2995..9653b4a 100644 --- a/CRD/Utils/Structs/CalendarStructs.cs +++ b/CRD/Utils/Structs/CalendarStructs.cs @@ -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 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){ diff --git a/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs b/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs index 7285306..e54e416 100644 --- a/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs +++ b/CRD/Utils/Structs/Crunchyroll/CrDownloadOptions.cs @@ -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; } diff --git a/CRD/Utils/Structs/Crunchyroll/CrunchyStreamData.cs b/CRD/Utils/Structs/Crunchyroll/CrunchyStreamData.cs index 7f6c14d..927fca3 100644 --- a/CRD/Utils/Structs/Crunchyroll/CrunchyStreamData.cs +++ b/CRD/Utils/Structs/Crunchyroll/CrunchyStreamData.cs @@ -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? Captions{ get; set; } diff --git a/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs b/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs index 7de04c1..11f835e 100644 --- a/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs +++ b/CRD/Utils/Structs/Crunchyroll/Episode/EpisodeStructs.cs @@ -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)){ diff --git a/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs b/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs index 18e3e8a..5f7c3ab 100644 --- a/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs +++ b/CRD/Utils/Structs/Crunchyroll/StreamLimits.cs @@ -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"; } } diff --git a/CRD/Utils/Structs/HelperClasses.cs b/CRD/Utils/Structs/HelperClasses.cs index 6c2cfd8..25967d6 100644 --- a/CRD/Utils/Structs/HelperClasses.cs +++ b/CRD/Utils/Structs/HelperClasses.cs @@ -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{ diff --git a/CRD/Utils/Structs/History/HistoryEpisode.cs b/CRD/Utils/Structs/History/HistoryEpisode.cs index e955d0e..c9d0dc9 100644 --- a/CRD/Utils/Structs/History/HistoryEpisode.cs +++ b/CRD/Utils/Structs/History/HistoryEpisode.cs @@ -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: diff --git a/CRD/ViewModels/DownloadsPageViewModel.cs b/CRD/ViewModels/DownloadsPageViewModel.cs index a369a26..920ef2d 100644 --- a/CRD/ViewModels/DownloadsPageViewModel.cs +++ b/CRD/ViewModels/DownloadsPageViewModel.cs @@ -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{ diff --git a/CRD/ViewModels/SeriesPageViewModel.cs b/CRD/ViewModels/SeriesPageViewModel.cs index 632fabd..359f979 100644 --- a/CRD/ViewModels/SeriesPageViewModel.cs +++ b/CRD/ViewModels/SeriesPageViewModel.cs @@ -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 }; diff --git a/CRD/ViewModels/UpcomingSeasonsPageViewModel.cs b/CRD/ViewModels/UpcomingSeasonsPageViewModel.cs index f5cf727..93cae4c 100644 --- a/CRD/ViewModels/UpcomingSeasonsPageViewModel.cs +++ b/CRD/ViewModels/UpcomingSeasonsPageViewModel.cs @@ -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 Seasons{ get; set; } =[]; public ObservableCollection 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> GetSeriesForSeason(string season, int year, bool forceRefresh){ + private async Task> 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(); + 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( + 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(); + allMedia.AddRange(media); - AniListResponse aniListResponse = Helpers.Deserialize(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("", "") .Replace("", "") + .Replace("", "") + .Replace("", "") .Replace("
", "") .Replace("
", ""); @@ -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()) + .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(); + 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(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 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 } \ No newline at end of file diff --git a/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs b/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs index b9fcecc..22f7bf5 100644 --- a/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs +++ b/CRD/ViewModels/Utils/ContentDialogFeaturedMusicViewModel.cs @@ -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){ diff --git a/CRD/ViewModels/Utils/ContentDialogInputLoginViewModel.cs b/CRD/ViewModels/Utils/ContentDialogInputLoginViewModel.cs index 9c7a67f..6ac4ee9 100644 --- a/CRD/ViewModels/Utils/ContentDialogInputLoginViewModel.cs +++ b/CRD/ViewModels/Utils/ContentDialogInputLoginViewModel.cs @@ -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 _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){ diff --git a/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs b/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs index d6fad20..f41cdda 100644 --- a/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs +++ b/CRD/ViewModels/Utils/GeneralSettingsViewModel.cs @@ -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; diff --git a/CRD/Views/UpcomingSeasonsPageView.axaml b/CRD/Views/UpcomingSeasonsPageView.axaml index 0b8aa97..24fc9ca 100644 --- a/CRD/Views/UpcomingSeasonsPageView.axaml +++ b/CRD/Views/UpcomingSeasonsPageView.axaml @@ -59,6 +59,19 @@ + + + + + + + + + + - + @@ -268,7 +281,7 @@ - diff --git a/CRD/Views/Utils/GeneralSettingsView.axaml b/CRD/Views/Utils/GeneralSettingsView.axaml index 7d31275..f50f3e5 100644 --- a/CRD/Views/Utils/GeneralSettingsView.axaml +++ b/CRD/Views/Utils/GeneralSettingsView.axaml @@ -178,7 +178,7 @@
- + + + + + + +