From 1cc14103dfbbe1a850d8d03436dd8d38cc8c2278 Mon Sep 17 00:00:00 2001 From: Elwador <75888166+Elwador@users.noreply.github.com> Date: Sat, 4 May 2024 17:35:32 +0200 Subject: [PATCH] v1.4.1 --- App.axaml | 20 + App.axaml.cs | 23 + Assets/Icons.axaml | 11 + Assets/app_icon.ico | Bin 0 -> 161024 bytes Downloader/CRAuth.cs | 223 ++ Downloader/CrEpisode.cs | 249 ++ Downloader/CrSeries.cs | 406 +++ Downloader/Crunchyroll.cs | 1617 ++++++++++++ Downloader/History.cs | 411 +++ Program.cs | 20 + Styling/ControlsGalleryStyles.axaml | 106 + .../RefreshableObservableCollection.cs | 10 + Utils/DRM/ContentKey.cs | 29 + Utils/DRM/CryptoUtils.cs | 29 + Utils/DRM/PSSHbox.cs | 58 + Utils/DRM/Protocol.cs | 128 + Utils/DRM/Session.cs | 332 +++ Utils/DRM/Widevine.cs | 110 + Utils/DRM/WvProto2.cs | 2259 +++++++++++++++++ Utils/Enums/EnumCollection.cs | 82 + Utils/Files/CfgManager.cs | 182 ++ Utils/Files/FileNameManager.cs | 105 + Utils/HLS/HLSDownloader.cs | 585 +++++ Utils/Helpers.cs | 80 + Utils/Http/HttpClientReq.cs | 147 ++ Utils/JsonConv/LocaleConverter.cs | 38 + Utils/Muxing/FontsManager.cs | 143 ++ Utils/Muxing/Merger.cs | 404 +++ Utils/Parser/DashParser.cs | 58 + Utils/Parser/M3u8/ToM3u8Class.cs | 495 ++++ Utils/Parser/MPDTransformer.cs | 168 ++ Utils/Parser/Playlists/Errors.cs | 13 + Utils/Parser/Playlists/InheritAttributes.cs | 460 ++++ Utils/Parser/Playlists/ParseAttribute.cs | 321 +++ Utils/Parser/Playlists/PlaylistMerge.cs | 131 + Utils/Parser/Playlists/ToPlaylistsClass.cs | 65 + Utils/Parser/Segments/DurationTimeParser.cs | 89 + Utils/Parser/Segments/SegmentBase.cs | 111 + Utils/Parser/Segments/SegmentList.cs | 57 + Utils/Parser/Segments/SegmentTemplate.cs | 107 + Utils/Parser/Segments/TimelineTimeParser.cs | 65 + Utils/Parser/Segments/UrlType.cs | 34 + Utils/Parser/Utils/DivisionValueParser.cs | 13 + Utils/Parser/Utils/DurationParser.cs | 66 + Utils/Parser/Utils/ManifestInfo.cs | 10 + Utils/Parser/Utils/ObjectUtilities.cs | 110 + Utils/Parser/Utils/UrlResolver.cs | 8 + Utils/Parser/Utils/UrlUtils.cs | 23 + Utils/Parser/Utils/XMLUtils.cs | 29 + Utils/Structs/CalendarStructs.cs | 70 + Utils/Structs/Chapters.cs | 33 + Utils/Structs/CrCmsToken.cs | 24 + Utils/Structs/CrDownloadOptions.cs | 123 + Utils/Structs/CrProfile.cs | 15 + Utils/Structs/CrSeriesBase.cs | 96 + Utils/Structs/CrSeriesSearch.cs | 59 + Utils/Structs/CrToken.cs | 15 + Utils/Structs/CrunchyNoDRMStream.cs | 46 + Utils/Structs/EpisodeStructs.cs | 211 ++ Utils/Structs/Languages.cs | 142 ++ Utils/Structs/Playback.cs | 57 + Utils/Structs/PlaybackDataAndroid.cs | 19 + Utils/Structs/Structs.cs | 84 + Utils/Structs/Variable.cs | 18 + Utils/UI/UiIntToVisibilityConverter.cs | 21 + Utils/UI/UiSeasonValueConverter.cs | 23 + Utils/UI/UiValueConverter.cs | 24 + ViewLocator.cs | 28 + ViewModels/AccountPageViewModel.cs | 66 + ViewModels/AddDownloadPageViewModel.cs | 255 ++ ViewModels/CalendarPageViewModel.cs | 141 + .../ContentDialogInputLoginViewModel.cs | 41 + ViewModels/DownloadsPageViewModel.cs | 227 ++ ViewModels/HistoryPageViewModel.cs | 54 + ViewModels/MainWindowViewModel.cs | 38 + ViewModels/SeriesPageViewModel.cs | 38 + ViewModels/SettingsPageViewModel.cs | 383 +++ ViewModels/ViewModelBase.cs | 12 + Views/AccountPageView.axaml | 32 + Views/AccountPageView.axaml.cs | 11 + Views/AddDownloadPageView.axaml | 108 + Views/AddDownloadPageView.axaml.cs | 11 + Views/CalendarPageView.axaml | 143 ++ Views/CalendarPageView.axaml.cs | 12 + Views/ContentDialogInputLoginView.axaml | 16 + Views/ContentDialogInputLoginView.axaml.cs | 11 + Views/DownloadsPageView.axaml | 118 + Views/DownloadsPageView.axaml.cs | 24 + Views/HistoryPageView.axaml | 63 + Views/HistoryPageView.axaml.cs | 11 + Views/MainWindow.axaml | 83 + Views/MainWindow.axaml.cs | 117 + Views/SeriesPageView.axaml | 139 + Views/SeriesPageView.axaml.cs | 11 + Views/SettingsPageView.axaml | 399 +++ Views/SettingsPageView.axaml.cs | 12 + Views/ToastNotification.axaml | 32 + Views/ToastNotification.axaml.cs | 51 + Views/Utils/ErrorWindow.axaml | 22 + Views/Utils/ErrorWindow.axaml.cs | 23 + c-sharp-language.cs | 3 - 101 files changed, 14052 insertions(+), 3 deletions(-) create mode 100644 App.axaml create mode 100644 App.axaml.cs create mode 100644 Assets/Icons.axaml create mode 100644 Assets/app_icon.ico create mode 100644 Downloader/CRAuth.cs create mode 100644 Downloader/CrEpisode.cs create mode 100644 Downloader/CrSeries.cs create mode 100644 Downloader/Crunchyroll.cs create mode 100644 Downloader/History.cs create mode 100644 Program.cs create mode 100644 Styling/ControlsGalleryStyles.axaml create mode 100644 Utils/CustomList/RefreshableObservableCollection.cs create mode 100644 Utils/DRM/ContentKey.cs create mode 100644 Utils/DRM/CryptoUtils.cs create mode 100644 Utils/DRM/PSSHbox.cs create mode 100644 Utils/DRM/Protocol.cs create mode 100644 Utils/DRM/Session.cs create mode 100644 Utils/DRM/Widevine.cs create mode 100644 Utils/DRM/WvProto2.cs create mode 100644 Utils/Enums/EnumCollection.cs create mode 100644 Utils/Files/CfgManager.cs create mode 100644 Utils/Files/FileNameManager.cs create mode 100644 Utils/HLS/HLSDownloader.cs create mode 100644 Utils/Helpers.cs create mode 100644 Utils/Http/HttpClientReq.cs create mode 100644 Utils/JsonConv/LocaleConverter.cs create mode 100644 Utils/Muxing/FontsManager.cs create mode 100644 Utils/Muxing/Merger.cs create mode 100644 Utils/Parser/DashParser.cs create mode 100644 Utils/Parser/M3u8/ToM3u8Class.cs create mode 100644 Utils/Parser/MPDTransformer.cs create mode 100644 Utils/Parser/Playlists/Errors.cs create mode 100644 Utils/Parser/Playlists/InheritAttributes.cs create mode 100644 Utils/Parser/Playlists/ParseAttribute.cs create mode 100644 Utils/Parser/Playlists/PlaylistMerge.cs create mode 100644 Utils/Parser/Playlists/ToPlaylistsClass.cs create mode 100644 Utils/Parser/Segments/DurationTimeParser.cs create mode 100644 Utils/Parser/Segments/SegmentBase.cs create mode 100644 Utils/Parser/Segments/SegmentList.cs create mode 100644 Utils/Parser/Segments/SegmentTemplate.cs create mode 100644 Utils/Parser/Segments/TimelineTimeParser.cs create mode 100644 Utils/Parser/Segments/UrlType.cs create mode 100644 Utils/Parser/Utils/DivisionValueParser.cs create mode 100644 Utils/Parser/Utils/DurationParser.cs create mode 100644 Utils/Parser/Utils/ManifestInfo.cs create mode 100644 Utils/Parser/Utils/ObjectUtilities.cs create mode 100644 Utils/Parser/Utils/UrlResolver.cs create mode 100644 Utils/Parser/Utils/UrlUtils.cs create mode 100644 Utils/Parser/Utils/XMLUtils.cs create mode 100644 Utils/Structs/CalendarStructs.cs create mode 100644 Utils/Structs/Chapters.cs create mode 100644 Utils/Structs/CrCmsToken.cs create mode 100644 Utils/Structs/CrDownloadOptions.cs create mode 100644 Utils/Structs/CrProfile.cs create mode 100644 Utils/Structs/CrSeriesBase.cs create mode 100644 Utils/Structs/CrSeriesSearch.cs create mode 100644 Utils/Structs/CrToken.cs create mode 100644 Utils/Structs/CrunchyNoDRMStream.cs create mode 100644 Utils/Structs/EpisodeStructs.cs create mode 100644 Utils/Structs/Languages.cs create mode 100644 Utils/Structs/Playback.cs create mode 100644 Utils/Structs/PlaybackDataAndroid.cs create mode 100644 Utils/Structs/Structs.cs create mode 100644 Utils/Structs/Variable.cs create mode 100644 Utils/UI/UiIntToVisibilityConverter.cs create mode 100644 Utils/UI/UiSeasonValueConverter.cs create mode 100644 Utils/UI/UiValueConverter.cs create mode 100644 ViewLocator.cs create mode 100644 ViewModels/AccountPageViewModel.cs create mode 100644 ViewModels/AddDownloadPageViewModel.cs create mode 100644 ViewModels/CalendarPageViewModel.cs create mode 100644 ViewModels/ContentDialogInputLoginViewModel.cs create mode 100644 ViewModels/DownloadsPageViewModel.cs create mode 100644 ViewModels/HistoryPageViewModel.cs create mode 100644 ViewModels/MainWindowViewModel.cs create mode 100644 ViewModels/SeriesPageViewModel.cs create mode 100644 ViewModels/SettingsPageViewModel.cs create mode 100644 ViewModels/ViewModelBase.cs create mode 100644 Views/AccountPageView.axaml create mode 100644 Views/AccountPageView.axaml.cs create mode 100644 Views/AddDownloadPageView.axaml create mode 100644 Views/AddDownloadPageView.axaml.cs create mode 100644 Views/CalendarPageView.axaml create mode 100644 Views/CalendarPageView.axaml.cs create mode 100644 Views/ContentDialogInputLoginView.axaml create mode 100644 Views/ContentDialogInputLoginView.axaml.cs create mode 100644 Views/DownloadsPageView.axaml create mode 100644 Views/DownloadsPageView.axaml.cs create mode 100644 Views/HistoryPageView.axaml create mode 100644 Views/HistoryPageView.axaml.cs create mode 100644 Views/MainWindow.axaml create mode 100644 Views/MainWindow.axaml.cs create mode 100644 Views/SeriesPageView.axaml create mode 100644 Views/SeriesPageView.axaml.cs create mode 100644 Views/SettingsPageView.axaml create mode 100644 Views/SettingsPageView.axaml.cs create mode 100644 Views/ToastNotification.axaml create mode 100644 Views/ToastNotification.axaml.cs create mode 100644 Views/Utils/ErrorWindow.axaml create mode 100644 Views/Utils/ErrorWindow.axaml.cs delete mode 100644 c-sharp-language.cs diff --git a/App.axaml b/App.axaml new file mode 100644 index 0000000..c6ae445 --- /dev/null +++ b/App.axaml @@ -0,0 +1,20 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/App.axaml.cs b/App.axaml.cs new file mode 100644 index 0000000..c6f60f6 --- /dev/null +++ b/App.axaml.cs @@ -0,0 +1,23 @@ +using Avalonia; +using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Markup.Xaml; +using CRD.ViewModels; +using MainWindow = CRD.Views.MainWindow; + +namespace CRD; + +public partial class App : Application{ + public override void Initialize(){ + AvaloniaXamlLoader.Load(this); + } + + public override void OnFrameworkInitializationCompleted(){ + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop){ + desktop.MainWindow = new MainWindow{ + DataContext = new MainWindowViewModel(), + }; + } + + base.OnFrameworkInitializationCompleted(); + } +} \ No newline at end of file diff --git a/Assets/Icons.axaml b/Assets/Icons.axaml new file mode 100644 index 0000000..4ff874f --- /dev/null +++ b/Assets/Icons.axaml @@ -0,0 +1,11 @@ + + + \ No newline at end of file diff --git a/Assets/app_icon.ico b/Assets/app_icon.ico new file mode 100644 index 0000000000000000000000000000000000000000..66ef3af5420eb5d5cb8e64c0a67cec8b7c9a0d5d GIT binary patch literal 161024 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
    AlRUnHuc@gEpDzY|*qbv0*I6Bjf)~$9(O8jsz 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 F{{`c^(;5H( literal 0 HcmV?d00001 diff --git a/Downloader/CRAuth.cs b/Downloader/CRAuth.cs new file mode 100644 index 0000000..244551b --- /dev/null +++ b/Downloader/CRAuth.cs @@ -0,0 +1,223 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using System.Web; +using CRD.Utils; +using CRD.Utils.Structs; +using Newtonsoft.Json; +using YamlDotNet.Core.Tokens; + +namespace CRD.Downloader; + +public class CrAuth(Crunchyroll crunInstance){ + public async Task AuthAnonymous(){ + var formData = new Dictionary{ + { "grant_type", "client_id" }, + { "scope", "offline_access" } + }; + var requestContent = new FormUrlEncodedContent(formData); + requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + + var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){ + Content = requestContent + }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + JsonTokenToFileAndVariable(response.ResponseContent); + } else{ + Console.WriteLine("Anonymous login failed"); + } + + crunInstance.Profile = new CrProfile{ + Username = "???", + Avatar = "003-cr-hime-excited.png", + PreferredContentAudioLanguage = "ja-JP", + PreferredContentSubtitleLanguage = "de-DE" + }; + + Crunchyroll.Instance.CmsToken = new CrCmsToken(); + + } + + private void JsonTokenToFileAndVariable(string content){ + crunInstance.Token = JsonConvert.DeserializeObject(content, crunInstance.SettingsJsonSerializerSettings); + + + if (crunInstance.Token != null && crunInstance.Token.expires_in != null){ + crunInstance.Token.expires = DateTime.Now.AddMilliseconds((double)crunInstance.Token.expires_in); + + CfgManager.WriteTokenToYamlFile(crunInstance.Token, CfgManager.PathCrToken); + } + } + + public async Task Auth(AuthData data){ + var formData = new Dictionary{ + { "username", data.Username }, + { "password", data.Password }, + { "grant_type", "password" }, + { "scope", "offline_access" } + }; + var requestContent = new FormUrlEncodedContent(formData); + requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + + var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){ + Content = requestContent + }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + JsonTokenToFileAndVariable(response.ResponseContent); + } + + if (crunInstance.Token?.refresh_token != null){ + HttpClientReq.Instance.SetETPCookie(crunInstance.Token.refresh_token); + + await GetProfile(); + } + } + + public async Task GetProfile(){ + if (crunInstance.Token?.access_token == null){ + Console.Error.WriteLine("Missing Access Token"); + return; + } + + var request = HttpClientReq.CreateRequestMessage(Api.BetaProfile, HttpMethod.Get, true, true, null); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + var profileTemp = Helpers.Deserialize(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + + if (profileTemp != null){ + crunInstance.Profile = profileTemp; + } + } + } + + public async void LoginWithToken(){ + if (crunInstance.Token?.refresh_token == null){ + Console.WriteLine("Missing Refresh Token"); + return; + } + + var formData = new Dictionary{ + { "refresh_token", crunInstance.Token.refresh_token }, + { "grant_type", "refresh_token" }, + { "scope", "offline_access" } + }; + var requestContent = new FormUrlEncodedContent(formData); + requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + + var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){ + Content = requestContent + }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + JsonTokenToFileAndVariable(response.ResponseContent); + } else{ + Console.WriteLine("Token Auth Failed"); + } + + if (crunInstance.Token?.refresh_token != null){ + await GetProfile(); + } + + await GetCmsToken(); + } + + public async Task RefreshToken(bool needsToken){ + if (crunInstance.Token?.access_token == null && crunInstance.Token?.refresh_token == null || + crunInstance.Token.access_token != null && crunInstance.Token.refresh_token == null){ + await AuthAnonymous(); + } else{ + if (!(DateTime.Now > crunInstance.Token.expires) && needsToken){ + return; + } + } + + var formData = new Dictionary{ + { "refresh_token", crunInstance.Token?.refresh_token ?? string.Empty }, + { "grant_type", "refresh_token" }, + { "scope", "offline_access" } + }; + var requestContent = new FormUrlEncodedContent(formData); + requestContent.Headers.ContentType = new MediaTypeHeaderValue("application/x-www-form-urlencoded"); + + var request = new HttpRequestMessage(HttpMethod.Post, Api.BetaAuth){ + Content = requestContent + }; + + request.Headers.Authorization = new AuthenticationHeaderValue("Basic", Api.authBasicSwitch); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + JsonTokenToFileAndVariable(response.ResponseContent); + } else{ + Console.WriteLine("Refresh Token Auth Failed"); + } + + await GetCmsToken(); + } + + + public async Task GetCmsToken(){ + if (crunInstance.Token?.access_token == null){ + Console.WriteLine($"Missing Access Token: {crunInstance.Token?.access_token}"); + return; + } + + var request = HttpClientReq.CreateRequestMessage(Api.BetaCmsToken, HttpMethod.Get, true, true, null); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + crunInstance.CmsToken = JsonConvert.DeserializeObject(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + } else{ + Console.WriteLine("CMS Token Auth Failed"); + } + } + + public async Task GetCmsData(){ + if (crunInstance.CmsToken?.Cms == null){ + Console.WriteLine("Missing CMS Token"); + return; + } + + UriBuilder uriBuilder = new UriBuilder(Api.BetaCms + crunInstance.CmsToken.Cms.Bucket + "/index?"); + + NameValueCollection query = HttpUtility.ParseQueryString(uriBuilder.Query); + + query["preferred_audio_language"] = "ja-JP"; + query["Policy"] = crunInstance.CmsToken.Cms.Policy; + query["Signature"] = crunInstance.CmsToken.Cms.Signature; + query["Key-Pair-Id"] = crunInstance.CmsToken.Cms.KeyPairId; + + uriBuilder.Query = query.ToString(); + + var request = new HttpRequestMessage(HttpMethod.Get, uriBuilder.ToString()); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (response.IsOk){ + Console.WriteLine(response.ResponseContent); + } else{ + Console.WriteLine("Failed to Get CMS Index"); + } + } +} \ No newline at end of file diff --git a/Downloader/CrEpisode.cs b/Downloader/CrEpisode.cs new file mode 100644 index 0000000..1dc5710 --- /dev/null +++ b/Downloader/CrEpisode.cs @@ -0,0 +1,249 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using CRD.Utils; +using CRD.Utils.Structs; +using Newtonsoft.Json; + +namespace CRD.Downloader; + +public class CrEpisode(Crunchyroll crunInstance){ + public async Task ParseEpisodeById(string id,string locale){ + if (crunInstance.CmsToken?.Cms == null){ + Console.WriteLine("Missing CMS Access Token"); + return null; + } + + NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); + + query["preferred_audio_language"] = "ja-JP"; + query["locale"] = Languages.Locale2language(locale).CrLocale; + + var request = HttpClientReq.CreateRequestMessage($"{Api.Cms}/episodes/{id}", HttpMethod.Get, true, true, query); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (!response.IsOk){ + Console.WriteLine("Series Request Failed"); + return null; + } + + CrunchyEpisodeList epsidoe = Helpers.Deserialize(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + + if (epsidoe.Total < 1){ + return null; + } + + return epsidoe; + } + + + public CrunchySeriesList EpisodeData(CrunchyEpisodeList dlEpisodes){ + bool serieshasversions = true; + + Dictionary episodes = new Dictionary(); + + if (dlEpisodes.Data != null){ + foreach (var episode in dlEpisodes.Data){ + + if (crunInstance.CrunOptions.History){ + crunInstance.CrHistory.UpdateWithEpisode(episode); + } + + // Prepare the episode array + EpisodeAndLanguage item; + var seasonIdentifier = !string.IsNullOrEmpty(episode.Identifier) ? episode.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}"; + var episodeKey = $"{seasonIdentifier}E{episode.Episode ?? (episode.EpisodeNumber + "")}"; + + if (!episodes.ContainsKey(episodeKey)){ + item = new EpisodeAndLanguage{ + Items = new List(), + Langs = new List() + }; + episodes[episodeKey] = item; + } else{ + item = episodes[episodeKey]; + } + + if (episode.Versions != null){ + foreach (var version in episode.Versions){ + // Ensure there is only one of the same language + if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){ + // Push to arrays if there are no duplicates of the same language + item.Items.Add(episode); + item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)); + } + } + } else{ + // Episode didn't have versions, mark it as such to be logged. + serieshasversions = false; + // Ensure there is only one of the same language + if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){ + // Push to arrays if there are no duplicates of the same language + item.Items.Add(episode); + item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale)); + } + } + } + } + + int specialIndex = 1; + int epIndex = 1; + + var keys = new List(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating. + + foreach (var key in keys){ + EpisodeAndLanguage item = episodes[key]; + var isSpecial = !Regex.IsMatch(item.Items[0].Episode ?? string.Empty, @"^\d+$"); // Checking if the episode is not a number (i.e., special). + string newKey; + if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){ + newKey = item.Items[0].Episode ?? "SP" + (specialIndex + " " + item.Items[0].Id); + } else{ + newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}"; + } + + episodes.Remove(key); + episodes.Add(newKey, item); + + if (isSpecial){ + specialIndex++; + } else{ + epIndex++; + } + } + + var specials = episodes.Where(e => e.Key.StartsWith("SP")).ToList(); + var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList(); + + // Combining and sorting episodes with normal first, then specials. + var sortedEpisodes = new Dictionary(normal.Concat(specials)); + + foreach (var kvp in sortedEpisodes){ + var key = kvp.Key; + var item = kvp.Value; + + var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)")).SeasonTitle + ?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); + + var title = item.Items[0].Title; + var seasonNumber = item.Items[0].SeasonNumber; + + var languages = item.Items.Select((a, index) => + $"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index).Name ?? "Unknown"}").ToArray(); //☆ + + Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]"); + } + + if (!serieshasversions){ + Console.WriteLine("Couldn\'t find versions on some episodes, fell back to old method."); + } + + CrunchySeriesList crunchySeriesList = new CrunchySeriesList(); + crunchySeriesList.Data = sortedEpisodes; + + crunchySeriesList.List = sortedEpisodes.Select(kvp => { + var key = kvp.Key; + var value = kvp.Value; + var images = (value.Items[0].Images?.Thumbnail ?? new List>{ new List{ new Image{ Source = "/notFound.png" } } }); + var seconds = (int)Math.Floor(value.Items[0].DurationMs / 1000.0); + return new Episode{ + E = key.StartsWith("E") ? key.Substring(1) : key, + Lang = value.Langs.Select(a => a.Code).ToList(), + Name = value.Items[0].Title, + Season = value.Items[0].SeasonNumber.ToString(), + SeriesTitle = Regex.Replace(value.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd(), + SeasonTitle = Regex.Replace(value.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(), + EpisodeNum = value.Items[0].EpisodeNumber?.ToString() ?? value.Items[0].Episode ?? "?", + Id = value.Items[0].SeasonId, + Img = images[images.Count / 2].FirstOrDefault().Source, + Description = value.Items[0].Description, + Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds. + }; + }).ToList(); + + return crunchySeriesList; + } + + public Dictionary EpisodeMeta(Dictionary eps, List dubLang){ + var ret = new Dictionary(); + + + foreach (var kvp in eps){ + var key = kvp.Key; + var episode = kvp.Value; + + for (int index = 0; index < episode.Items.Count; index++){ + var item = episode.Items[index]; + + if (!dubLang.Contains(episode.Langs[index].CrLocale)) + continue; + + item.HideSeasonTitle = true; + if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){ + item.SeasonTitle = item.SeriesTitle; + item.HideSeasonTitle = false; + item.HideSeasonNumber = true; + } + + if (string.IsNullOrEmpty(item.SeasonTitle) && string.IsNullOrEmpty(item.SeriesTitle)){ + item.SeasonTitle = "NO_TITLE"; + item.SeriesTitle = "NO_TITLE"; + } + + var epNum = key.StartsWith('E') ? key[1..] : key; + var images = (item.Images?.Thumbnail ?? new List>{ new List{ new Image{ Source = "/notFound.png" } } }); + + Regex dubPattern = new Regex(@"\(\w+ Dub\)"); + + var epMeta = new CrunchyEpMeta(); + epMeta.Data = new List{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } }; + epMeta.SeriesTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle)).SeriesTitle ?? Regex.Replace(episode.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd(); + epMeta.SeasonTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle)).SeasonTitle ?? Regex.Replace(episode.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); + epMeta.EpisodeNumber = item.Episode; + epMeta.EpisodeTitle = item.Title; + epMeta.SeasonId = item.SeasonId; + epMeta.Season = item.SeasonNumber; + epMeta.ShowId = item.SeriesId; + epMeta.AbsolutEpisodeNumberE = epNum; + epMeta.Image = images[images.Count / 2].FirstOrDefault().Source; + epMeta.DownloadProgress = new DownloadProgress(){ + IsDownloading = false, + Done = false, + Error = false, + Percent = 0, + Time = 0, + DownloadSpeed = 0 + }; + + var epMetaData = epMeta.Data[0]; + if (!string.IsNullOrEmpty(item.StreamsLink)){ + epMetaData.Playback = item.StreamsLink; + if (string.IsNullOrEmpty(item.Playback)){ + item.Playback = item.StreamsLink; + } + } + + if (ret.TryGetValue(key, out var epMe)){ + epMetaData.Lang = episode.Langs[index]; + epMe.Data?.Add(epMetaData); + } else{ + epMetaData.Lang = episode.Langs[index]; + epMeta.Data[0] = epMetaData; + ret.Add(key, epMeta); + } + + + // show ep + item.SeqId = epNum; + } + } + + + return ret; + } +} \ No newline at end of file diff --git a/Downloader/CrSeries.cs b/Downloader/CrSeries.cs new file mode 100644 index 0000000..faa7352 --- /dev/null +++ b/Downloader/CrSeries.cs @@ -0,0 +1,406 @@ +using System; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Linq; +using System.Net.Http; +using System.Net.Http.Headers; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using CRD.Utils; +using CRD.Utils.Structs; +using Newtonsoft.Json; + +namespace CRD.Downloader; + +public class CrSeries(Crunchyroll crunInstance){ + public async Task> DownloadFromSeriesId(string id, CrunchyMultiDownload data){ + var series = await ListSeriesId(id, "" ,data); + + if (series != null){ + var selected = ItemSelectMultiDub(series.Value.Data, data.DubLang, data.But, data.AllEpisodes, data.E); + + foreach (var crunchyEpMeta in selected.Values){ + if (crunchyEpMeta.Data == null) continue; + var languages = crunchyEpMeta.Data.Select((a) => + $" {a.Lang?.Name ?? "Unknown Language"}"); + + Console.WriteLine($"[S{crunchyEpMeta.Season}E{crunchyEpMeta.EpisodeNumber} - {crunchyEpMeta.EpisodeTitle} [{string.Join(", ", languages)}]"); + } + + return selected.Values.ToList(); + } + + return new List(); + } + + public Dictionary ItemSelectMultiDub(Dictionary eps, List dubLang, bool? but, bool? all, List? e){ + var ret = new Dictionary(); + + + foreach (var kvp in eps){ + var key = kvp.Key; + var episode = kvp.Value; + + for (int index = 0; index < episode.Items.Count; index++){ + var item = episode.Items[index]; + + if (!dubLang.Contains(episode.Langs[index].CrLocale)) + continue; + + item.HideSeasonTitle = true; + if (string.IsNullOrEmpty(item.SeasonTitle) && !string.IsNullOrEmpty(item.SeriesTitle)){ + item.SeasonTitle = item.SeriesTitle; + item.HideSeasonTitle = false; + item.HideSeasonNumber = true; + } + + if (string.IsNullOrEmpty(item.SeasonTitle) && string.IsNullOrEmpty(item.SeriesTitle)){ + item.SeasonTitle = "NO_TITLE"; + item.SeriesTitle = "NO_TITLE"; + } + + var epNum = key.StartsWith('E') ? key[1..] : key; + var images = (item.Images?.Thumbnail ?? new List>{ new List{ new Image{ Source = "/notFound.png" } } }); + + Regex dubPattern = new Regex(@"\(\w+ Dub\)"); + + var epMeta = new CrunchyEpMeta(); + epMeta.Data = new List{ new(){ MediaId = item.Id, Versions = item.Versions, IsSubbed = item.IsSubbed, IsDubbed = item.IsDubbed } }; + epMeta.SeriesTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeriesTitle)).SeriesTitle ?? Regex.Replace(episode.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd(); + epMeta.SeasonTitle = episode.Items.FirstOrDefault(a => !dubPattern.IsMatch(a.SeasonTitle)).SeasonTitle ?? Regex.Replace(episode.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); + epMeta.EpisodeNumber = item.Episode; + epMeta.EpisodeTitle = item.Title; + epMeta.SeasonId = item.SeasonId; + epMeta.Season = item.SeasonNumber; + epMeta.ShowId = item.SeriesId; + epMeta.AbsolutEpisodeNumberE = epNum; + epMeta.Image = images[images.Count / 2].FirstOrDefault().Source; + epMeta.DownloadProgress = new DownloadProgress(){ + IsDownloading = false, + Done = false, + Percent = 0, + Time = 0, + DownloadSpeed = 0 + }; + + + + var epMetaData = epMeta.Data[0]; + if (!string.IsNullOrEmpty(item.StreamsLink)){ + epMetaData.Playback = item.StreamsLink; + if (string.IsNullOrEmpty(item.Playback)){ + item.Playback = item.StreamsLink; + } + } + + if (all is true || e != null && e.Contains(epNum)){ + if (ret.TryGetValue(key, out var epMe)){ + epMetaData.Lang = episode.Langs[index]; + epMe.Data?.Add(epMetaData); + } else{ + epMetaData.Lang = episode.Langs[index]; + epMeta.Data[0] = epMetaData; + ret.Add(key, epMeta); + } + } + + + // show ep + item.SeqId = epNum; + } + } + + + return ret; + } + + + public async Task ListSeriesId(string id,string Locale, CrunchyMultiDownload? data){ + await crunInstance.CrAuth.RefreshToken(true); + + bool serieshasversions = true; + + CrSeriesSearch? parsedSeries = await ParseSeriesById(id,Locale); // one piece - GRMG8ZQZR + + if (parsedSeries == null){ + Console.WriteLine("Parse Data Invalid"); + return null; + } + + var result = ParseSeriesResult(parsedSeries); + Dictionary episodes = new Dictionary(); + + + foreach (int season in result.Keys){ + foreach (var key in result[season].Keys){ + var s = result[season][key]; + if (data?.S != null && s.Id != data.Value.S) continue; + int fallbackIndex = 0; + var seasonData = await GetSeasonDataById(s); + if (seasonData.Data != null){ + + if (crunInstance.CrunOptions.History){ + crunInstance.CrHistory.UpdateWithSeasonData(seasonData); + } + + foreach (var episode in seasonData.Data){ + // Prepare the episode array + EpisodeAndLanguage item; + + + string episodeNum = (episode.Episode != String.Empty ? episode.Episode : (episode.EpisodeNumber != null ? episode.EpisodeNumber + "" : $"F{fallbackIndex++}")) ?? string.Empty; + + var seasonIdentifier = !string.IsNullOrEmpty(s.Identifier) ? s.Identifier.Split('|')[1] : $"S{episode.SeasonNumber}"; + var episodeKey = $"{seasonIdentifier}E{episodeNum}"; + + if (!episodes.ContainsKey(episodeKey)){ + item = new EpisodeAndLanguage{ + Items = new List(), + Langs = new List() + }; + episodes[episodeKey] = item; + } else{ + item = episodes[episodeKey]; + } + + if (episode.Versions != null){ + foreach (var version in episode.Versions){ + // Ensure there is only one of the same language + if (item.Langs.All(a => a.CrLocale != version.AudioLocale)){ + // Push to arrays if there are no duplicates of the same language + item.Items.Add(episode); + item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == version.AudioLocale)); + } + } + } else{ + // Episode didn't have versions, mark it as such to be logged. + serieshasversions = false; + // Ensure there is only one of the same language + if (item.Langs.All(a => a.CrLocale != episode.AudioLocale)){ + // Push to arrays if there are no duplicates of the same language + item.Items.Add(episode); + item.Langs.Add(Array.Find(Languages.languages, a => a.CrLocale == episode.AudioLocale)); + } + } + } + } + } + } + + int specialIndex = 1; + int epIndex = 1; + + var keys = new List(episodes.Keys); // Copying the keys to a new list to avoid modifying the collection while iterating. + + foreach (var key in keys){ + EpisodeAndLanguage item = episodes[key]; + var episode = item.Items[0].Episode; + var isSpecial = episode != null && !Regex.IsMatch(episode, @"^\d+$"); // Checking if the episode is not a number (i.e., special). + // var newKey = $"{(isSpecial ? 'S' : 'E')}{(isSpecial ? specialIndex : epIndex).ToString()}"; + + string newKey; + if (isSpecial && !string.IsNullOrEmpty(item.Items[0].Episode)){ + newKey = item.Items[0].Episode ?? "SP" + (specialIndex + " " + item.Items[0].Id); + } else{ + newKey = $"{(isSpecial ? "SP" : 'E')}{(isSpecial ? (specialIndex + " " + item.Items[0].Id) : epIndex + "")}"; + } + + episodes.Remove(key); + episodes.Add(newKey, item); + + if (isSpecial){ + specialIndex++; + } else{ + epIndex++; + } + } + + var specials = episodes.Where(e => e.Key.StartsWith("S")).ToList(); + var normal = episodes.Where(e => e.Key.StartsWith("E")).ToList(); + + // Combining and sorting episodes with normal first, then specials. + var sortedEpisodes = new Dictionary(normal.Concat(specials)); + + foreach (var kvp in sortedEpisodes){ + var key = kvp.Key; + var item = kvp.Value; + + var seasonTitle = item.Items.FirstOrDefault(a => !Regex.IsMatch(a.SeasonTitle, @"\(\w+ Dub\)")).SeasonTitle + ?? Regex.Replace(item.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(); + + var title = item.Items[0].Title; + var seasonNumber = item.Items[0].SeasonNumber; + + var languages = item.Items.Select((a, index) => + $"{(a.IsPremiumOnly ? "+ " : "")}{item.Langs.ElementAtOrDefault(index).Name ?? "Unknown"}").ToArray(); //☆ + + Console.WriteLine($"[{key}] {seasonTitle} - Season {seasonNumber} - {title} [{string.Join(", ", languages)}]"); + } + + if (!serieshasversions){ + Console.WriteLine("Couldn\'t find versions on some episodes, fell back to old method."); + } + + CrunchySeriesList crunchySeriesList = new CrunchySeriesList(); + crunchySeriesList.Data = sortedEpisodes; + + crunchySeriesList.List = sortedEpisodes.Select(kvp => { + var key = kvp.Key; + var value = kvp.Value; + var images = (value.Items[0].Images?.Thumbnail ?? new List>{ new List{ new Image{ Source = "/notFound.png" } } }); + var seconds = (int)Math.Floor(value.Items[0].DurationMs / 1000.0); + return new Episode{ + E = key.StartsWith("E") ? key.Substring(1) : key, + Lang = value.Langs.Select(a => a.Code).ToList(), + Name = value.Items[0].Title, + Season = value.Items[0].SeasonNumber.ToString(), + SeriesTitle = Regex.Replace(value.Items[0].SeriesTitle, @"\(\w+ Dub\)", "").TrimEnd(), + SeasonTitle = Regex.Replace(value.Items[0].SeasonTitle, @"\(\w+ Dub\)", "").TrimEnd(), + EpisodeNum = value.Items[0].EpisodeNumber?.ToString() ?? value.Items[0].Episode ?? "?", + Id = value.Items[0].SeasonId, + Img = images[images.Count / 2].FirstOrDefault().Source, + Description = value.Items[0].Description, + Time = $"{seconds / 60}:{seconds % 60:D2}" // Ensures two digits for seconds. + }; + }).ToList(); + + return crunchySeriesList; + } + + public async Task GetSeasonDataById(SeriesSearchItem item, bool log = false){ + CrunchyEpisodeList episodeList = new CrunchyEpisodeList(){ Data = new List(), Total = 0, Meta = new Meta() }; + + if (crunInstance.CmsToken?.Cms == null){ + Console.WriteLine("Missing CMS Token"); + return episodeList; + } + + if (log){ + var showRequest = HttpClientReq.CreateRequestMessage($"{Api.Cms}/seasons/{item.Id}?preferred_audio_language=ja-JP", HttpMethod.Get, true, true, null); + + var response = await HttpClientReq.Instance.SendHttpRequest(showRequest); + + if (!response.IsOk){ + Console.WriteLine("Show Request FAILED!"); + } else{ + Console.WriteLine(response.ResponseContent); + } + } + + //TODO + + var episodeRequest = new HttpRequestMessage(HttpMethod.Get, $"{Api.Cms}/seasons/{item.Id}/episodes?preferred_audio_language=ja-JP"); + + episodeRequest.Headers.Authorization = new AuthenticationHeaderValue("Bearer", crunInstance.Token?.access_token); + + var episodeRequestResponse = await HttpClientReq.Instance.SendHttpRequest(episodeRequest); + + if (!episodeRequestResponse.IsOk){ + Console.WriteLine($"Episode List Request FAILED! uri: {episodeRequest.RequestUri}"); + } else{ + episodeList = Helpers.Deserialize(episodeRequestResponse.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + } + + if (episodeList.Total < 1){ + Console.WriteLine("Season is empty!"); + } + + return episodeList; + } + + public Dictionary> ParseSeriesResult(CrSeriesSearch seasonsList){ + var ret = new Dictionary>(); + int i = 0; + + foreach (var item in seasonsList.Data){ + i++; + foreach (var lang in Languages.languages){ + int seasonNumber = item.SeasonNumber; + if (item.Versions != null){ + seasonNumber = i; + } + + if (!ret.ContainsKey(seasonNumber)){ + ret[seasonNumber] = new Dictionary(); + } + + if (item.Title.Contains($"({lang.Name} Dub)") || item.Title.Contains($"({lang.Name})")){ + ret[seasonNumber][lang.Code] = item; + } else if (item.IsSubbed && !item.IsDubbed && lang.Code == "jpn"){ + ret[seasonNumber][lang.Code] = item; + } else if (item.IsDubbed && lang.Code == "eng" && !Languages.languages.Any(a => (item.Title.Contains($"({a.Name})") || item.Title.Contains($"({a.Name} Dub)")))){ + // Dubbed with no more infos will be treated as eng dubs + ret[seasonNumber][lang.Code] = item; + } else if (item.AudioLocale == lang.CrLocale){ + ret[seasonNumber][lang.Code] = item; + } + } + } + + return ret; + } + + public async Task ParseSeriesById(string id,string? locale){ + if (crunInstance.CmsToken?.Cms == null){ + Console.WriteLine("Missing CMS Access Token"); + return null; + } + + NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); + + query["preferred_audio_language"] = "ja-JP"; + if (!string.IsNullOrEmpty(locale)){ + query["locale"] = Languages.Locale2language(locale).CrLocale; + } + + + var request = HttpClientReq.CreateRequestMessage($"{Api.Cms}/series/{id}/seasons", HttpMethod.Get, true, true, query); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (!response.IsOk){ + Console.WriteLine("Series Request Failed"); + return null; + } + + + CrSeriesSearch? seasonsList = Helpers.Deserialize(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + + if (seasonsList == null || seasonsList.Total < 1){ + return null; + } + + return seasonsList; + } + + public async Task SeriesById(string id){ + if (crunInstance.CmsToken?.Cms == null){ + Console.WriteLine("Missing CMS Access Token"); + return null; + } + + NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); + + query["preferred_audio_language"] = "ja-JP"; + + var request = HttpClientReq.CreateRequestMessage($"{Api.Cms}/series/{id}", HttpMethod.Get, true, true, query); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + if (!response.IsOk){ + Console.WriteLine("Series Request Failed"); + return null; + } + + + CrSeriesBase? series = Helpers.Deserialize(response.ResponseContent, crunInstance.SettingsJsonSerializerSettings); + + if (series == null || series.Total < 1){ + return null; + } + + return series; + } + +} \ No newline at end of file diff --git a/Downloader/Crunchyroll.cs b/Downloader/Crunchyroll.cs new file mode 100644 index 0000000..a96b7a5 --- /dev/null +++ b/Downloader/Crunchyroll.cs @@ -0,0 +1,1617 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Net; +using System.Net.Http; +using System.Text; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using System.Web; +using Avalonia.Media; +using CRD.Utils; +using CRD.Utils.CustomList; +using CRD.Utils.DRM; +using CRD.Utils.HLS; +using CRD.Utils.Muxing; +using CRD.Utils.Structs; +using CRD.ViewModels; +using CRD.Views; +using HtmlAgilityPack; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; +using ReactiveUI; +using LanguageItem = CRD.Utils.Structs.LanguageItem; + +namespace CRD.Downloader; + +public class Crunchyroll{ + public CrToken? Token; + public CrCmsToken? CmsToken; + private readonly string _api = "web"; //web | android + + public CrProfile Profile = new(); + public CrDownloadOptions CrunOptions; + + #region Download Variables + + public RefreshableObservableCollection Queue = new RefreshableObservableCollection(); + public ObservableCollection DownloadItemModels = new ObservableCollection(); + public int ActiveDownloads; + public bool AutoDownload = false; + + #endregion + + + #region Calendar Variables + + private Dictionary calendar = new(); + private Dictionary calendarLanguage = new(); + + #endregion + + + #region History Variables + + public ObservableCollection HistoryList = new(); + + public HistorySeries SelectedSeries = new HistorySeries{ + Seasons =[] + }; + + #endregion + + public string DefaultLocale = "en"; + + public JsonSerializerSettings? SettingsJsonSerializerSettings = new(){ + NullValueHandling = NullValueHandling.Ignore, + }; + + private Widevine _widevine = Widevine.Instance; + + public CrAuth CrAuth; + public CrEpisode CrEpisode; + public CrSeries CrSeries; + public History CrHistory; + + #region Singelton + + private static Crunchyroll? _instance; + private static readonly object Padlock = new(); + + public static Crunchyroll Instance{ + get{ + if (_instance == null){ + lock (Padlock){ + if (_instance == null){ + _instance = new Crunchyroll(); + } + } + } + + return _instance; + } + } + + #endregion + + public async Task Init(){ + _widevine = Widevine.Instance; + + CrAuth = new CrAuth(Instance); + CrEpisode = new CrEpisode(Instance); + CrSeries = new CrSeries(Instance); + CrHistory = new History(Instance); + + Profile = new CrProfile{ + Username = "???", + Avatar = "003-cr-hime-excited.png", + PreferredContentAudioLanguage = "ja-JP", + PreferredContentSubtitleLanguage = "de-DE" + }; + + + if (CfgManager.CheckIfFileExists(CfgManager.PathCrToken)){ + Token = CfgManager.DeserializeFromFile(CfgManager.PathCrToken); + CrAuth.LoginWithToken(); + } else{ + await CrAuth.AuthAnonymous(); + } + + Console.WriteLine($"Can Decrypt: {_widevine.canDecrypt}"); + + CrunOptions = new CrDownloadOptions(); + + CrunOptions.Chapters = true; + CrunOptions.Hslang = "none"; + CrunOptions.Force = "Y"; + CrunOptions.FileName = "${showTitle} - S${season}E${episode} [${height}p]"; + CrunOptions.Partsize = 10; + CrunOptions.NoSubs = false; + CrunOptions.DlSubs = new List{ "de-DE" }; + CrunOptions.Skipmux = false; + CrunOptions.MkvmergeOptions = new List{ "--no-date", "--disable-track-statistics-tags", "--engage no_variable_data" }; + CrunOptions.DefaultAudio = Languages.FindLang("ja-JP"); + CrunOptions.DefaultSub = Languages.FindLang("de-DE"); + CrunOptions.CcTag = "cc"; + CrunOptions.FsRetryTime = 5; + CrunOptions.Numbers = 2; + CrunOptions.Timeout = 15000; + CrunOptions.DubLang = new List(){ "ja-JP" }; + CrunOptions.SimultaneousDownloads = 2; + CrunOptions.AccentColor = Colors.SlateBlue.ToString(); + CrunOptions.Theme = "System"; + CrunOptions.SelectedCalendarLanguage = "de"; + + CrunOptions.History = true; + + CfgManager.UpdateSettingsFromFile(); + + if (CrunOptions.History){ + if (File.Exists(CfgManager.PathCrHistory)){ + HistoryList = JsonConvert.DeserializeObject>(File.ReadAllText(CfgManager.PathCrHistory)) ??[]; + } + } + + + calendarLanguage = new(){ + { "en-us", "https://www.crunchyroll.com/simulcastcalendar" }, + { "es", "https://www.crunchyroll.com/es/simulcastcalendar" }, + { "es-es", "https://www.crunchyroll.com/es-es/simulcastcalendar" }, + { "pt-br", "https://www.crunchyroll.com/pt-br/simulcastcalendar" }, + { "pt-pt", "https://www.crunchyroll.com/pt-pt/simulcastcalendar" }, + { "fr", "https://www.crunchyroll.com/fr/simulcastcalendar" }, + { "de", "https://www.crunchyroll.com/de/simulcastcalendar" }, + { "ar", "https://www.crunchyroll.com/ar/simulcastcalendar" }, + { "it", "https://www.crunchyroll.com/it/simulcastcalendar" }, + { "ru", "https://www.crunchyroll.com/ru/simulcastcalendar" }, + { "hi", "https://www.crunchyroll.com/hi/simulcastcalendar" }, + }; + } + + // public async void TestMethode(){ + // // One Pice - GRMG8ZQZR + // // Studio - G9VHN9QWQ + // var episodesMeta = await DownloadFromSeriesId("G9VHN9QWQ", new CrunchyMultiDownload(Crunchy.Instance.CrunOptions.dubLang, true)); + // + // + // foreach (var crunchyEpMeta in episodesMeta){ + // await DownloadEpisode(crunchyEpMeta, CrunOptions, false); + // } + // } + + public async Task GetCalendarForDate(string weeksMondayDate, bool forceUpdate){ + if (!forceUpdate && calendar.TryGetValue(weeksMondayDate, out var forDate)){ + return forDate; + } + + var request = HttpClientReq.CreateRequestMessage($"{calendarLanguage[CrunOptions.SelectedCalendarLanguage ?? "de"]}?filter=premium&date={weeksMondayDate}", HttpMethod.Get, true, true, null); + + var response = await HttpClientReq.Instance.SendHttpRequest(request); + + CalendarWeek week = new CalendarWeek(); + week.CalendarDays = new List(); + + // Load the HTML content from a file + HtmlDocument doc = new HtmlDocument(); + doc.LoadHtml(WebUtility.HtmlDecode(response.ResponseContent)); + + // Select each 'li' element with class 'day' + var dayNodes = doc.DocumentNode.SelectNodes("//li[contains(@class, 'day')]"); + + if (dayNodes != null){ + foreach (var day in dayNodes){ + // Extract the date and day name + var date = day.SelectSingleNode(".//time[@datetime]")?.GetAttributeValue("datetime", "No date"); + DateTime dayDateTime = DateTime.Parse(date, null, DateTimeStyles.RoundtripKind); + + if (week.FirstDayOfWeek == null){ + week.FirstDayOfWeek = dayDateTime; + week.FirstDayOfWeekString = dayDateTime.ToString("yyyy-MM-dd"); + } + + var dayName = day.SelectSingleNode(".//h1[@class='day-name']/time")?.InnerText.Trim(); + + // Console.WriteLine($"Day: {dayName}, Date: {date}"); + + CalendarDay calDay = new CalendarDay(); + + calDay.CalendarEpisodes = new List(); + calDay.DayName = dayName; + calDay.DateTime = dayDateTime; + + // Iterate through each episode listed under this day + var episodes = day.SelectNodes(".//article[contains(@class, 'release')]"); + if (episodes != null){ + foreach (var episode in episodes){ + var episodeTimeStr = episode.SelectSingleNode(".//time[contains(@class, 'available-time')]")?.GetAttributeValue("datetime", null); + DateTime episodeTime = DateTime.Parse(episodeTimeStr, null, DateTimeStyles.RoundtripKind); + var hasPassed = DateTime.Now > episodeTime; + + var episodeName = episode.SelectSingleNode(".//h1[contains(@class, 'episode-name')]")?.SelectSingleNode(".//cite[@itemprop='name']")?.InnerText.Trim(); + var seasonLink = episode.SelectSingleNode(".//a[contains(@class, 'js-season-name-link')]")?.GetAttributeValue("href", "No link"); + var episodeLink = episode.SelectSingleNode(".//a[contains(@class, 'available-episode-link')]")?.GetAttributeValue("href", "No link"); + var thumbnailUrl = episode.SelectSingleNode(".//img[contains(@class, 'thumbnail')]")?.GetAttributeValue("src", "No image"); + var isPremiumOnly = episode.SelectSingleNode(".//svg[contains(@class, 'premium-flag')]") != null; + var seasonName = episode.SelectSingleNode(".//a[contains(@class, 'js-season-name-link')]")?.SelectSingleNode(".//cite[@itemprop='name']")?.InnerText.Trim(); + var episodeNumber = episode.SelectSingleNode(".//meta[contains(@itemprop, 'episodeNumber')]")?.GetAttributeValue("content", "?"); + + // Console.WriteLine($" Time: {episodeTime} (Has Passed: {hasPassed}), Episode: {episodeName}"); + // Console.WriteLine($" Season Link: {seasonLink}"); + // Console.WriteLine($" Episode Link: {episodeLink}"); + // Console.WriteLine($" Thumbnail URL: {thumbnailUrl}"); + + CalendarEpisode calEpisode = new CalendarEpisode(); + + calEpisode.DateTime = episodeTime; + calEpisode.HasPassed = hasPassed; + calEpisode.EpisodeName = episodeName; + calEpisode.SeasonUrl = seasonLink; + calEpisode.EpisodeUrl = episodeLink; + calEpisode.ThumbnailUrl = thumbnailUrl; + calEpisode.IsPremiumOnly = isPremiumOnly; + calEpisode.SeasonName = seasonName; + calEpisode.EpisodeNumber = episodeNumber; + + calDay.CalendarEpisodes.Add(calEpisode); + } + } + + week.CalendarDays.Add(calDay); + // Console.WriteLine(); + } + } else{ + Console.WriteLine("No days found in the HTML document."); + } + + calendar[weeksMondayDate] = week; + + + return week; + } + + public async void AddEpisodeToQue(string epId, string locale, List dubLang){ + await CrAuth.RefreshToken(true); + + var episodeL = await CrEpisode.ParseEpisodeById(epId, locale); + + + if (episodeL != null){ + if (episodeL.Value.Data != null && episodeL.Value.Data.First().IsPremiumOnly && Profile.Username == "???"){ + MessageBus.Current.SendMessage(new ToastMessage($"Episode is a premium episode - try to login first", ToastType.Error, 3)); + return; + } + + var sList = CrEpisode.EpisodeData((CrunchyEpisodeList)episodeL); + var selected = CrEpisode.EpisodeMeta(sList.Data, dubLang); + var metas = selected.Values.ToList(); + foreach (var crunchyEpMeta in metas){ + Queue.Add(crunchyEpMeta); + } + Console.WriteLine("Added Episode to Queue"); + MessageBus.Current.SendMessage(new ToastMessage($"Added episode to the queue", ToastType.Information, 1)); + } + } + + public void AddSeriesToQueue(CrunchySeriesList list, CrunchyMultiDownload data){ + var selected = CrSeries.ItemSelectMultiDub(list.Data, data.DubLang, data.But, data.AllEpisodes, data.E); + + foreach (var crunchyEpMeta in selected.Values.ToList()){ + Queue.Add(crunchyEpMeta); + } + } + + + public async Task DownloadEpisode(CrunchyEpMeta data, CrDownloadOptions options, bool? isSeries){ + ActiveDownloads++; + + data.DownloadProgress = new DownloadProgress(){ + IsDownloading = true, + Error = false, + Percent = 0, + Time = 0, + DownloadSpeed = 0, + Doing = "Starting" + }; + Queue.Refresh(); + var res = await DownloadMediaList(data, options); + + if (res.Error){ + ActiveDownloads--; + data.DownloadProgress = new DownloadProgress(){ + IsDownloading = false, + Error = true, + Percent = 100, + Time = 0, + DownloadSpeed = 0, + Doing = "Download Error" + }; + Queue.Refresh(); + return false; + } + + if (options.Skipmux == false){ + data.DownloadProgress = new DownloadProgress(){ + IsDownloading = true, + Percent = 100, + Time = 0, + DownloadSpeed = 0, + Doing = "Muxing" + }; + + Queue.Refresh(); + + await MuxStreams(res.Data, + new CrunchyMuxOptions{ + FfmpegOptions = options.FfmpegOptions, + SkipSubMux = options.Skipmux, + Output = res.FileName, + Mp4 = options.Mp4, + VideoTitle = options.VideoTitle, + Novids = options.Novids, + NoCleanup = options.Nocleanup, + DefaultAudio = options.DefaultAudio, + DefaultSub = options.DefaultSub, + MkvmergeOptions = options.MkvmergeOptions, + ForceMuxer = options.Force, + SyncTiming = options.SyncTiming, + CcTag = options.CcTag, + KeepAllVideos = false + }, + res.FileName); + + data.DownloadProgress = new DownloadProgress(){ + IsDownloading = true, + Done = true, + Percent = 100, + Time = 0, + DownloadSpeed = 0, + Doing = "Done" + }; + + Queue.Refresh(); + } else{ + Console.WriteLine("Skipping mux"); + } + + ActiveDownloads--; + + if (CrunOptions.History && data.Data != null && data.Data.Count > 0){ + CrHistory.SetAsDownloaded(data.ShowId, data.SeasonId, data.Data.First().MediaId); + } + + + return true; + } + + private async Task MuxStreams(List data, CrunchyMuxOptions options, string filename){ + var hasAudioStreams = false; + + var muxToMp3 = false; + + if (options.Novids == true || data.FindAll(a => a.Type == DownloadMediaType.Video).Count == 0){ + if (data.FindAll(a => a.Type == DownloadMediaType.Audio).Count > 0){ + Console.WriteLine("Mux to MP3"); + muxToMp3 = true; + } else{ + Console.WriteLine("Skip muxing since no videos are downloaded"); + return; + } + } + + if (data.Any(a => a.Type == DownloadMediaType.Audio)){ + hasAudioStreams = true; + } + + var subs = data.Where(a => a.Type == DownloadMediaType.Subtitle).ToList(); + var subsList = new List(); + + foreach (var downloadedMedia in subs){ + var subt = new SubtitleFonts(); + subt.Language = downloadedMedia.Language; + subt.Fonts = downloadedMedia.Fonts; + subsList.Add(subt); + } + + var merger = new Merger(new MergerOptions{ + OnlyVid = hasAudioStreams ? data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList() : new List(), + SkipSubMux = options.SkipSubMux, + OnlyAudio = hasAudioStreams ? data.Where(a => a.Type == DownloadMediaType.Audio).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList() : new List(), + 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, Signs = a.Signs }).ToList(), + Simul = false, + KeepAllVideos = options.KeepAllVideos, + Fonts = FontsManager.Instance.MakeFontsList(CfgManager.PathFONTS_DIR, subsList), // Assuming MakeFontsList is properly defined + VideoAndAudio = hasAudioStreams ? new List() : data.Where(a => a.Type == DownloadMediaType.Video).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(), + Chapters = data.Where(a => a.Type == DownloadMediaType.Chapters).Select(a => new MergerInput{ Language = a.Lang, Path = a.Path ?? string.Empty }).ToList(), + VideoTitle = options.VideoTitle, + Options = new MuxOptions(){ + ffmpeg = options.FfmpegOptions, + mkvmerge = options.MkvmergeOptions + }, + Defaults = new Defaults(){ + Audio = options.DefaultAudio, + Sub = options.DefaultSub + }, + CcTag = options.CcTag, + mp3 = muxToMp3 + }); + + if (!File.Exists(CfgManager.PathFFMPEG)){ + Console.WriteLine("FFmpeg not found"); + } + + if (!File.Exists(CfgManager.PathMKVMERGE)){ + Console.WriteLine("MKVmerge not found"); + } + + bool isMuxed; + + // if (options.SyncTiming){ + // await Merger.CreateDelays(); + // } + + if (!options.Mp4 && !muxToMp3){ + await merger.Merge("mkvmerge", CfgManager.PathMKVMERGE); + isMuxed = true; + } else{ + await merger.Merge("ffmpeg", CfgManager.PathFFMPEG); + isMuxed = true; + } + + if (isMuxed && options.NoCleanup == false){ + merger.CleanUp(); + } + } + + private async Task DownloadMediaList(CrunchyEpMeta data, CrDownloadOptions options){ + if (CmsToken?.Cms == null){ + Console.WriteLine("Missing CMS Token"); + MainWindow.ShowError("Missing CMS Token - are you signed in?"); + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + if (Profile.Username == "???"){ + MainWindow.ShowError("User Account not recognized - are you signed in?"); + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + if (!File.Exists(CfgManager.PathFFMPEG)){ + Console.Error.WriteLine("Missing ffmpeg"); + MainWindow.ShowError("FFmpeg not found"); + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + string mediaName = $"{data.SeasonTitle} - {data.EpisodeNumber} - {data.EpisodeTitle}"; + string fileName = ""; + var variables = new List(); + + List files = new List(); + + if (data.Data != null && data.Data.All(a => a.Playback == null)){ + Console.WriteLine("Video not available!"); + MainWindow.ShowError("No Video Data found"); + return new DownloadResponse{ + Data = files, + Error = true, + FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(CfgManager.PathVIDEOS_DIR, fileName)) : "./unknown" + }; + } + + bool dlFailed = false; + bool dlVideoOnce = false; + + if (data.Data != null) + foreach (CrunchyEpMetaData epMeta in data.Data){ + Console.WriteLine($"Requesting: [{epMeta.MediaId}] {mediaName}"); + + string currentMediaId = (epMeta.MediaId.Contains(':') ? epMeta.MediaId.Split(':')[1] : epMeta.MediaId); + + await CrAuth.RefreshToken(true); + + EpisodeVersion currentVersion = new EpisodeVersion(); + EpisodeVersion primaryVersion = new EpisodeVersion(); + bool isPrimary = epMeta.IsSubbed; + + //Get Media GUID + string mediaId = epMeta.MediaId; + string mediaGuid = currentMediaId; + if (epMeta.Versions != null){ + if (epMeta.Lang != null){ + currentVersion = epMeta.Versions.Find(a => a.AudioLocale == epMeta.Lang?.CrLocale); + } else if (options.DubLang.Count == 1){ + LanguageItem lang = Array.Find(Languages.languages, a => a.CrLocale == options.DubLang[0]); + currentVersion = epMeta.Versions.Find(a => a.AudioLocale == lang.CrLocale); + } else if (epMeta.Versions.Count == 1){ + currentVersion = epMeta.Versions[0]; + } + + if (currentVersion.MediaGuid == null){ + Console.WriteLine("Selected language not found in versions."); + MainWindow.ShowError("Selected language not found"); + continue; + } + + isPrimary = currentVersion.Original; + mediaId = currentVersion.MediaGuid; + mediaGuid = currentVersion.Guid; + + if (!isPrimary){ + primaryVersion = epMeta.Versions.Find(a => a.Original); + } else{ + primaryVersion = currentVersion; + } + } + + if (mediaId.Contains(':')){ + mediaId = mediaId.Split(':')[1]; + } + + if (mediaGuid.Contains(':')){ + mediaGuid = mediaGuid.Split(':')[1]; + } + + Console.WriteLine("MediaGuid: " + mediaId); + + #region Chapters + + List compiledChapters = new List(); + + if (options.Chapters){ + await ParseChapters(primaryVersion.Guid, compiledChapters); + } + + #endregion + + + var fetchPlaybackData = await FetchPlaybackData(mediaId, epMeta); + + if (!fetchPlaybackData.IsOk){ + MainWindow.ShowError("Couldn't get Playback Data"); + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + var pbData = fetchPlaybackData.pbData; + + + #region NonDrmRequest + + await FetchNoDrmPlaybackData(mediaGuid, pbData); + + #endregion + + + List hsLangs = new List(); + var pbStreams = pbData.Data?[0]; + var streams = new List(); + + variables.Add(new Variable("title", data.EpisodeTitle ?? string.Empty, true)); + variables.Add(new Variable("episode", (int.TryParse(data.EpisodeNumber, out int episodeNum) ? (object)episodeNum : data.AbsolutEpisodeNumberE) ?? string.Empty, false)); + variables.Add(new Variable("seriesTitle", data.SeriesTitle ?? string.Empty, true)); + variables.Add(new Variable("showTitle", data.SeasonTitle ?? string.Empty, true)); + variables.Add(new Variable("season", data.Season ?? 0, false)); + + if (pbStreams?.Keys != null){ + foreach (var key in pbStreams.Keys){ + if ((key.Contains("hls") || key.Contains("dash")) && + !(key.Contains("hls") && key.Contains("drm")) && + !((!_widevine.canDecrypt || !File.Exists(CfgManager.PathMP4Decrypt)) && key.Contains("drm")) && + !key.Contains("trailer")){ + var pb = pbStreams[key].Select(v => { + v.Value.HardsubLang = v.Value.HardsubLocale != null + ? Languages.FixAndFindCrLc(v.Value.HardsubLocale.GetEnumMemberValue()).Locale + : null; + if (v.Value.HardsubLocale != null && v.Value.HardsubLang != null && !hsLangs.Contains(v.Value.HardsubLocale.GetEnumMemberValue())){ + hsLangs.Add(v.Value.HardsubLang); + } + + return new StreamDetailsPop{ + Url = v.Value.Url, + HardsubLocale = v.Value.HardsubLocale, + HardsubLang = v.Value.HardsubLang, + AudioLang = v.Value.AudioLang, + Type = v.Value.Type, + Format = key, + }; + }).ToList(); + + streams.AddRange(pb); + } + } + + if (streams.Count < 1){ + Console.WriteLine("No full streams found!"); + MainWindow.ShowError("No streams found"); + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + var audDub = ""; + if (pbData.Meta != null){ + audDub = Languages.FindLang(Languages.FixLanguageTag((pbData.Meta.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue())).Code; + } + + hsLangs = Languages.SortTags(hsLangs); + + streams = streams.Select(s => { + s.AudioLang = audDub; + s.HardsubLang = string.IsNullOrEmpty(s.HardsubLang) ? "-" : s.HardsubLang; + s.Type = $"{s.Format}/{s.AudioLang}/{s.HardsubLang}"; + return s; + }).ToList(); + + streams.Sort((a, b) => String.CompareOrdinal(a.Type, b.Type)); + + if (options.Hslang != "none"){ + if (hsLangs.IndexOf(options.Hslang) > -1){ + Console.WriteLine($"Selecting stream with {Languages.Locale2language(options.Hslang).Language} hardsubs"); + streams = streams.Where((s) => s.HardsubLang != "-" && s.HardsubLang == options.Hslang).ToList(); + } else{ + Console.WriteLine($"Selected stream with {Languages.Locale2language(options.Hslang).Language} hardsubs not available"); + if (hsLangs.Count > 0){ + Console.WriteLine("Try hardsubs stream: " + string.Join(", ", hsLangs)); + } + + dlFailed = true; + } + } else{ + streams = streams.Where((s) => { + if (s.HardsubLang != "-"){ + return false; + } + + return true; + }).ToList(); + + if (streams.Count < 1){ + Console.WriteLine("Raw streams not available!"); + if (hsLangs.Count > 0){ + Console.WriteLine("Try hardsubs stream: " + string.Join(", ", hsLangs)); + } + + dlFailed = true; + } + + Console.WriteLine("Selecting raw stream"); + } + + StreamDetailsPop? curStream = null; + if (!dlFailed){ + // Validate or adjust options.kstream + options.Kstream = options.Kstream >= 1 && options.Kstream <= streams.Count + ? options.Kstream + : 1; + + for (int i = 0; i < streams.Count; i++){ + string isSelected = options.Kstream == i + 1 ? "✓" : " "; + Console.WriteLine($"Full stream found! ({isSelected}{i + 1}: {streams[i].Type})"); + } + + Console.WriteLine("Downloading video..."); + curStream = streams[options.Kstream - 1]; + + Console.WriteLine($"Playlists URL: {curStream.Url} ({curStream.Type})"); + } + + string tsFile = ""; + + if (!dlFailed && curStream != null && options is not{ Novids: true, Noaudio: true }){ + var streamPlaylistsReq = HttpClientReq.CreateRequestMessage(curStream.Url ?? string.Empty, HttpMethod.Get, true, true, null); + + var streamPlaylistsReqResponse = await HttpClientReq.Instance.SendHttpRequest(streamPlaylistsReq); + + if (!streamPlaylistsReqResponse.IsOk){ + dlFailed = true; + } + + if (dlFailed){ + Console.WriteLine($"CAN\'T FETCH VIDEO PLAYLISTS!"); + } else{ + if (streamPlaylistsReqResponse.ResponseContent.Contains("MPD")){ + var match = Regex.Match(curStream.Url ?? string.Empty, @"(.*\.urlset\/)"); + var matchedUrl = match.Success ? match.Value : null; + //Parse MPD Playlists + var crLocal = ""; + if (pbData.Meta != null){ + crLocal = Languages.FixLanguageTag((pbData.Meta.AudioLocale ?? Locale.DefaulT).GetEnumMemberValue()); + } + + MPDParsed streamPlaylists = MPDParser.Parse(streamPlaylistsReqResponse.ResponseContent, Languages.FindLang(crLocal), matchedUrl); + + List streamServers = new List(streamPlaylists.Data.Keys); + options.X = options.X > streamServers.Count ? 1 : options.X; + + if (streamServers.Count == 0){ + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + if (options.X == 0){ + options.X = 1; + } + + string selectedServer = streamServers[options.X - 1]; + ServerData selectedList = streamPlaylists.Data[selectedServer]; + + var videos = selectedList.video.Select(item => new VideoItem{ + segments = item.segments, + pssh = item.pssh, + quality = item.quality, + bandwidth = item.bandwidth, + resolutionText = $"{item.quality.width}x{item.quality.height} ({Math.Round(item.bandwidth / 1024.0)}KiB/s)" + }).ToList(); + + var audios = selectedList.audio.Select(item => new AudioItem{ + @default = item.@default, + segments = item.segments, + pssh = item.pssh, + language = item.language, + bandwidth = item.bandwidth, + resolutionText = $"{Math.Round(item.bandwidth / 1000.0)}kB/s" + }).ToList(); + + videos.Sort((a, b) => a.quality.width.CompareTo(b.quality.width)); + audios.Sort((a, b) => a.bandwidth.CompareTo(b.bandwidth)); + + int chosenVideoQuality; + if (options.QualityVideo == "best"){ + chosenVideoQuality = videos.Count; + } else if (options.QualityVideo == "worst"){ + chosenVideoQuality = 1; + } else{ + var tempIndex = videos.FindIndex(a => a.quality.height + "" == options.QualityAudio); + if (tempIndex < 0){ + chosenVideoQuality = videos.Count; + } else{ + tempIndex++; + chosenVideoQuality = tempIndex; + } + } + + if (chosenVideoQuality > videos.Count){ + Console.WriteLine($"The requested quality of {chosenVideoQuality} is greater than the maximum {videos.Count}.\n[WARN] Therefore, the maximum will be capped at {videos.Count}."); + chosenVideoQuality = videos.Count; + } + + chosenVideoQuality--; + + int chosenAudioQuality; + if (options.QualityAudio == "best"){ + chosenAudioQuality = audios.Count; + } else if (options.QualityAudio == "worst"){ + chosenAudioQuality = 1; + } else{ + var tempIndex = audios.FindIndex(a => a.resolutionText == options.QualityAudio); + if (tempIndex < 0){ + chosenAudioQuality = audios.Count; + } else{ + tempIndex++; + chosenAudioQuality = tempIndex; + } + } + + + if (chosenAudioQuality > audios.Count){ + chosenAudioQuality = audios.Count; + } + + chosenAudioQuality--; + + VideoItem chosenVideoSegments = videos[chosenVideoQuality]; + AudioItem chosenAudioSegments = audios[chosenAudioQuality]; + + Console.WriteLine("Servers available:"); + foreach (var server in streamServers){ + Console.WriteLine($"\t{server}"); + } + + Console.WriteLine("Available Video Qualities:"); + for (int i = 0; i < videos.Count; i++){ + Console.WriteLine($"\t[{i + 1}] {videos[i].resolutionText}"); + } + + Console.WriteLine("Available Audio Qualities:"); + for (int i = 0; i < audios.Count; i++){ + Console.WriteLine($"\t[{i + 1}] {audios[i].resolutionText}"); + } + + variables.Add(new Variable("height", chosenVideoSegments.quality.height, false)); + variables.Add(new Variable("width", chosenVideoSegments.quality.width, false)); + + LanguageItem? lang = Languages.languages.FirstOrDefault(a => a.Code == curStream.AudioLang); + if (lang == null){ + Console.Error.WriteLine($"Unable to find language for code {curStream.AudioLang}"); + MainWindow.ShowError($"Unable to find language for code {curStream.AudioLang}"); + return new DownloadResponse{ + Data = new List(), + Error = true, + FileName = "./unknown" + }; + } + + Console.WriteLine($"Selected quality: \n\tVideo: {chosenVideoSegments.resolutionText}\n\tAudio: {chosenAudioSegments.resolutionText}\n\tServer: {selectedServer}"); + 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.Override).ToArray()); + string outFile = Path.Combine(FileNameManager.ParseFileName(options.FileName + "." + (epMeta.Lang?.Name ?? lang.Value.Name), variables, options.Numbers, options.Override).ToArray()); + + string tempFile = Path.Combine(FileNameManager.ParseFileName($"temp-{(currentVersion.Guid != null ? currentVersion.Guid : currentMediaId)}", variables, options.Numbers, options.Override) + .ToArray()); + string tempTsFile = Path.IsPathRooted(tempFile) ? tempFile : Path.Combine(CfgManager.PathVIDEOS_DIR, tempFile); + + bool audioDownloaded = false, videoDownloaded = false; + + if (options.DlVideoOnce && dlVideoOnce){ + Console.WriteLine("Already downloaded video, skipping video download..."); + return new DownloadResponse{ + Data = files, + Error = dlFailed, + FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(CfgManager.PathVIDEOS_DIR, fileName)) : "./unknown" + }; + } + + if (options.Novids){ + Console.WriteLine("Skipping video download..."); + } else{ + var videoDownloadResult = await DownloadVideo(chosenVideoSegments, options, outFile, tsFile, tempTsFile, data); + + tsFile = videoDownloadResult.tsFile; + + if (!videoDownloadResult.Ok){ + Console.Error.WriteLine($"DL Stats: {JsonConvert.SerializeObject(videoDownloadResult.Parts)}"); + dlFailed = true; + } + + dlVideoOnce = true; + videoDownloaded = true; + } + + + if (chosenAudioSegments.segments.Count > 0 && !options.Noaudio && !dlFailed){ + var audioDownloadResult = await DownloadAudio(chosenAudioSegments, options, outFile, tsFile, tempTsFile, data); + + tsFile = audioDownloadResult.tsFile; + + if (!audioDownloadResult.Ok){ + Console.Error.WriteLine($"DL Stats: {JsonConvert.SerializeObject(audioDownloadResult.Parts)}"); + dlFailed = true; + } + + audioDownloaded = true; + } else if (options.Noaudio){ + Console.WriteLine("Skipping audio download..."); + } + + if (dlFailed){ + return new DownloadResponse{ + Data = files, + Error = dlFailed, + FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(CfgManager.PathVIDEOS_DIR, fileName)) : "./unknown" + }; + } + + if ((chosenVideoSegments.pssh != null || chosenAudioSegments.pssh != null) && (videoDownloaded || audioDownloaded)){ + data.DownloadProgress = new DownloadProgress(){ + IsDownloading = true, + Percent = 100, + Time = 0, + DownloadSpeed = 0, + Doing = "Decrypting" + }; + + var assetIdRegexMatch = Regex.Match(chosenVideoSegments.segments[0].uri, @"/assets/(?:p/)?([^_,]+)"); + var assetId = assetIdRegexMatch.Success ? assetIdRegexMatch.Groups[1].Value : null; + var sessionId = Helpers.GenerateSessionId(); + + Console.WriteLine("Decryption Needed, attempting to decrypt"); + + var reqBodyData = new{ + accounting_id = "crunchyroll", + asset_id = assetId, + session_id = sessionId, + user_id = Token?.account_id + }; + + var json = JsonConvert.SerializeObject(reqBodyData); + var reqBody = new StringContent(json, Encoding.UTF8, "application/json"); + + var decRequest = HttpClientReq.CreateRequestMessage("https://pl.crunchyroll.com/drm/v1/auth", HttpMethod.Post, false, false, null); + decRequest.Content = reqBody; + + var decRequestResponse = await HttpClientReq.Instance.SendHttpRequest(decRequest); + + if (!decRequestResponse.IsOk){ + Console.WriteLine("Request to DRM Authentication failed: "); + return new DownloadResponse{ + Data = files, + Error = dlFailed, + FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(CfgManager.PathVIDEOS_DIR, fileName)) : "./unknown" + }; + } + + DrmAuthData authData = Helpers.Deserialize(decRequestResponse.ResponseContent, SettingsJsonSerializerSettings) ?? new DrmAuthData(); + + + Dictionary authDataDict = new Dictionary + { { "dt-custom-data", authData.CustomData ?? string.Empty },{ "x-dt-auth-token", authData.Token ?? string.Empty } }; + + var encryptionKeys = await _widevine.getKeys(chosenVideoSegments.pssh, "https://lic.drmtoday.com/license-proxy-widevine/cenc/", authDataDict); + + if (encryptionKeys.Count == 0){ + Console.WriteLine("Failed to get encryption keys"); + return new DownloadResponse{ + Data = files, + Error = dlFailed, + FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(CfgManager.PathVIDEOS_DIR, fileName)) : "./unknown" + }; + } + + if (Path.Exists(CfgManager.PathMP4Decrypt)){ + var keyId = BitConverter.ToString(encryptionKeys[0].KeyID).Replace("-", "").ToLower(); + var key = BitConverter.ToString(encryptionKeys[0].Bytes).Replace("-", "").ToLower(); + var commandBase = $"--show-progress --key {keyId}:{key}"; + var commandVideo = commandBase + $" \"{tempTsFile}.video.enc.m4s\" \"{tempTsFile}.video.m4s\""; + var commandAudio = commandBase + $" \"{tempTsFile}.audio.enc.m4s\" \"{tempTsFile}.audio.m4s\""; + + if (videoDownloaded){ + Console.WriteLine("Started decrypting video"); + var decryptVideo = await Helpers.ExecuteCommandAsync("mp4decrypt", CfgManager.PathMP4Decrypt, commandVideo); + + if (!decryptVideo.IsOk){ + Console.Error.WriteLine($"Decryption failed with exit code {decryptVideo.ErrorCode}"); + try{ + File.Move($"{tempTsFile}.video.enc.m4s", $"{tsFile}.video.enc.m4s"); + } catch (IOException ex){ + Console.WriteLine($"An error occurred: {ex.Message}"); + } + } else{ + Console.WriteLine("Decryption done for video"); + if (!options.Nocleanup){ + try{ + if (File.Exists($"{tempTsFile}.video.enc.m4s")){ + File.Delete($"{tempTsFile}.video.enc.m4s"); + } + + if (File.Exists($"{tempTsFile}.video.enc.m4s.resume")){ + File.Delete($"{tempTsFile}.video.enc.m4s.resume"); + } + } catch (Exception ex){ + Console.WriteLine($"Failed to delete file {tempTsFile}.video.enc.m4s. Error: {ex.Message}"); + // Handle exceptions if you need to log them or throw + } + + try{ + File.Move($"{tempTsFile}.video.m4s", $"{tsFile}.video.m4s"); + } catch (IOException ex){ + Console.WriteLine($"An error occurred: {ex.Message}"); + } + + files.Add(new DownloadedMedia{ + Type = DownloadMediaType.Video, + Path = $"{tsFile}.video.m4s", + Lang = lang.Value, + IsPrimary = isPrimary + }); + } + } + } + + if (audioDownloaded){ + Console.WriteLine("Started decrypting audio"); + var decryptAudio = await Helpers.ExecuteCommandAsync("mp4decrypt", CfgManager.PathMP4Decrypt, commandAudio); + + if (!decryptAudio.IsOk){ + Console.Error.WriteLine($"Decryption failed with exit code {decryptAudio.ErrorCode}"); + try{ + File.Move($"{tempTsFile}.audio.enc.m4s", $"{tsFile}.audio.enc.m4s"); + } catch (IOException ex){ + Console.WriteLine($"An error occurred: {ex.Message}"); + } + } else{ + Console.WriteLine("Decryption done for audio"); + if (!options.Nocleanup){ + try{ + if (File.Exists($"{tempTsFile}.audio.enc.m4s")){ + File.Delete($"{tempTsFile}.audio.enc.m4s"); + } + + if (File.Exists($"{tempTsFile}.audio.enc.m4s.resume")){ + File.Delete($"{tempTsFile}.audio.enc.m4s.resume"); + } + } catch (Exception ex){ + Console.WriteLine($"Failed to delete file {tempTsFile}.audio.enc.m4s. Error: {ex.Message}"); + // Handle exceptions if you need to log them or throw + } + + try{ + File.Move($"{tempTsFile}.audio.m4s", $"{tsFile}.audio.m4s"); + } catch (IOException ex){ + Console.WriteLine($"An error occurred: {ex.Message}"); + } + + files.Add(new DownloadedMedia{ + Type = DownloadMediaType.Audio, + Path = $"{tsFile}.audio.m4s", + Lang = lang.Value, + IsPrimary = isPrimary + }); + } + } + } + } else{ + Console.WriteLine("mp4decrypt not found, files need decryption. Decryption Keys: "); + MainWindow.ShowError($"mp4decrypt not found, files need decryption"); + } + } else{ + if (videoDownloaded){ + files.Add(new DownloadedMedia{ + Type = DownloadMediaType.Video, + Path = $"{tsFile}.video.m4s", + Lang = lang.Value, + IsPrimary = isPrimary + }); + } + + if (audioDownloaded){ + files.Add(new DownloadedMedia{ + Type = DownloadMediaType.Audio, + Path = $"{tsFile}.audio.m4s", + Lang = lang.Value, + IsPrimary = isPrimary + }); + } + } + } else if (!options.Novids){ + //TODO + } else if (options.Novids){ + fileName = Path.Combine(FileNameManager.ParseFileName(options.FileName, variables, options.Numbers, options.Override).ToArray()); + Console.WriteLine("Downloading skipped!"); + } + } + } else if (options.Novids && options.Noaudio){ + fileName = Path.Combine(FileNameManager.ParseFileName(options.FileName, variables, options.Numbers, options.Override).ToArray()); + } + + if (compiledChapters.Count > 0){ + try{ + // Parsing and constructing the file names + fileName = Path.Combine(FileNameManager.ParseFileName(options.FileName, variables, options.Numbers, options.Override).ToArray()); + string outFile = Path.Combine(FileNameManager.ParseFileName(options.FileName + "." + (epMeta.Lang?.Name), variables, options.Numbers, options.Override).ToArray()); + if (Path.IsPathRooted(outFile)){ + tsFile = outFile; + } else{ + tsFile = Path.Combine(CfgManager.PathVIDEOS_DIR, outFile); + } + + // Check if the path is absolute + bool isAbsolute = Path.IsPathRooted(outFile); + + // Get all directory parts of the path except the last segment (assuming it's a file) + string[] directories = Path.GetDirectoryName(outFile)?.Split(Path.DirectorySeparatorChar) ?? Array.Empty(); + + // Initialize the cumulative path based on whether the original path is absolute or not + string cumulativePath = isAbsolute ? "" : CfgManager.PathVIDEOS_DIR; + for (int i = 0; i < directories.Length; i++){ + // Build the path incrementally + cumulativePath = Path.Combine(cumulativePath, directories[i]); + + // Check if the directory exists and create it if it does not + if (!Directory.Exists(cumulativePath)){ + Directory.CreateDirectory(cumulativePath); + Console.WriteLine($"Created directory: {cumulativePath}"); + } + } + + // Finding language by code + var lang = Languages.languages.FirstOrDefault(l => l.Code == curStream?.AudioLang); + if (lang.Code == "und"){ + Console.Error.WriteLine($"Unable to find language for code {curStream?.AudioLang}"); + } + + File.WriteAllText($"{tsFile}.txt", string.Join("\r\n", compiledChapters)); + + files.Add(new DownloadedMedia{ Path = $"{tsFile}.txt", Lang = lang, Type = DownloadMediaType.Chapters }); + } catch{ + Console.Error.WriteLine("Failed to write chapter file"); + } + } + + if (options.DlSubs.IndexOf("all") > -1){ + options.DlSubs = new List{ "all" }; + } + + if (options.Hslang != "none"){ + Console.WriteLine("Subtitles downloading disabled for hardsubed streams."); + options.SkipSubs = true; + } + + if (options.NoSubs){ + Console.WriteLine("Subtitles downloading disabled from nosubs flag."); + options.SkipSubs = true; + } + + if (!options.SkipSubs && options.DlSubs.IndexOf("none") == -1){ + await DownloadSubtitles(options, pbData, audDub, fileName, files); + } else{ + Console.WriteLine("Subtitles downloading skipped!"); + } + } + + await Task.Delay(options.Waittime); + } + + + // variables.Add(new Variable("height", quality == 0 ? plQuality.Last().RESOLUTION.Height : plQuality[quality - 1].RESOLUTION.Height, false)); + // variables.Add(new Variable("width", quality == 0 ? plQuality.Last().RESOLUTION.Width : plQuality[quality - 1].RESOLUTION.Width, false)); + + return new DownloadResponse{ + Data = files, + Error = dlFailed, + FileName = fileName.Length > 0 ? (Path.IsPathRooted(fileName) ? fileName : Path.Combine(CfgManager.PathVIDEOS_DIR, fileName)) : "./unknown" + }; + } + + private static async Task DownloadSubtitles(CrDownloadOptions options, PlaybackData pbData, string audDub, string fileName, List files){ + if (pbData.Meta != null && pbData.Meta.Subtitles != null && pbData.Meta.Subtitles.Count > 0){ + List subsData = pbData.Meta.Subtitles.Values.ToList(); + List capsData = pbData.Meta.ClosedCaptions?.Values.ToList() ?? new List(); + var subsDataMapped = subsData.Select(s => { + var subLang = Languages.FixAndFindCrLc((s.Locale ?? Locale.DefaulT).GetEnumMemberValue()); + return new{ + format = s.Format, + url = s.Url, + locale = subLang, + language = subLang.Locale, + isCC = false + }; + }).ToList(); + + var capsDataMapped = capsData.Select(s => { + var subLang = Languages.FixAndFindCrLc((s.Locale ?? Locale.DefaulT).GetEnumMemberValue()); + return new{ + format = s.Format, + url = s.Url, + locale = subLang, + language = subLang.Locale, + isCC = true + }; + }).ToList(); + + subsDataMapped.AddRange(capsDataMapped); + + var subsArr = Languages.SortSubtitles(subsDataMapped, "language"); + + foreach (var subsItem in subsArr){ + var index = subsArr.IndexOf(subsItem); + var langItem = subsItem.locale; + var sxData = new SxItem(); + sxData.Language = langItem; + var isSigns = langItem.Code == audDub && !subsItem.isCC; + var isCc = subsItem.isCC; + sxData.File = Languages.SubsFile(fileName, index + "", langItem, isCc, options.CcTag, isSigns, subsItem.format); + sxData.Path = Path.Combine(CfgManager.PathVIDEOS_DIR, sxData.File); + + // Check if the path is absolute + bool isAbsolute = Path.IsPathRooted(sxData.Path); + + // Get all directory parts of the path except the last segment (assuming it's a file) + string[] directories = Path.GetDirectoryName(sxData.Path)?.Split(Path.DirectorySeparatorChar) ?? Array.Empty(); + + // Initialize the cumulative path based on whether the original path is absolute or not + string cumulativePath = isAbsolute ? "" : CfgManager.PathVIDEOS_DIR; + for (int i = 0; i < directories.Length; i++){ + // Build the path incrementally + cumulativePath = Path.Combine(cumulativePath, directories[i]); + + // Check if the directory exists and create it if it does not + if (!Directory.Exists(cumulativePath)){ + Directory.CreateDirectory(cumulativePath); + Console.WriteLine($"Created directory: {cumulativePath}"); + } + } + + // Check if any file matches the specified conditions + if (files.Any(a => a.Type == DownloadMediaType.Subtitle && + (a.Language.CrLocale == langItem.CrLocale || a.Language.Locale == langItem.Locale) && + a.Cc == isCc && + a.Signs == isSigns)){ + continue; + } + + if (options.DlSubs.Contains("all") || options.DlSubs.Contains(langItem.CrLocale)){ + var subsAssReq = HttpClientReq.CreateRequestMessage(subsItem.url ?? string.Empty, HttpMethod.Get, false, false, null); + + var subsAssReqResponse = await HttpClientReq.Instance.SendHttpRequest(subsAssReq); + + if (subsAssReqResponse.IsOk){ + if (subsItem.format == "ass"){ + subsAssReqResponse.ResponseContent = '\ufeff' + subsAssReqResponse.ResponseContent; + var sBodySplit = subsAssReqResponse.ResponseContent.Split(new[]{ "\r\n" }, StringSplitOptions.None).ToList(); + // Insert 'ScaledBorderAndShadow: yes' after the second line + if (sBodySplit.Count > 2) + sBodySplit.Insert(2, "ScaledBorderAndShadow: yes"); + + // Rejoin the lines back into a single string + subsAssReqResponse.ResponseContent = string.Join("\r\n", sBodySplit); + + // Extract the title from the second line and remove 'Title: ' prefix + 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); + } + } + + File.WriteAllText(sxData.Path, subsAssReqResponse.ResponseContent); + Console.WriteLine($"Subtitle downloaded: ${sxData.File}"); + files.Add(new DownloadedMedia{ + Type = DownloadMediaType.Subtitle, + Cc = isCc, + Signs = isSigns, + Path = sxData.Path, + File = sxData.File, + Title = sxData.Title, + Fonts = sxData.Fonts, + Language = sxData.Language, + Lang = sxData.Language + }); + } else{ + Console.WriteLine($"Failed to download subtitle: ${sxData.File}"); + } + } + } + } else{ + Console.WriteLine("Can\'t find urls for subtitles!"); + } + } + + private async Task<(bool Ok, PartsData Parts, string tsFile)> DownloadVideo(VideoItem chosenVideoSegments, CrDownloadOptions options, string outFile, string tsFile, string tempTsFile, CrunchyEpMeta data){ + // Prepare for video download + int totalParts = chosenVideoSegments.segments.Count; + int mathParts = (int)Math.Ceiling((double)totalParts / options.Partsize); + string mathMsg = $"({mathParts}*{options.Partsize})"; + Console.WriteLine($"Total parts in video stream: {totalParts} {mathMsg}"); + + if (Path.IsPathRooted(outFile)){ + tsFile = outFile; + } else{ + tsFile = Path.Combine(CfgManager.PathVIDEOS_DIR, outFile); + } + + // var split = outFile.Split(Path.DirectorySeparatorChar).AsSpan().Slice(0, -1).ToArray(); + // Check if the path is absolute + bool isAbsolute = Path.IsPathRooted(outFile); + + // Get all directory parts of the path except the last segment (assuming it's a file) + string[] directories = Path.GetDirectoryName(outFile)?.Split(Path.DirectorySeparatorChar) ?? Array.Empty(); + + // Initialize the cumulative path based on whether the original path is absolute or not + string cumulativePath = isAbsolute ? "" : CfgManager.PathVIDEOS_DIR; + for (int i = 0; i < directories.Length; i++){ + // Build the path incrementally + cumulativePath = Path.Combine(cumulativePath, directories[i]); + + // Check if the directory exists and create it if it does not + if (!Directory.Exists(cumulativePath)){ + Directory.CreateDirectory(cumulativePath); + Console.WriteLine($"Created directory: {cumulativePath}"); + } + } + + M3U8Json videoJson = new M3U8Json{ + Segments = chosenVideoSegments.segments.Cast().ToList() + }; + + var videoDownloader = new HlsDownloader(new HlsOptions{ + Output = chosenVideoSegments.pssh != null ? $"{tempTsFile}.video.enc.m4s" : $"{tsFile}.video.m4s", + Timeout = options.Timeout, + M3U8Json = videoJson, + // BaseUrl = chunkPlaylist.BaseUrl, + Threads = options.Partsize, + FsRetryTime = options.FsRetryTime * 1000, + Override = options.Force, + }, data, true, false); + + var videoDownloadResult = await videoDownloader.Download(); + + return (videoDownloadResult.Ok, videoDownloadResult.Parts, tsFile); + } + + private async Task<(bool Ok, PartsData Parts, string tsFile)> DownloadAudio(AudioItem chosenAudioSegments, CrDownloadOptions options, string outFile, string tsFile, string tempTsFile, CrunchyEpMeta data){ + // Prepare for audio download + int totalParts = chosenAudioSegments.segments.Count; + int mathParts = (int)Math.Ceiling((double)totalParts / options.Partsize); + string mathMsg = $"({mathParts}*{options.Partsize})"; + Console.WriteLine($"Total parts in audio stream: {totalParts} {mathMsg}"); + + if (Path.IsPathRooted(outFile)){ + tsFile = outFile; + } else{ + tsFile = Path.Combine(CfgManager.PathVIDEOS_DIR, outFile); + } + + // Check if the path is absolute + bool isAbsolute = Path.IsPathRooted(outFile); + + // Get all directory parts of the path except the last segment (assuming it's a file) + string[] directories = Path.GetDirectoryName(outFile)?.Split(Path.DirectorySeparatorChar) ?? Array.Empty(); + + // Initialize the cumulative path based on whether the original path is absolute or not + string cumulativePath = isAbsolute ? "" : CfgManager.PathVIDEOS_DIR; + for (int i = 0; i < directories.Length; i++){ + // Build the path incrementally + cumulativePath = Path.Combine(cumulativePath, directories[i]); + + // Check if the directory exists and create it if it does not + if (!Directory.Exists(cumulativePath)){ + Directory.CreateDirectory(cumulativePath); + Console.WriteLine($"Created directory: {cumulativePath}"); + } + } + + M3U8Json audioJson = new M3U8Json{ + Segments = chosenAudioSegments.segments.Cast().ToList() + }; + + var audioDownloader = new HlsDownloader(new HlsOptions{ + Output = chosenAudioSegments.pssh != null ? $"{tempTsFile}.audio.enc.m4s" : $"{tsFile}.audio.m4s", + Timeout = options.Timeout, + M3U8Json = audioJson, + // BaseUrl = chunkPlaylist.BaseUrl, + Threads = options.Partsize, + FsRetryTime = options.FsRetryTime * 1000, + Override = options.Force, + }, data, false, true); + + var audioDownloadResult = await audioDownloader.Download(); + + + return (audioDownloadResult.Ok, audioDownloadResult.Parts, tsFile); + } + + private async Task FetchNoDrmPlaybackData(string currentMediaId, PlaybackData pbData){ + var playbackRequestNonDrm = HttpClientReq.CreateRequestMessage($"https://cr-play-service.prd.crunchyrollsvc.com/v1/{currentMediaId}/console/switch/play", HttpMethod.Get, true, true, null); + + var playbackRequestNonDrmResponse = await HttpClientReq.Instance.SendHttpRequest(playbackRequestNonDrm); + + if (playbackRequestNonDrmResponse.IsOk && playbackRequestNonDrmResponse.ResponseContent != string.Empty){ + CrunchyNoDrmStream? playStream = JsonConvert.DeserializeObject(playbackRequestNonDrmResponse.ResponseContent, SettingsJsonSerializerSettings); + CrunchyStreams derivedPlayCrunchyStreams = new CrunchyStreams(); + if (playStream != null){ + if (playStream.HardSubs != null) + foreach (var hardsub in playStream.HardSubs){ + var stream = hardsub.Value; + derivedPlayCrunchyStreams[hardsub.Key] = new StreamDetails{ + Url = stream.Url, + HardsubLocale = Helpers.ConvertStringToLocale(stream.Hlang) + }; + } + + derivedPlayCrunchyStreams[""] = new StreamDetails{ + Url = playStream.Url, + HardsubLocale = Locale.DefaulT + }; + + if (pbData.Data != null) pbData.Data[0]["adaptive_switch_dash"] = derivedPlayCrunchyStreams; + } + } else{ + Console.WriteLine("Non-DRM Request Stream URLs FAILED!"); + } + } + + private async Task<(bool IsOk, PlaybackData pbData)> FetchPlaybackData(string mediaId, CrunchyEpMetaData epMeta){ + PlaybackData temppbData = new PlaybackData{ Total = 0, Data = new List>>() }; + bool ok = true; + + HttpRequestMessage playbackRequest; + (bool IsOk, string ResponseContent) playbackRequestResponse; + + if (_api == "android"){ + NameValueCollection query = HttpUtility.ParseQueryString(new UriBuilder().Query); + + query["force_locale"] = ""; + query["preferred_audio_language"] = "ja-JP"; + query["Policy"] = CmsToken?.Cms.Policy; + query["Signature"] = CmsToken?.Cms.Signature; + query["Key-Pair-Id"] = CmsToken?.Cms.KeyPairId; + + + playbackRequest = HttpClientReq.CreateRequestMessage($"{Api.BetaCms}{CmsToken?.Cms.Bucket}/videos/{mediaId}/streams?", HttpMethod.Get, true, true, query); + + playbackRequestResponse = await HttpClientReq.Instance.SendHttpRequest(playbackRequest); + + if (playbackRequestResponse.IsOk){ + var androidTempData = Helpers.Deserialize(playbackRequestResponse.ResponseContent, SettingsJsonSerializerSettings); + temppbData = new PlaybackData(){ + Data = androidTempData.streams, Total = androidTempData.streams.Count, + Meta = new PlaybackMeta(){ + MediaId = androidTempData.media_id, Subtitles = androidTempData.subtitles, Bifs = androidTempData.bifs, Versions = androidTempData.versions, AudioLocale = androidTempData.audio_locale, + ClosedCaptions = androidTempData.closed_captions, Captions = androidTempData.captions + } + }; + } else{ + NameValueCollection query2 = HttpUtility.ParseQueryString(new UriBuilder().Query); + + query2["preferred_audio_language"] = "ja-JP"; + query2["Policy"] = CmsToken?.Cms.Policy; + query2["Signature"] = CmsToken?.Cms.Signature; + query2["Key-Pair-Id"] = CmsToken?.Cms.KeyPairId; + + playbackRequest = HttpClientReq.CreateRequestMessage($"{Api.ApiBeta}{epMeta.Playback}?", HttpMethod.Get, true, true, query2); + + playbackRequestResponse = await HttpClientReq.Instance.SendHttpRequest(playbackRequest); + + if (playbackRequestResponse.IsOk){ + temppbData = Helpers.Deserialize(playbackRequestResponse.ResponseContent, SettingsJsonSerializerSettings) ?? + new PlaybackData{ Total = 0, Data = new List>>() }; + } else{ + Console.WriteLine("'Fallback Request Stream URLs FAILED!'"); + ok = playbackRequestResponse.IsOk; + } + } + } else{ + playbackRequest = HttpClientReq.CreateRequestMessage($"{Api.Cms}/videos/{mediaId}/streams", HttpMethod.Get, true, false, null); + + playbackRequestResponse = await HttpClientReq.Instance.SendHttpRequest(playbackRequest); + + if (playbackRequestResponse.IsOk){ + temppbData = Helpers.Deserialize(playbackRequestResponse.ResponseContent, SettingsJsonSerializerSettings) ?? + new PlaybackData{ Total = 0, Data = new List>>() }; + } else{ + Console.WriteLine("Request Stream URLs FAILED! Attempting fallback"); + + playbackRequest = HttpClientReq.CreateRequestMessage($"{Api.ApiBeta}{epMeta.Playback}", HttpMethod.Get, true, true, null); + + playbackRequestResponse = await HttpClientReq.Instance.SendHttpRequest(playbackRequest); + + if (playbackRequestResponse.IsOk){ + temppbData = Helpers.Deserialize(playbackRequestResponse.ResponseContent, SettingsJsonSerializerSettings) ?? + new PlaybackData{ Total = 0, Data = new List>>() }; + } else{ + Console.WriteLine("'Fallback Request Stream URLs FAILED!'"); + ok = playbackRequestResponse.IsOk; + } + } + } + + return (IsOk: ok, pbData: temppbData); + } + + private async Task ParseChapters(string currentMediaId, List compiledChapters){ + var showRequest = HttpClientReq.CreateRequestMessage($"https://static.crunchyroll.com/skip-events/production/{currentMediaId}.json", HttpMethod.Get, true, true, null); + + var showRequestResponse = await HttpClientReq.Instance.SendHttpRequest(showRequest); + + if (showRequestResponse.IsOk){ + JObject jObject = JObject.Parse(showRequestResponse.ResponseContent); + + CrunchyChapters chapterData = new CrunchyChapters(); + chapterData.lastUpdate = jObject["lastUpdate"]?.ToObject(); + chapterData.mediaId = jObject["mediaId"]?.ToObject(); + chapterData.Chapters = new List(); + + foreach (var property in jObject.Properties()){ + // Check if the property value is an object and the property is not one of the known non-dictionary properties + if (property.Value.Type == JTokenType.Object && property.Name != "lastUpdate" && property.Name != "mediaId"){ + // Deserialize the property value into a CrunchyChapter and add it to the dictionary + CrunchyChapter chapter = property.Value.ToObject(); + chapterData.Chapters.Add(chapter); + } + } + + if (chapterData.Chapters.Count > 0){ + chapterData.Chapters.Sort((a, b) => { + if (a.start != null && b.start != null) + return a.start.Value - b.start.Value; + return 0; + }); + + if (!((chapterData.Chapters.Any(c => c.type == "intro")) || chapterData.Chapters.Any(c => c.type == "recap"))){ + int chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}=00:00:00.00"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Episode"); + } + + foreach (CrunchyChapter chapter in chapterData.Chapters){ + if (chapter.start == null || chapter.end == null) continue; + + DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + DateTime startTime = epoch.AddSeconds(chapter.start.Value); + DateTime endTime = epoch.AddSeconds(chapter.end.Value); + + string startFormatted = startTime.ToString("HH:mm:ss") + ".00"; + string endFormatted = endTime.ToString("HH:mm:ss") + ".00"; + + int chapterNumber = (compiledChapters.Count / 2) + 1; + if (chapter.type == "intro"){ + if (chapter.start > 0){ + compiledChapters.Add($"CHAPTER{chapterNumber}=00:00:00.00"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Prologue"); + } + + chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}={startFormatted}"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Opening"); + chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}={endFormatted}"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Episode"); + } else{ + string formattedChapterType = char.ToUpper(chapter.type[0]) + chapter.type.Substring(1); + chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}={startFormatted}"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME={formattedChapterType} Start"); + chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}={endFormatted}"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME={formattedChapterType} End"); + } + } + } + } else{ + Console.WriteLine("Chapter request failed, attempting old API "); + + showRequest = HttpClientReq.CreateRequestMessage($"https://static.crunchyroll.com/datalab-intro-v2/{currentMediaId}.json", HttpMethod.Get, true, true, null); + + showRequestResponse = await HttpClientReq.Instance.SendHttpRequest(showRequest); + + if (showRequestResponse.IsOk){ + CrunchyOldChapter chapterData = JsonConvert.DeserializeObject(showRequestResponse.ResponseContent); + + DateTime epoch = new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc); + + DateTime startTime = epoch.AddSeconds(chapterData.startTime); + DateTime endTime = epoch.AddSeconds(chapterData.endTime); + + string[] startTimeParts = startTime.ToString(CultureInfo.CurrentCulture).Split('.'); + string[] endTimeParts = endTime.ToString(CultureInfo.CurrentCulture).Split('.'); + + string startMs = startTimeParts.Length > 1 ? startTimeParts[1] : "00"; + string endMs = endTimeParts.Length > 1 ? endTimeParts[1] : "00"; + + string startFormatted = startTime.ToString("HH:mm:ss") + "." + startMs; + string endFormatted = endTime.ToString("HH:mm:ss") + "." + endMs; + + int chapterNumber = (compiledChapters.Count / 2) + 1; + if (chapterData.startTime > 1){ + compiledChapters.Add($"CHAPTER{chapterNumber}=00:00:00.00"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Prologue"); + } + + chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}={startFormatted}"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Opening"); + chapterNumber = (compiledChapters.Count / 2) + 1; + compiledChapters.Add($"CHAPTER{chapterNumber}={endFormatted}"); + compiledChapters.Add($"CHAPTER{chapterNumber}NAME=Episode"); + } else{ + Console.WriteLine("Old Chapter API request failed"); + } + } + } +} \ No newline at end of file diff --git a/Downloader/History.cs b/Downloader/History.cs new file mode 100644 index 0000000..f679769 --- /dev/null +++ b/Downloader/History.cs @@ -0,0 +1,411 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.ComponentModel; +using System.Linq; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Utils; +using CRD.Utils.Structs; +using CRD.Views; +using Newtonsoft.Json; +using ReactiveUI; + +namespace CRD.Downloader; + +public class History(Crunchyroll crunInstance){ + public async Task UpdateSeries(string seriesId, string? seasonId){ + await crunInstance.CrAuth.RefreshToken(true); + + CrSeriesSearch? parsedSeries = await crunInstance.CrSeries.ParseSeriesById(seriesId, "ja"); + + if (parsedSeries == null){ + Console.WriteLine("Parse Data Invalid"); + return; + } + + var result = crunInstance.CrSeries.ParseSeriesResult(parsedSeries); + Dictionary episodes = new Dictionary(); + + foreach (int season in result.Keys){ + foreach (var key in result[season].Keys){ + var s = result[season][key]; + if (seasonId != null && s.Id != seasonId) continue; + var seasonData = await crunInstance.CrSeries.GetSeasonDataById(s); + UpdateWithSeasonData(seasonData); + } + } + } + + private void UpdateHistoryFile(){ + CfgManager.WriteJsonToFile(CfgManager.PathCrHistory, crunInstance.HistoryList); + } + + public void SetAsDownloaded(string? seriesId, string? seasonId, string episodeId){ + var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); + + if (historySeries != null){ + var historySeason = historySeries.Seasons.Find(s => s.SeasonId == seasonId); + + if (historySeason != null){ + var historyEpisode = historySeason.EpisodesList.Find(e => e.EpisodeId == episodeId); + + if (historyEpisode != null){ + historyEpisode.WasDownloaded = true; + historySeason.UpdateDownloaded(); + return; + } + } + } + + MessageBus.Current.SendMessage(new ToastMessage($"Couldn't update download History", ToastType.Warning, 1)); + } + + + public async void UpdateWithEpisode(CrunchyEpisode episodeParam){ + var episode = episodeParam; + + if (episode.Versions != null){ + var version = episode.Versions.Find(a => a.Original); + if (version.AudioLocale != episode.AudioLocale){ + var episodeById = await crunInstance.CrEpisode.ParseEpisodeById(version.Guid, ""); + if (episodeById?.Data != null){ + if (episodeById.Value.Total != 1){ + MessageBus.Current.SendMessage(new ToastMessage($"Couldn't update download History", ToastType.Warning, 1)); + return; + } + + episode = episodeById.Value.Data.First(); + } + } + } + + + var seriesId = episode.SeriesId; + var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); + if (historySeries != null){ + var historySeason = historySeries.Seasons.Find(s => s.SeasonId == episode.SeasonId); + + if (historySeason != null){ + if (historySeason.EpisodesList.All(e => e.EpisodeId != episode.Id)){ + var newHistoryEpisode = new HistoryEpisode{ + EpisodeTitle = episode.Title, + EpisodeId = episode.Id, + Episode = episode.Episode, + }; + + historySeason.EpisodesList.Add(newHistoryEpisode); + + historySeason.EpisodesList.Sort(new NumericStringPropertyComparer()); + } + } else{ + var newSeason = NewHistorySeason(episode); + + historySeries.Seasons.Add(newSeason); + + historySeries.Seasons = historySeries.Seasons.OrderBy(s => s.SeasonNum).ToList(); + } + historySeries.UpdateNewEpisodes(); + } else{ + var newHistorySeries = new HistorySeries{ + SeriesTitle = episode.SeriesTitle, + SeriesId = episode.SeriesId, + Seasons =[], + }; + crunInstance.HistoryList.Add(newHistorySeries); + var newSeason = NewHistorySeason(episode); + + var series = await crunInstance.CrSeries.SeriesById(seriesId); + if (series?.Data != null){ + newHistorySeries.SeriesDescription = series.Data.First().Description; + newHistorySeries.ThumbnailImageUrl = GetSeriesThumbnail(series); + } + + newHistorySeries.Seasons.Add(newSeason); + newHistorySeries.UpdateNewEpisodes(); + } + + var sortedList = crunInstance.HistoryList.OrderBy(item => item.SeriesTitle).ToList(); + crunInstance.HistoryList.Clear(); + foreach (var item in sortedList){ + crunInstance.HistoryList.Add(item); + } + + UpdateHistoryFile(); + } + + public async void UpdateWithSeasonData(CrunchyEpisodeList seasonData){ + if (seasonData.Data != null){ + var firstEpisode = seasonData.Data.First(); + var seriesId = firstEpisode.SeriesId; + var historySeries = crunInstance.HistoryList.FirstOrDefault(series => series.SeriesId == seriesId); + if (historySeries != null){ + var historySeason = historySeries.Seasons.Find(s => s.SeasonId == firstEpisode.SeasonId); + + if (historySeason != null){ + foreach (var crunchyEpisode in seasonData.Data){ + if (historySeason.EpisodesList.All(e => e.EpisodeId != crunchyEpisode.Id)){ + var newHistoryEpisode = new HistoryEpisode{ + EpisodeTitle = crunchyEpisode.Title, + EpisodeId = crunchyEpisode.Id, + Episode = crunchyEpisode.Episode, + }; + + historySeason.EpisodesList.Add(newHistoryEpisode); + } + } + + historySeason.EpisodesList.Sort(new NumericStringPropertyComparer()); + } else{ + var newSeason = NewHistorySeason(seasonData, firstEpisode); + + newSeason.EpisodesList.Sort(new NumericStringPropertyComparer()); + + historySeries.Seasons.Add(newSeason); + + historySeries.Seasons = historySeries.Seasons.OrderBy(s => s.SeasonNum).ToList(); + } + historySeries.UpdateNewEpisodes(); + } else{ + var newHistorySeries = new HistorySeries{ + SeriesTitle = firstEpisode.SeriesTitle, + SeriesId = firstEpisode.SeriesId, + Seasons =[], + }; + crunInstance.HistoryList.Add(newHistorySeries); + + var newSeason = NewHistorySeason(seasonData, firstEpisode); + + newSeason.EpisodesList.Sort(new NumericStringPropertyComparer()); + + var series = await crunInstance.CrSeries.SeriesById(seriesId); + if (series?.Data != null){ + newHistorySeries.SeriesDescription = series.Data.First().Description; + newHistorySeries.ThumbnailImageUrl = GetSeriesThumbnail(series); + } + + + newHistorySeries.Seasons.Add(newSeason); + newHistorySeries.UpdateNewEpisodes(); + } + } + + var sortedList = crunInstance.HistoryList.OrderBy(item => item.SeriesTitle).ToList(); + crunInstance.HistoryList.Clear(); + foreach (var item in sortedList){ + crunInstance.HistoryList.Add(item); + } + + UpdateHistoryFile(); + } + + private string GetSeriesThumbnail(CrSeriesBase series){ + // var series = await crunInstance.CrSeries.SeriesById(seriesId); + + if ((series.Data ?? Array.Empty()).First().Images.PosterTall?.Count > 0){ + return series.Data.First().Images.PosterTall.First().First(e => e.Height == 360).Source; + } + + return ""; + } + + private static bool CheckStringForSpecial(string identifier){ + // Regex pattern to match any sequence that does NOT contain "|S" followed by one or more digits immediately after + string pattern = @"^(?!.*\|S\d+).*"; + + // Use Regex.IsMatch to check if the identifier matches the pattern + return Regex.IsMatch(identifier, pattern); + } + + private static HistorySeason NewHistorySeason(CrunchyEpisodeList seasonData, CrunchyEpisode firstEpisode){ + var newSeason = new HistorySeason{ + SeasonTitle = firstEpisode.SeasonTitle, + SeasonId = firstEpisode.SeasonId, + SeasonNum = firstEpisode.SeasonNumber + "", + EpisodesList =[], + SpecialSeason = CheckStringForSpecial(firstEpisode.Identifier) + }; + + foreach (var crunchyEpisode in seasonData.Data!){ + var newHistoryEpisode = new HistoryEpisode{ + EpisodeTitle = crunchyEpisode.Title, + EpisodeId = crunchyEpisode.Id, + Episode = crunchyEpisode.Episode, + }; + + newSeason.EpisodesList.Add(newHistoryEpisode); + } + + return newSeason; + } + + private static HistorySeason NewHistorySeason(CrunchyEpisode episode){ + var newSeason = new HistorySeason{ + SeasonTitle = episode.SeasonTitle, + SeasonId = episode.SeasonId, + SeasonNum = episode.SeasonNumber + "", + EpisodesList =[], + }; + + var newHistoryEpisode = new HistoryEpisode{ + EpisodeTitle = episode.Title, + EpisodeId = episode.Id, + Episode = episode.Episode, + }; + + newSeason.EpisodesList.Add(newHistoryEpisode); + + + return newSeason; + } +} + +public class NumericStringPropertyComparer : IComparer{ + public int Compare(HistoryEpisode x, HistoryEpisode y){ + if (int.TryParse(x.Episode, out int xInt) && int.TryParse(y.Episode, out int yInt)){ + return xInt.CompareTo(yInt); + } + + // Fall back to string comparison if not parseable as integers + return String.Compare(x.Episode, y.Episode, StringComparison.Ordinal); + } +} + +public class HistorySeries : INotifyPropertyChanged{ + [JsonProperty("series_title")] + public string? SeriesTitle{ get; set; } + + [JsonProperty("series_id")] + public string? SeriesId{ get; set; } + + [JsonProperty("series_description")] + public string? SeriesDescription{ get; set; } + + [JsonProperty("series_thumbnail_url")] + public string? ThumbnailImageUrl{ get; set; } + + [JsonProperty("series_new_episodes")] + public int NewEpisodes{ get; set; } + + [JsonIgnore] + public Bitmap? ThumbnailImage{ get; set; } + + [JsonProperty("series_season_list")] + public required List Seasons{ get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + public async Task LoadImage(){ + try{ + using (var client = new HttpClient()){ + var response = await client.GetAsync(ThumbnailImageUrl); + response.EnsureSuccessStatusCode(); + using (var stream = await response.Content.ReadAsStreamAsync()){ + ThumbnailImage = new Bitmap(stream); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ThumbnailImage))); + } + } + } catch (Exception ex){ + // Handle exceptions + Console.WriteLine("Failed to load image: " + ex.Message); + } + } + + public void UpdateNewEpisodes(){ + int count = 0; + bool foundWatched = false; + + // Iterate over the Seasons list from the end to the beginning + for (int i = Seasons.Count - 1; i >= 0 && !foundWatched; i--){ + + if (Seasons[i].SpecialSeason == true){ + continue; + } + + // Iterate over the Episodes from the end to the beginning + for (int j = Seasons[i].EpisodesList.Count - 1; j >= 0 && !foundWatched; j--){ + if (!Seasons[i].EpisodesList[j].WasDownloaded){ + count++; + } else{ + foundWatched = true; + } + } + } + NewEpisodes = count; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(NewEpisodes))); + } + + public async Task FetchData(string? seasonId){ + await Crunchyroll.Instance.CrHistory.UpdateSeries(SeriesId, seasonId); + } +} + +public class HistorySeason : INotifyPropertyChanged{ + [JsonProperty("season_title")] + public string? SeasonTitle{ get; set; } + + [JsonProperty("season_id")] + public string? SeasonId{ get; set; } + + [JsonProperty("season_cr_season_number")] + public string? SeasonNum{ get; set; } + + [JsonProperty("season_special_season")] + public bool? SpecialSeason{ get; set; } + [JsonIgnore] + public string CombinedProperty => SpecialSeason ?? false ? $"Specials {SeasonNum}" : $"Season {SeasonNum}"; + + [JsonProperty("season_downloaded_episodes")] + public int DownloadedEpisodes{ get; set; } + + [JsonProperty("season_episode_list")] + public required List EpisodesList{ get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void UpdateDownloaded(string? EpisodeId){ + if (!string.IsNullOrEmpty(EpisodeId)){ + EpisodesList.First(e => e.EpisodeId == EpisodeId).ToggleWasDownloaded(); + } + + DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes))); + CfgManager.WriteJsonToFile(CfgManager.PathCrHistory, Crunchyroll.Instance.HistoryList); + } + + public void UpdateDownloaded(){ + DownloadedEpisodes = EpisodesList.FindAll(e => e.WasDownloaded).Count; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadedEpisodes))); + CfgManager.WriteJsonToFile(CfgManager.PathCrHistory, Crunchyroll.Instance.HistoryList); + } +} + +public partial class HistoryEpisode : INotifyPropertyChanged{ + [JsonProperty("episode_title")] + public string? EpisodeTitle{ get; set; } + + [JsonProperty("episode_id")] + public string? EpisodeId{ get; set; } + + [JsonProperty("episode_cr_episode_number")] + public string? Episode{ get; set; } + + [JsonProperty("episode_was_downloaded")] + public bool WasDownloaded{ get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + public void ToggleWasDownloaded(){ + WasDownloaded = !WasDownloaded; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(WasDownloaded))); + } + + public void DownloadEpisode(){ + Crunchyroll.Instance.AddEpisodeToQue(EpisodeId, Crunchyroll.Instance.DefaultLocale, Crunchyroll.Instance.CrunOptions.DubLang); + + } +} \ No newline at end of file diff --git a/Program.cs b/Program.cs new file mode 100644 index 0000000..174e972 --- /dev/null +++ b/Program.cs @@ -0,0 +1,20 @@ +using System; +using Avalonia; + +namespace CRD; + +sealed class Program{ + // Initialization code. Don't use any Avalonia, third-party APIs or any + // SynchronizationContext-reliant code before AppMain is called: things aren't initialized + // yet and stuff might break. + [STAThread] + public static void Main(string[] args) => BuildAvaloniaApp() + .StartWithClassicDesktopLifetime(args); + + // Avalonia configuration, don't remove; also used by visual designer. + public static AppBuilder BuildAvaloniaApp() + => AppBuilder.Configure() + .UsePlatformDetect() + .WithInterFont() + .LogToTrace(); +} \ No newline at end of file diff --git a/Styling/ControlsGalleryStyles.axaml b/Styling/ControlsGalleryStyles.axaml new file mode 100644 index 0000000..50cb62c --- /dev/null +++ b/Styling/ControlsGalleryStyles.axaml @@ -0,0 +1,106 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/Utils/CustomList/RefreshableObservableCollection.cs b/Utils/CustomList/RefreshableObservableCollection.cs new file mode 100644 index 0000000..e9c4199 --- /dev/null +++ b/Utils/CustomList/RefreshableObservableCollection.cs @@ -0,0 +1,10 @@ +using System.Collections.ObjectModel; +using System.Collections.Specialized; + +namespace CRD.Utils.CustomList; + +public class RefreshableObservableCollection : ObservableCollection{ + public void Refresh(){ + OnCollectionChanged(new NotifyCollectionChangedEventArgs(NotifyCollectionChangedAction.Reset)); + } +} \ No newline at end of file diff --git a/Utils/DRM/ContentKey.cs b/Utils/DRM/ContentKey.cs new file mode 100644 index 0000000..a36328f --- /dev/null +++ b/Utils/DRM/ContentKey.cs @@ -0,0 +1,29 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel.DataAnnotations.Schema; +using System.Linq; +using System.Text.Json.Serialization; + +namespace CRD.Utils.DRM; + +[Serializable] +public class ContentKey{ + [JsonPropertyName("key_id")] public byte[] KeyID{ get; set; } + + [JsonPropertyName("type")] public string Type{ get; set; } + + [JsonPropertyName("bytes")] public byte[] Bytes{ get; set; } // key + + [NotMapped] + [JsonPropertyName("permissions")] + public List Permissions{ + get{ return PermissionsString.Split(",").ToList(); } + set{ PermissionsString = string.Join(",", value); } + } + + [JsonIgnore] public string PermissionsString{ get; set; } + + public override string ToString(){ + return $"{BitConverter.ToString(KeyID).Replace("-", "").ToLower()}:{BitConverter.ToString(Bytes).Replace("-", "").ToLower()}"; + } +} \ No newline at end of file diff --git a/Utils/DRM/CryptoUtils.cs b/Utils/DRM/CryptoUtils.cs new file mode 100644 index 0000000..03a75d9 --- /dev/null +++ b/Utils/DRM/CryptoUtils.cs @@ -0,0 +1,29 @@ +namespace CRD.Utils.DRM; + +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Macs; +using Org.BouncyCastle.Crypto.Parameters; +using System.Security.Cryptography; + +public class CryptoUtils{ + public static byte[] GetHMACSHA256Digest(byte[] data, byte[] key){ + return new HMACSHA256(key).ComputeHash(data); + } + + public static byte[] GetCMACDigest(byte[] data, byte[] key){ + IBlockCipher cipher = new AesEngine(); + IMac mac = new CMac(cipher, 128); + + KeyParameter keyParam = new KeyParameter(key); + + mac.Init(keyParam); + + mac.BlockUpdate(data, 0, data.Length); + + byte[] outBytes = new byte[16]; + + mac.DoFinal(outBytes, 0); + return outBytes; + } +} \ No newline at end of file diff --git a/Utils/DRM/PSSHbox.cs b/Utils/DRM/PSSHbox.cs new file mode 100644 index 0000000..fc48c70 --- /dev/null +++ b/Utils/DRM/PSSHbox.cs @@ -0,0 +1,58 @@ +namespace CRD.Utils.DRM; + +using System; +using System.Collections.Generic; +using System.Linq; + +class PSSHBox{ + static readonly byte[] PSSH_HEADER = new byte[]{ 0x70, 0x73, 0x73, 0x68 }; + + public List KIDs{ get; set; } = new List(); + public byte[] Data{ get; set; } + + PSSHBox(List kids, byte[] data){ + KIDs = kids; + Data = data; + } + + public static PSSHBox FromByteArray(byte[] psshbox){ + using var stream = new System.IO.MemoryStream(psshbox); + + stream.Seek(4, System.IO.SeekOrigin.Current); + byte[] header = new byte[4]; + stream.Read(header, 0, 4); + + if (!header.SequenceEqual(PSSH_HEADER)) + throw new Exception("Not a pssh box"); + + stream.Seek(20, System.IO.SeekOrigin.Current); + byte[] kidCountBytes = new byte[4]; + stream.Read(kidCountBytes, 0, 4); + + if (BitConverter.IsLittleEndian) + Array.Reverse(kidCountBytes); + uint kidCount = BitConverter.ToUInt32(kidCountBytes); + + List kids = new List(); + for (int i = 0; i < kidCount; i++){ + byte[] kid = new byte[16]; + stream.Read(kid); + kids.Add(kid); + } + + byte[] dataLengthBytes = new byte[4]; + stream.Read(dataLengthBytes); + + if (BitConverter.IsLittleEndian) + Array.Reverse(dataLengthBytes); + uint dataLength = BitConverter.ToUInt32(dataLengthBytes); + + if (dataLength == 0) + return new PSSHBox(kids, null); + + byte[] data = new byte[dataLength]; + stream.Read(data); + + return new PSSHBox(kids, data); + } +} \ No newline at end of file diff --git a/Utils/DRM/Protocol.cs b/Utils/DRM/Protocol.cs new file mode 100644 index 0000000..97fcf53 --- /dev/null +++ b/Utils/DRM/Protocol.cs @@ -0,0 +1,128 @@ +// using System; +// using System.Collections.Generic; +// using System.IO; +// using ProtoBuf; +// +// namespace CRD.Utils.DRM; +// +// public class ClientIdentification{ +// /** Type of factory-provisioned device root of trust. Optional. */ +// public ClientIdentification_TokenType type{ get; set; } +// +// /** Factory-provisioned device root of trust. Required. */ +// public byte[] token{ get; set; } +// +// /** Optional client information name/value pairs. */ +// public List clientInfo{ get; set; } +// +// /** Client token generated by the content provider. Optional. */ +// public byte[] providerClientToken{ get; set; } +// +// /** +// * Number of licenses received by the client to which the token above belongs. +// * Only present if client_token is specified. +// */ +// public double licenseCounter{ get; set; } +// +// /** List of non-baseline client capabilities. */ +// public ClientIdentification_ClientCapabilities? clientCapabilities{ get; set; } +// +// /** Serialized VmpData message. Optional. */ +// public byte[] vmpData{ get; set; } +// +// /** Optional field that may contain additional provisioning credentials. */ +// public List deviceCredentials{ get; set; } +// +// public static ClientIdentification decode(byte[] input){ +// return Serializer.Deserialize(new MemoryStream(input)); +// } +// } +// +// public struct ClientIdentification_NameValue{ +// public string name{ get; set; } +// public string value{ get; set; } +// } +// +// public enum ClientIdentification_TokenType{ +// KEYBOX = 0, +// DRM_DEVICE_CERTIFICATE = 1, +// REMOTE_ATTESTATION_CERTIFICATE = 2, +// OEM_DEVICE_CERTIFICATE = 3, +// UNRECOGNIZED = -1 +// } +// +// public struct ClientIdentification_ClientCredentials{ +// public ClientIdentification_TokenType type{ get; set; } +// public byte[] token{ get; set; } +// } +// +// /** +// * Capabilities which not all clients may support. Used for the license +// * exchange protocol only. +// */ +// public class ClientIdentification_ClientCapabilities{ +// public bool clientToken{ get; set; } +// public bool sessionToken{ get; set; } +// public bool videoResolutionConstraints{ get; set; } +// public ClientIdentification_ClientCapabilities_HdcpVersion maxHdcpVersion{ get; set; } +// public double oemCryptoApiVersion{ get; set; } +// +// /** +// * Client has hardware support for protecting the usage table, such as +// * storing the generation number in secure memory. For Details, see: +// * Widevine Modular DRM Security Integration Guide for CENC +// */ +// public bool antiRollbackUsageTable{ get; set; } +// +// /** The client shall report |srm_version| if available. */ +// public double srmVersion{ get; set; } +// +// /** +// * A device may have SRM data, and report a version, but may not be capable +// * of updating SRM data. +// */ +// public bool canUpdateSrm{ get; set; } +// +// public ClientIdentification_ClientCapabilities_CertificateKeyType[] supportedCertificateKeyType{ get; set; } +// public ClientIdentification_ClientCapabilities_AnalogOutputCapabilities analogOutputCapabilities{ get; set; } +// public bool canDisableAnalogOutput{ get; set; } +// +// /** +// * Clients can indicate a performance level supported by OEMCrypto. +// * This will allow applications and providers to choose an appropriate +// * quality of content to serve. Currently defined tiers are +// * 1 (low), 2 (medium) and 3 (high). Any other value indicates that +// * the resource rating is unavailable or reporting erroneous values +// * for that device. For details see, +// * Widevine Modular DRM Security Integration Guide for CENC +// */ +// public double resourceRatingTier{ get; set; } +// } +// +// public enum ClientIdentification_ClientCapabilities_HdcpVersion{ +// HDCP_NONE = 0, +// HDCP_V1 = 1, +// HDCP_V2 = 2, +// HDCP_V2_1 = 3, +// HDCP_V2_2 = 4, +// HDCP_V2_3 = 5, +// HDCP_NO_DIGITAL_OUTPUT = 255, +// UNRECOGNIZED = -1 +// } +// +// public enum ClientIdentification_ClientCapabilities_AnalogOutputCapabilities{ +// ANALOG_OUTPUT_UNKNOWN = 0, +// ANALOG_OUTPUT_NONE = 1, +// ANALOG_OUTPUT_SUPPORTED = 2, +// ANALOG_OUTPUT_SUPPORTS_CGMS_A = 3, +// UNRECOGNIZED = -1 +// } +// +// public enum ClientIdentification_ClientCapabilities_CertificateKeyType{ +// RSA_2048 = 0, +// RSA_3072 = 1, +// ECC_SECP256R1 = 2, +// ECC_SECP384R1 = 3, +// ECC_SECP521R1 = 4, +// UNRECOGNIZED = -1 +// } \ No newline at end of file diff --git a/Utils/DRM/Session.cs b/Utils/DRM/Session.cs new file mode 100644 index 0000000..38e2ec4 --- /dev/null +++ b/Utils/DRM/Session.cs @@ -0,0 +1,332 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Reflection; +using System.Security.Cryptography; +using System.Text; +using Org.BouncyCastle.Crypto; +using Org.BouncyCastle.Crypto.Digests; +using Org.BouncyCastle.Crypto.Encodings; +using Org.BouncyCastle.Crypto.Engines; +using Org.BouncyCastle.Crypto.Signers; +using Org.BouncyCastle.OpenSsl; +using ProtoBuf; + +namespace CRD.Utils.DRM; + +public struct ContentDecryptionModule{ + public byte[] privateKey{ get; set; } + public byte[] identifierBlob{ get; set; } +} + +public class DerivedKeys{ + public byte[] Auth1{ get; set; } + public byte[] Auth2{ get; set; } + public byte[] Enc{ get; set; } +} + +public class Session{ + public byte[] WIDEVINE_SYSTEM_ID = new byte[]{ 237, 239, 139, 169, 121, 214, 74, 206, 163, 200, 39, 220, 213, 29, 33, 237 }; + + private RSA _devicePrivateKey; + private ClientIdentification _identifierBlob; + private byte[] _identifier; + private byte[] _pssh; + private byte[] _rawLicenseRequest; + private byte[] _sessionKey; + private DerivedKeys _derivedKeys; + private OaepEncoding _decryptEngine; + public List ContentKeys { get; set; } = new List(); + public dynamic InitData{ get; set; } + + private AsymmetricCipherKeyPair DeviceKeys{ get; set; } + + public Session(ContentDecryptionModule contentDecryptionModule, byte[] pssh){ + _devicePrivateKey = CreatePrivateKeyFromPem(contentDecryptionModule.privateKey); + + using var reader = new StringReader(Encoding.UTF8.GetString(contentDecryptionModule.privateKey)); + DeviceKeys = (AsymmetricCipherKeyPair)new PemReader(reader).ReadObject(); + + _identifierBlob = Serializer.Deserialize(new MemoryStream(contentDecryptionModule.identifierBlob)); + _identifier = GenerateIdentifier(); + _pssh = pssh; + InitData = ParseInitData(pssh); + _decryptEngine = new OaepEncoding(new RsaEngine()); + _decryptEngine.Init(false, DeviceKeys.Private); + } + + private RSA CreatePrivateKeyFromPem(byte[] pemKey){ + RSA rsa = RSA.Create(); + string s = System.Text.Encoding.UTF8.GetString(pemKey); + rsa.ImportFromPem(s); + return rsa; + } + + private byte[] GenerateIdentifier(){ + // Generate 8 random bytes + byte[] randomBytes = RandomNumberGenerator.GetBytes(8); + + // Convert to hex string + string hex = BitConverter.ToString(randomBytes).Replace("-", "").ToLower(); + + // Concatenate with '01' and '00000000000000' + string identifier = hex + "01" + "00000000000000"; + + // Convert the final string to a byte array + return Encoding.UTF8.GetBytes(identifier); + } + + public byte[] GetLicenseRequest(){ + dynamic licenseRequest; + + if (InitData is WidevineCencHeader){ + licenseRequest = new SignedLicenseRequest{ + Type = SignedLicenseRequest.MessageType.LicenseRequest, + Msg = new LicenseRequest{ + Type = LicenseRequest.RequestType.New, + KeyControlNonce = 1093602366, + ProtocolVersion = ProtocolVersion.Current, + RequestTime = uint.Parse((DateTime.Now - DateTime.UnixEpoch).TotalSeconds.ToString().Split(",")[0]), + ContentId = new LicenseRequest.ContentIdentification{ + CencId = new LicenseRequest.ContentIdentification.Cenc{ + LicenseType = LicenseType.Default, + RequestId = _identifier, + Pssh = InitData + } + } + } + }; + } else{ + licenseRequest = new SignedLicenseRequestRaw{ + Type = SignedLicenseRequestRaw.MessageType.LicenseRequest, + Msg = new LicenseRequestRaw{ + Type = LicenseRequestRaw.RequestType.New, + KeyControlNonce = 1093602366, + ProtocolVersion = ProtocolVersion.Current, + RequestTime = uint.Parse((DateTime.Now - DateTime.UnixEpoch).TotalSeconds.ToString().Split(",")[0]), + ContentId = new LicenseRequestRaw.ContentIdentification{ + CencId = new LicenseRequestRaw.ContentIdentification.Cenc{ + LicenseType = LicenseType.Default, + RequestId = _identifier, + Pssh = InitData + } + } + } + }; + } + + licenseRequest.Msg.ClientId = _identifierBlob; + + //Logger.Debug("Signing license request"); + + using (var memoryStream = new MemoryStream()){ + Serializer.Serialize(memoryStream, licenseRequest.Msg); + byte[] data = memoryStream.ToArray(); + _rawLicenseRequest = data; + + licenseRequest.Signature = Sign(data); + } + + byte[] requestBytes; + using (var memoryStream = new MemoryStream()){ + Serializer.Serialize(memoryStream, licenseRequest); + requestBytes = memoryStream.ToArray(); + } + + return requestBytes; + } + + static WidevineCencHeader ParseInitData(byte[] initData){ + WidevineCencHeader cencHeader; + + try{ + cencHeader = Serializer.Deserialize(new MemoryStream(initData[32..])); + } catch{ + try{ + //needed for HBO Max + + PSSHBox psshBox = PSSHBox.FromByteArray(initData); + cencHeader = Serializer.Deserialize(new MemoryStream(psshBox.Data)); + } catch{ + //Logger.Verbose("Unable to parse, unsupported init data format"); + return null; + } + } + + return cencHeader; + } + + + public byte[] Sign(byte[] data){ + PssSigner eng = new PssSigner(new RsaEngine(), new Sha1Digest()); + + eng.Init(true, DeviceKeys.Private); + eng.BlockUpdate(data, 0, data.Length); + return eng.GenerateSignature(); + } + + public byte[] Decrypt(byte[] data){ + int blockSize = _decryptEngine.GetInputBlockSize(); + List plainText = new List(); + + // Process the data in blocks + for (int chunkPosition = 0; chunkPosition < data.Length; chunkPosition += blockSize){ + int chunkSize = Math.Min(blockSize, data.Length - chunkPosition); + byte[] decryptedChunk = _decryptEngine.ProcessBlock(data, chunkPosition, chunkSize); + plainText.AddRange(decryptedChunk); + } + + return plainText.ToArray(); + } + + public void ProvideLicense(byte[] license){ + SignedLicense signedLicense; + try{ + signedLicense = Serializer.Deserialize(new MemoryStream(license)); + } catch{ + throw new Exception("Unable to parse license"); + } + + try{ + var sessionKey = Decrypt(signedLicense.SessionKey); + + if (sessionKey.Length != 16){ + throw new Exception("Unable to decrypt session key"); + } + + _sessionKey = sessionKey; + } catch{ + throw new Exception("Unable to decrypt session key"); + } + + _derivedKeys = DeriveKeys(_rawLicenseRequest, _sessionKey); + + byte[] licenseBytes; + using (var memoryStream = new MemoryStream()){ + Serializer.Serialize(memoryStream, signedLicense.Msg); + licenseBytes = memoryStream.ToArray(); + } + + byte[] hmacHash = CryptoUtils.GetHMACSHA256Digest(licenseBytes, _derivedKeys.Auth1); + + if (!hmacHash.SequenceEqual(signedLicense.Signature)){ + throw new Exception("License signature mismatch"); + } + + foreach (License.KeyContainer key in signedLicense.Msg.Keys){ + string type = key.Type.ToString(); + + if (type == "Signing") + continue; + + byte[] keyId; + byte[] encryptedKey = key.Key; + byte[] iv = key.Iv; + keyId = key.Id; + if (keyId == null){ + keyId = Encoding.ASCII.GetBytes(key.Type.ToString()); + } + + byte[] decryptedKey; + + using MemoryStream mstream = new MemoryStream(); + using AesCryptoServiceProvider aesProvider = new AesCryptoServiceProvider{ + Mode = CipherMode.CBC, + Padding = PaddingMode.PKCS7 + }; + using CryptoStream cryptoStream = new CryptoStream(mstream, aesProvider.CreateDecryptor(_derivedKeys.Enc, iv), CryptoStreamMode.Write); + cryptoStream.Write(encryptedKey, 0, encryptedKey.Length); + decryptedKey = mstream.ToArray(); + + List permissions = new List(); + if (type == "OperatorSession"){ + foreach (PropertyInfo perm in key._OperatorSessionKeyPermissions.GetType().GetProperties()){ + if ((uint)perm.GetValue(key._OperatorSessionKeyPermissions) == 1){ + permissions.Add(perm.Name); + } + } + } + + ContentKeys.Add(new ContentKey{ + KeyID = keyId, + Type = type, + Bytes = decryptedKey, + Permissions = permissions + }); + } + + + } + + public static DerivedKeys DeriveKeys(byte[] message, byte[] key){ + byte[] encKeyBase = Encoding.UTF8.GetBytes("ENCRYPTION").Concat(new byte[]{ 0x0, }).Concat(message).Concat(new byte[]{ 0x0, 0x0, 0x0, 0x80 }).ToArray(); + byte[] authKeyBase = Encoding.UTF8.GetBytes("AUTHENTICATION").Concat(new byte[]{ 0x0, }).Concat(message).Concat(new byte[]{ 0x0, 0x0, 0x2, 0x0 }).ToArray(); + + byte[] encKey = new byte[]{ 0x01 }.Concat(encKeyBase).ToArray(); + byte[] authKey1 = new byte[]{ 0x01 }.Concat(authKeyBase).ToArray(); + byte[] authKey2 = new byte[]{ 0x02 }.Concat(authKeyBase).ToArray(); + byte[] authKey3 = new byte[]{ 0x03 }.Concat(authKeyBase).ToArray(); + byte[] authKey4 = new byte[]{ 0x04 }.Concat(authKeyBase).ToArray(); + + byte[] encCmacKey = CryptoUtils.GetCMACDigest(encKey, key); + byte[] authCmacKey1 = CryptoUtils.GetCMACDigest(authKey1, key); + byte[] authCmacKey2 = CryptoUtils.GetCMACDigest(authKey2, key); + byte[] authCmacKey3 = CryptoUtils.GetCMACDigest(authKey3, key); + byte[] authCmacKey4 = CryptoUtils.GetCMACDigest(authKey4, key); + + byte[] authCmacCombined1 = authCmacKey1.Concat(authCmacKey2).ToArray(); + byte[] authCmacCombined2 = authCmacKey3.Concat(authCmacKey4).ToArray(); + + return new DerivedKeys{ + Auth1 = authCmacCombined1, + Auth2 = authCmacCombined2, + Enc = encCmacKey + }; + } + + // public KeyContainer ParseLicense(byte[] rawLicense){ + // if (_rawLicenseRequest == null){ + // throw new InvalidOperationException("Please request a license first."); + // } + // + // // Assuming SignedMessage and License have Decode methods that deserialize the respective types + // var signedLicense = Serializer.Deserialize(new MemoryStream(rawLicense)); + // byte[] sessionKey = _devicePrivateKey.Decrypt(signedLicense.SessionKey, RSAEncryptionPadding.OaepSHA256); + // + // var cmac = new AesCmac(sessionKey); + // var encKeyBase = Concat("ENCRYPTION\x00", _rawLicenseRequest, "\x00\x00\x00\x80"); + // var authKeyBase = Concat("AUTHENTICATION\x00", _rawLicenseRequest, "\x00\x00\x02\x00"); + // + // byte[] encKey = cmac.ComputeHash(Concat("\x01", encKeyBase)); + // byte[] serverKey = Concat( + // cmac.ComputeHash(Concat("\x01", authKeyBase)), + // cmac.ComputeHash(Concat("\x02", authKeyBase)) + // ); + // + // using var hmac = new HMACSHA256(serverKey); + // byte[] calculatedSignature = hmac.ComputeHash(signedLicense.Msg); + // + // if (!calculatedSignature.SequenceEqual(signedLicense.Signature)){ + // throw new InvalidOperationException("Signatures do not match."); + // } + // + // var license = License.Decode(signedLicense.Msg); + // + // return license.Key.Select(keyContainer => { + // string keyId = keyContainer.Id.Length > 0 ? BitConverter.ToString(keyContainer.Id).Replace("-", "").ToLower() : keyContainer.Type.ToString(); + // using var aes = Aes.Create(); + // aes.Key = encKey; + // aes.IV = keyContainer.Iv; + // aes.Mode = CipherMode.CBC; + // + // using var decryptor = aes.CreateDecryptor(); + // byte[] decryptedKey = decryptor.TransformFinalBlock(keyContainer.Key, 0, keyContainer.Key.Length); + // + // return new KeyContainer{ + // Kid = keyId, + // Key = BitConverter.ToString(decryptedKey).Replace("-", "").ToLower() + // }; + // }).ToArray(); + // } +} \ No newline at end of file diff --git a/Utils/DRM/Widevine.cs b/Utils/DRM/Widevine.cs new file mode 100644 index 0000000..5f6ba47 --- /dev/null +++ b/Utils/DRM/Widevine.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Text; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace CRD.Utils.DRM; + +public class Widevine{ + private byte[] privateKey = new byte[0]; + private byte[] identifierBlob = new byte[0]; + + public bool canDecrypt = false; + + + #region Singelton + + private static Widevine? instance; + private static readonly object padlock = new object(); + + public static Widevine Instance{ + get{ + if (instance == null){ + lock (padlock){ + if (instance == null){ + instance = new Widevine(); + } + } + } + + return instance; + } + } + + #endregion + + public Widevine(){ + try{ + if (Directory.Exists(CfgManager.PathWIDEVINE_DIR)){ + var files = Directory.GetFiles(CfgManager.PathWIDEVINE_DIR); + + foreach (var file in files){ + var fileInfo = new FileInfo(file); + if (fileInfo.Length < 1024 * 8 && !fileInfo.Attributes.HasFlag(FileAttributes.Directory)){ + string fileContents = File.ReadAllText(file, Encoding.UTF8); + if (fileContents.Contains("-BEGIN RSA PRIVATE KEY-")){ + privateKey = File.ReadAllBytes(file); + } + + if (fileContents.Contains("widevine_cdm_version")){ + identifierBlob = File.ReadAllBytes(file); + } + } + } + } + + + if (privateKey.Length != 0 && identifierBlob.Length != 0){ + canDecrypt = true; + } else if (privateKey.Length == 0){ + Console.WriteLine("Private key missing"); + canDecrypt = false; + } else if (identifierBlob.Length == 0){ + Console.WriteLine("Identifier blob missing"); + canDecrypt = false; + } + } catch (Exception e){ + Console.WriteLine(e); + canDecrypt = false; + } + } + + public async Task> getKeys(string? pssh, string licenseServer, Dictionary authData){ + if (pssh == null || !canDecrypt) return new List(); + + byte[] psshBuffer = Convert.FromBase64String(pssh); + + Session ses = new Session(new ContentDecryptionModule{ identifierBlob = identifierBlob, privateKey = privateKey }, psshBuffer); + + var playbackRequest2 = new HttpRequestMessage(HttpMethod.Post, licenseServer); + foreach (var keyValuePair in authData){ + playbackRequest2.Headers.Add(keyValuePair.Key, keyValuePair.Value); + } + + var licenceReq = ses.GetLicenseRequest(); + playbackRequest2.Content = new ByteArrayContent(licenceReq); + + var response = await HttpClientReq.Instance.SendHttpRequest(playbackRequest2); + + if (!response.IsOk){ + Console.WriteLine("Fallback Request Stream URLs FAILED!"); + return new List(); + } + + LicenceReqResp resp = Helpers.Deserialize(response.ResponseContent,null) ?? new LicenceReqResp(); + + ses.ProvideLicense(Convert.FromBase64String(resp.license)); + + return ses.ContentKeys; + } +} + +public class LicenceReqResp{ + public string status{ get; set; } + public string license{ get; set; } + public string platform{ get; set; } + public string message_type{ get; set; } +} \ No newline at end of file diff --git a/Utils/DRM/WvProto2.cs b/Utils/DRM/WvProto2.cs new file mode 100644 index 0000000..342221e --- /dev/null +++ b/Utils/DRM/WvProto2.cs @@ -0,0 +1,2259 @@ +namespace CRD.Utils.DRM; + +// +// This file was generated by a tool; you should avoid making direct changes. +// Consider using 'partial classes' to extend these types +// Input: my.proto +// + +#region Designer generated code +#pragma warning disable CS0612, CS0618, CS1591, CS3021, IDE0079, IDE1006, RCS1036, RCS1057, RCS1085, RCS1192 +[global::ProtoBuf.ProtoContract()] +public partial class ClientIdentification : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, IsRequired = true)] + public TokenType Type { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public SignedDeviceCertificate Token { get; set; } + + [global::ProtoBuf.ProtoMember(3, Name = @"ClientInfo")] + public global::System.Collections.Generic.List ClientInfoes { get; } = new global::System.Collections.Generic.List(); + + [global::ProtoBuf.ProtoMember(4)] + public byte[] ProviderClientToken + { + get => __pbn__ProviderClientToken; + set => __pbn__ProviderClientToken = value; + } + public bool ShouldSerializeProviderClientToken() => __pbn__ProviderClientToken != null; + public void ResetProviderClientToken() => __pbn__ProviderClientToken = null; + private byte[] __pbn__ProviderClientToken; + + [global::ProtoBuf.ProtoMember(5)] + public uint LicenseCounter + { + get => __pbn__LicenseCounter.GetValueOrDefault(); + set => __pbn__LicenseCounter = value; + } + public bool ShouldSerializeLicenseCounter() => __pbn__LicenseCounter != null; + public void ResetLicenseCounter() => __pbn__LicenseCounter = null; + private uint? __pbn__LicenseCounter; + + [global::ProtoBuf.ProtoMember(6)] + public ClientCapabilities _ClientCapabilities { get; set; } + + [global::ProtoBuf.ProtoMember(7, Name = @"_FileHashes")] + public FileHashes FileHashes { get; set; } + + [global::ProtoBuf.ProtoContract()] + public partial class NameValue : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, IsRequired = true)] + public string Name { get; set; } + + [global::ProtoBuf.ProtoMember(2, IsRequired = true)] + public string Value { get; set; } + + } + + [global::ProtoBuf.ProtoContract()] + public partial class ClientCapabilities : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public uint ClientToken + { + get => __pbn__ClientToken.GetValueOrDefault(); + set => __pbn__ClientToken = value; + } + public bool ShouldSerializeClientToken() => __pbn__ClientToken != null; + public void ResetClientToken() => __pbn__ClientToken = null; + private uint? __pbn__ClientToken; + + [global::ProtoBuf.ProtoMember(2)] + public uint SessionToken + { + get => __pbn__SessionToken.GetValueOrDefault(); + set => __pbn__SessionToken = value; + } + public bool ShouldSerializeSessionToken() => __pbn__SessionToken != null; + public void ResetSessionToken() => __pbn__SessionToken = null; + private uint? __pbn__SessionToken; + + [global::ProtoBuf.ProtoMember(3)] + public uint VideoResolutionConstraints + { + get => __pbn__VideoResolutionConstraints.GetValueOrDefault(); + set => __pbn__VideoResolutionConstraints = value; + } + public bool ShouldSerializeVideoResolutionConstraints() => __pbn__VideoResolutionConstraints != null; + public void ResetVideoResolutionConstraints() => __pbn__VideoResolutionConstraints = null; + private uint? __pbn__VideoResolutionConstraints; + + [global::ProtoBuf.ProtoMember(4)] + [global::System.ComponentModel.DefaultValue(HdcpVersion.HdcpNone)] + public HdcpVersion MaxHdcpVersion + { + get => __pbn__MaxHdcpVersion ?? HdcpVersion.HdcpNone; + set => __pbn__MaxHdcpVersion = value; + } + public bool ShouldSerializeMaxHdcpVersion() => __pbn__MaxHdcpVersion != null; + public void ResetMaxHdcpVersion() => __pbn__MaxHdcpVersion = null; + private HdcpVersion? __pbn__MaxHdcpVersion; + + [global::ProtoBuf.ProtoMember(5)] + public uint OemCryptoApiVersion + { + get => __pbn__OemCryptoApiVersion.GetValueOrDefault(); + set => __pbn__OemCryptoApiVersion = value; + } + public bool ShouldSerializeOemCryptoApiVersion() => __pbn__OemCryptoApiVersion != null; + public void ResetOemCryptoApiVersion() => __pbn__OemCryptoApiVersion = null; + private uint? __pbn__OemCryptoApiVersion; + + [global::ProtoBuf.ProtoContract()] + public enum HdcpVersion + { + [global::ProtoBuf.ProtoEnum(Name = @"HDCP_NONE")] + HdcpNone = 0, + [global::ProtoBuf.ProtoEnum(Name = @"HDCP_V1")] + HdcpV1 = 1, + [global::ProtoBuf.ProtoEnum(Name = @"HDCP_V2")] + HdcpV2 = 2, + [global::ProtoBuf.ProtoEnum(Name = @"HDCP_V2_1")] + HdcpV21 = 3, + [global::ProtoBuf.ProtoEnum(Name = @"HDCP_V2_2")] + HdcpV22 = 4, + [global::ProtoBuf.ProtoEnum(Name = @"HDCP_V2_3")] + HdcpV23 = 5, + } + + } + + [global::ProtoBuf.ProtoContract()] + public enum TokenType + { + [global::ProtoBuf.ProtoEnum(Name = @"KEYBOX")] + Keybox = 0, + [global::ProtoBuf.ProtoEnum(Name = @"DEVICE_CERTIFICATE")] + DeviceCertificate = 1, + [global::ProtoBuf.ProtoEnum(Name = @"REMOTE_ATTESTATION_CERTIFICATE")] + RemoteAttestationCertificate = 2, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class DeviceCertificate : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, IsRequired = true)] + public CertificateType Type { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public byte[] SerialNumber + { + get => __pbn__SerialNumber; + set => __pbn__SerialNumber = value; + } + public bool ShouldSerializeSerialNumber() => __pbn__SerialNumber != null; + public void ResetSerialNumber() => __pbn__SerialNumber = null; + private byte[] __pbn__SerialNumber; + + [global::ProtoBuf.ProtoMember(3)] + public uint CreationTimeSeconds + { + get => __pbn__CreationTimeSeconds.GetValueOrDefault(); + set => __pbn__CreationTimeSeconds = value; + } + public bool ShouldSerializeCreationTimeSeconds() => __pbn__CreationTimeSeconds != null; + public void ResetCreationTimeSeconds() => __pbn__CreationTimeSeconds = null; + private uint? __pbn__CreationTimeSeconds; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] PublicKey + { + get => __pbn__PublicKey; + set => __pbn__PublicKey = value; + } + public bool ShouldSerializePublicKey() => __pbn__PublicKey != null; + public void ResetPublicKey() => __pbn__PublicKey = null; + private byte[] __pbn__PublicKey; + + [global::ProtoBuf.ProtoMember(5)] + public uint SystemId + { + get => __pbn__SystemId.GetValueOrDefault(); + set => __pbn__SystemId = value; + } + public bool ShouldSerializeSystemId() => __pbn__SystemId != null; + public void ResetSystemId() => __pbn__SystemId = null; + private uint? __pbn__SystemId; + + [global::ProtoBuf.ProtoMember(6)] + public uint TestDeviceDeprecated + { + get => __pbn__TestDeviceDeprecated.GetValueOrDefault(); + set => __pbn__TestDeviceDeprecated = value; + } + public bool ShouldSerializeTestDeviceDeprecated() => __pbn__TestDeviceDeprecated != null; + public void ResetTestDeviceDeprecated() => __pbn__TestDeviceDeprecated = null; + private uint? __pbn__TestDeviceDeprecated; + + [global::ProtoBuf.ProtoMember(7)] + public byte[] ServiceId + { + get => __pbn__ServiceId; + set => __pbn__ServiceId = value; + } + public bool ShouldSerializeServiceId() => __pbn__ServiceId != null; + public void ResetServiceId() => __pbn__ServiceId = null; + private byte[] __pbn__ServiceId; + + [global::ProtoBuf.ProtoContract()] + public enum CertificateType + { + [global::ProtoBuf.ProtoEnum(Name = @"ROOT")] + Root = 0, + [global::ProtoBuf.ProtoEnum(Name = @"INTERMEDIATE")] + Intermediate = 1, + [global::ProtoBuf.ProtoEnum(Name = @"USER_DEVICE")] + UserDevice = 2, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE")] + Service = 3, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class DeviceCertificateStatus : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public byte[] SerialNumber + { + get => __pbn__SerialNumber; + set => __pbn__SerialNumber = value; + } + public bool ShouldSerializeSerialNumber() => __pbn__SerialNumber != null; + public void ResetSerialNumber() => __pbn__SerialNumber = null; + private byte[] __pbn__SerialNumber; + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue(CertificateStatus.Valid)] + public CertificateStatus Status + { + get => __pbn__Status ?? CertificateStatus.Valid; + set => __pbn__Status = value; + } + public bool ShouldSerializeStatus() => __pbn__Status != null; + public void ResetStatus() => __pbn__Status = null; + private CertificateStatus? __pbn__Status; + + [global::ProtoBuf.ProtoMember(4)] + public ProvisionedDeviceInfo DeviceInfo { get; set; } + + [global::ProtoBuf.ProtoContract()] + public enum CertificateStatus + { + [global::ProtoBuf.ProtoEnum(Name = @"VALID")] + Valid = 0, + [global::ProtoBuf.ProtoEnum(Name = @"REVOKED")] + Revoked = 1, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class DeviceCertificateStatusList : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public uint CreationTimeSeconds + { + get => __pbn__CreationTimeSeconds.GetValueOrDefault(); + set => __pbn__CreationTimeSeconds = value; + } + public bool ShouldSerializeCreationTimeSeconds() => __pbn__CreationTimeSeconds != null; + public void ResetCreationTimeSeconds() => __pbn__CreationTimeSeconds = null; + private uint? __pbn__CreationTimeSeconds; + + [global::ProtoBuf.ProtoMember(2)] + public global::System.Collections.Generic.List CertificateStatus { get; } = new global::System.Collections.Generic.List(); + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedDeviceCertificateStatusList : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public DeviceCertificateStatusList CertificateList { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class EncryptedClientIdentification : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, IsRequired = true)] + public string ServiceId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public byte[] ServiceCertificateSerialNumber + { + get => __pbn__ServiceCertificateSerialNumber; + set => __pbn__ServiceCertificateSerialNumber = value; + } + public bool ShouldSerializeServiceCertificateSerialNumber() => __pbn__ServiceCertificateSerialNumber != null; + public void ResetServiceCertificateSerialNumber() => __pbn__ServiceCertificateSerialNumber = null; + private byte[] __pbn__ServiceCertificateSerialNumber; + + [global::ProtoBuf.ProtoMember(3, IsRequired = true)] + public byte[] EncryptedClientId { get; set; } + + [global::ProtoBuf.ProtoMember(4, IsRequired = true)] + public byte[] EncryptedClientIdIv { get; set; } + + [global::ProtoBuf.ProtoMember(5, IsRequired = true)] + public byte[] EncryptedPrivacyKey { get; set; } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class LicenseIdentification : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public byte[] RequestId + { + get => __pbn__RequestId; + set => __pbn__RequestId = value; + } + public bool ShouldSerializeRequestId() => __pbn__RequestId != null; + public void ResetRequestId() => __pbn__RequestId = null; + private byte[] __pbn__RequestId; + + [global::ProtoBuf.ProtoMember(2)] + public byte[] SessionId + { + get => __pbn__SessionId; + set => __pbn__SessionId = value; + } + public bool ShouldSerializeSessionId() => __pbn__SessionId != null; + public void ResetSessionId() => __pbn__SessionId = null; + private byte[] __pbn__SessionId; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] PurchaseId + { + get => __pbn__PurchaseId; + set => __pbn__PurchaseId = value; + } + public bool ShouldSerializePurchaseId() => __pbn__PurchaseId != null; + public void ResetPurchaseId() => __pbn__PurchaseId = null; + private byte[] __pbn__PurchaseId; + + [global::ProtoBuf.ProtoMember(4)] + [global::System.ComponentModel.DefaultValue(LicenseType.Zero)] + public LicenseType Type + { + get => __pbn__Type ?? LicenseType.Zero; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private LicenseType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(5)] + public uint Version + { + get => __pbn__Version.GetValueOrDefault(); + set => __pbn__Version = value; + } + public bool ShouldSerializeVersion() => __pbn__Version != null; + public void ResetVersion() => __pbn__Version = null; + private uint? __pbn__Version; + + [global::ProtoBuf.ProtoMember(6)] + public byte[] ProviderSessionToken + { + get => __pbn__ProviderSessionToken; + set => __pbn__ProviderSessionToken = value; + } + public bool ShouldSerializeProviderSessionToken() => __pbn__ProviderSessionToken != null; + public void ResetProviderSessionToken() => __pbn__ProviderSessionToken = null; + private byte[] __pbn__ProviderSessionToken; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class License : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public LicenseIdentification Id { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public Policy _Policy { get; set; } + + [global::ProtoBuf.ProtoMember(3, Name = @"Key")] + public global::System.Collections.Generic.List Keys { get; } = new global::System.Collections.Generic.List(); + + [global::ProtoBuf.ProtoMember(4)] + public uint LicenseStartTime + { + get => __pbn__LicenseStartTime.GetValueOrDefault(); + set => __pbn__LicenseStartTime = value; + } + public bool ShouldSerializeLicenseStartTime() => __pbn__LicenseStartTime != null; + public void ResetLicenseStartTime() => __pbn__LicenseStartTime = null; + private uint? __pbn__LicenseStartTime; + + [global::ProtoBuf.ProtoMember(5)] + public uint RemoteAttestationVerified + { + get => __pbn__RemoteAttestationVerified.GetValueOrDefault(); + set => __pbn__RemoteAttestationVerified = value; + } + public bool ShouldSerializeRemoteAttestationVerified() => __pbn__RemoteAttestationVerified != null; + public void ResetRemoteAttestationVerified() => __pbn__RemoteAttestationVerified = null; + private uint? __pbn__RemoteAttestationVerified; + + [global::ProtoBuf.ProtoMember(6)] + public byte[] ProviderClientToken + { + get => __pbn__ProviderClientToken; + set => __pbn__ProviderClientToken = value; + } + public bool ShouldSerializeProviderClientToken() => __pbn__ProviderClientToken != null; + public void ResetProviderClientToken() => __pbn__ProviderClientToken = null; + private byte[] __pbn__ProviderClientToken; + + [global::ProtoBuf.ProtoMember(7)] + public uint ProtectionScheme + { + get => __pbn__ProtectionScheme.GetValueOrDefault(); + set => __pbn__ProtectionScheme = value; + } + public bool ShouldSerializeProtectionScheme() => __pbn__ProtectionScheme != null; + public void ResetProtectionScheme() => __pbn__ProtectionScheme = null; + private uint? __pbn__ProtectionScheme; + + [global::ProtoBuf.ProtoContract()] + public partial class Policy : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public bool CanPlay + { + get => __pbn__CanPlay.GetValueOrDefault(); + set => __pbn__CanPlay = value; + } + public bool ShouldSerializeCanPlay() => __pbn__CanPlay != null; + public void ResetCanPlay() => __pbn__CanPlay = null; + private bool? __pbn__CanPlay; + + [global::ProtoBuf.ProtoMember(2)] + public bool CanPersist + { + get => __pbn__CanPersist.GetValueOrDefault(); + set => __pbn__CanPersist = value; + } + public bool ShouldSerializeCanPersist() => __pbn__CanPersist != null; + public void ResetCanPersist() => __pbn__CanPersist = null; + private bool? __pbn__CanPersist; + + [global::ProtoBuf.ProtoMember(3)] + public bool CanRenew + { + get => __pbn__CanRenew.GetValueOrDefault(); + set => __pbn__CanRenew = value; + } + public bool ShouldSerializeCanRenew() => __pbn__CanRenew != null; + public void ResetCanRenew() => __pbn__CanRenew = null; + private bool? __pbn__CanRenew; + + [global::ProtoBuf.ProtoMember(4)] + public uint RentalDurationSeconds + { + get => __pbn__RentalDurationSeconds.GetValueOrDefault(); + set => __pbn__RentalDurationSeconds = value; + } + public bool ShouldSerializeRentalDurationSeconds() => __pbn__RentalDurationSeconds != null; + public void ResetRentalDurationSeconds() => __pbn__RentalDurationSeconds = null; + private uint? __pbn__RentalDurationSeconds; + + [global::ProtoBuf.ProtoMember(5)] + public uint PlaybackDurationSeconds + { + get => __pbn__PlaybackDurationSeconds.GetValueOrDefault(); + set => __pbn__PlaybackDurationSeconds = value; + } + public bool ShouldSerializePlaybackDurationSeconds() => __pbn__PlaybackDurationSeconds != null; + public void ResetPlaybackDurationSeconds() => __pbn__PlaybackDurationSeconds = null; + private uint? __pbn__PlaybackDurationSeconds; + + [global::ProtoBuf.ProtoMember(6)] + public uint LicenseDurationSeconds + { + get => __pbn__LicenseDurationSeconds.GetValueOrDefault(); + set => __pbn__LicenseDurationSeconds = value; + } + public bool ShouldSerializeLicenseDurationSeconds() => __pbn__LicenseDurationSeconds != null; + public void ResetLicenseDurationSeconds() => __pbn__LicenseDurationSeconds = null; + private uint? __pbn__LicenseDurationSeconds; + + [global::ProtoBuf.ProtoMember(7)] + public uint RenewalRecoveryDurationSeconds + { + get => __pbn__RenewalRecoveryDurationSeconds.GetValueOrDefault(); + set => __pbn__RenewalRecoveryDurationSeconds = value; + } + public bool ShouldSerializeRenewalRecoveryDurationSeconds() => __pbn__RenewalRecoveryDurationSeconds != null; + public void ResetRenewalRecoveryDurationSeconds() => __pbn__RenewalRecoveryDurationSeconds = null; + private uint? __pbn__RenewalRecoveryDurationSeconds; + + [global::ProtoBuf.ProtoMember(8)] + [global::System.ComponentModel.DefaultValue("")] + public string RenewalServerUrl + { + get => __pbn__RenewalServerUrl ?? ""; + set => __pbn__RenewalServerUrl = value; + } + public bool ShouldSerializeRenewalServerUrl() => __pbn__RenewalServerUrl != null; + public void ResetRenewalServerUrl() => __pbn__RenewalServerUrl = null; + private string __pbn__RenewalServerUrl; + + [global::ProtoBuf.ProtoMember(9)] + public uint RenewalDelaySeconds + { + get => __pbn__RenewalDelaySeconds.GetValueOrDefault(); + set => __pbn__RenewalDelaySeconds = value; + } + public bool ShouldSerializeRenewalDelaySeconds() => __pbn__RenewalDelaySeconds != null; + public void ResetRenewalDelaySeconds() => __pbn__RenewalDelaySeconds = null; + private uint? __pbn__RenewalDelaySeconds; + + [global::ProtoBuf.ProtoMember(10)] + public uint RenewalRetryIntervalSeconds + { + get => __pbn__RenewalRetryIntervalSeconds.GetValueOrDefault(); + set => __pbn__RenewalRetryIntervalSeconds = value; + } + public bool ShouldSerializeRenewalRetryIntervalSeconds() => __pbn__RenewalRetryIntervalSeconds != null; + public void ResetRenewalRetryIntervalSeconds() => __pbn__RenewalRetryIntervalSeconds = null; + private uint? __pbn__RenewalRetryIntervalSeconds; + + [global::ProtoBuf.ProtoMember(11)] + public bool RenewWithUsage + { + get => __pbn__RenewWithUsage.GetValueOrDefault(); + set => __pbn__RenewWithUsage = value; + } + public bool ShouldSerializeRenewWithUsage() => __pbn__RenewWithUsage != null; + public void ResetRenewWithUsage() => __pbn__RenewWithUsage = null; + private bool? __pbn__RenewWithUsage; + + } + + [global::ProtoBuf.ProtoContract()] + public partial class KeyContainer : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public byte[] Id + { + get => __pbn__Id; + set => __pbn__Id = value; + } + public bool ShouldSerializeId() => __pbn__Id != null; + public void ResetId() => __pbn__Id = null; + private byte[] __pbn__Id; + + [global::ProtoBuf.ProtoMember(2)] + public byte[] Iv + { + get => __pbn__Iv; + set => __pbn__Iv = value; + } + public bool ShouldSerializeIv() => __pbn__Iv != null; + public void ResetIv() => __pbn__Iv = null; + private byte[] __pbn__Iv; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] Key + { + get => __pbn__Key; + set => __pbn__Key = value; + } + public bool ShouldSerializeKey() => __pbn__Key != null; + public void ResetKey() => __pbn__Key = null; + private byte[] __pbn__Key; + + [global::ProtoBuf.ProtoMember(4)] + [global::System.ComponentModel.DefaultValue(KeyType.Signing)] + public KeyType Type + { + get => __pbn__Type ?? KeyType.Signing; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private KeyType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(5)] + [global::System.ComponentModel.DefaultValue(SecurityLevel.SwSecureCrypto)] + public SecurityLevel Level + { + get => __pbn__Level ?? SecurityLevel.SwSecureCrypto; + set => __pbn__Level = value; + } + public bool ShouldSerializeLevel() => __pbn__Level != null; + public void ResetLevel() => __pbn__Level = null; + private SecurityLevel? __pbn__Level; + + [global::ProtoBuf.ProtoMember(6)] + public OutputProtection RequiredProtection { get; set; } + + [global::ProtoBuf.ProtoMember(7)] + public OutputProtection RequestedProtection { get; set; } + + [global::ProtoBuf.ProtoMember(8)] + public KeyControl _KeyControl { get; set; } + + [global::ProtoBuf.ProtoMember(9)] + public OperatorSessionKeyPermissions _OperatorSessionKeyPermissions { get; set; } + + [global::ProtoBuf.ProtoMember(10)] + public global::System.Collections.Generic.List VideoResolutionConstraints { get; } = new global::System.Collections.Generic.List(); + + [global::ProtoBuf.ProtoContract()] + public partial class OutputProtection : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(ClientIdentification.ClientCapabilities.HdcpVersion.HdcpNone)] + public ClientIdentification.ClientCapabilities.HdcpVersion Hdcp + { + get => __pbn__Hdcp ?? ClientIdentification.ClientCapabilities.HdcpVersion.HdcpNone; + set => __pbn__Hdcp = value; + } + public bool ShouldSerializeHdcp() => __pbn__Hdcp != null; + public void ResetHdcp() => __pbn__Hdcp = null; + private ClientIdentification.ClientCapabilities.HdcpVersion? __pbn__Hdcp; + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue(Cgms.CopyFree)] + public Cgms CgmsFlags + { + get => __pbn__CgmsFlags ?? Cgms.CopyFree; + set => __pbn__CgmsFlags = value; + } + public bool ShouldSerializeCgmsFlags() => __pbn__CgmsFlags != null; + public void ResetCgmsFlags() => __pbn__CgmsFlags = null; + private Cgms? __pbn__CgmsFlags; + + [global::ProtoBuf.ProtoContract(Name = @"CGMS")] + public enum Cgms + { + [global::ProtoBuf.ProtoEnum(Name = @"COPY_FREE")] + CopyFree = 0, + [global::ProtoBuf.ProtoEnum(Name = @"COPY_ONCE")] + CopyOnce = 2, + [global::ProtoBuf.ProtoEnum(Name = @"COPY_NEVER")] + CopyNever = 3, + [global::ProtoBuf.ProtoEnum(Name = @"CGMS_NONE")] + CgmsNone = 42, + } + + } + + [global::ProtoBuf.ProtoContract()] + public partial class KeyControl : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, IsRequired = true)] + public byte[] KeyControlBlock { get; set; } + + [global::ProtoBuf.ProtoMember(2, IsRequired = true)] + public byte[] Iv { get; set; } + + } + + [global::ProtoBuf.ProtoContract()] + public partial class OperatorSessionKeyPermissions : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public uint AllowEncrypt + { + get => __pbn__AllowEncrypt.GetValueOrDefault(); + set => __pbn__AllowEncrypt = value; + } + public bool ShouldSerializeAllowEncrypt() => __pbn__AllowEncrypt != null; + public void ResetAllowEncrypt() => __pbn__AllowEncrypt = null; + private uint? __pbn__AllowEncrypt; + + [global::ProtoBuf.ProtoMember(2)] + public uint AllowDecrypt + { + get => __pbn__AllowDecrypt.GetValueOrDefault(); + set => __pbn__AllowDecrypt = value; + } + public bool ShouldSerializeAllowDecrypt() => __pbn__AllowDecrypt != null; + public void ResetAllowDecrypt() => __pbn__AllowDecrypt = null; + private uint? __pbn__AllowDecrypt; + + [global::ProtoBuf.ProtoMember(3)] + public uint AllowSign + { + get => __pbn__AllowSign.GetValueOrDefault(); + set => __pbn__AllowSign = value; + } + public bool ShouldSerializeAllowSign() => __pbn__AllowSign != null; + public void ResetAllowSign() => __pbn__AllowSign = null; + private uint? __pbn__AllowSign; + + [global::ProtoBuf.ProtoMember(4)] + public uint AllowSignatureVerify + { + get => __pbn__AllowSignatureVerify.GetValueOrDefault(); + set => __pbn__AllowSignatureVerify = value; + } + public bool ShouldSerializeAllowSignatureVerify() => __pbn__AllowSignatureVerify != null; + public void ResetAllowSignatureVerify() => __pbn__AllowSignatureVerify = null; + private uint? __pbn__AllowSignatureVerify; + + } + + [global::ProtoBuf.ProtoContract()] + public partial class VideoResolutionConstraint : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public uint MinResolutionPixels + { + get => __pbn__MinResolutionPixels.GetValueOrDefault(); + set => __pbn__MinResolutionPixels = value; + } + public bool ShouldSerializeMinResolutionPixels() => __pbn__MinResolutionPixels != null; + public void ResetMinResolutionPixels() => __pbn__MinResolutionPixels = null; + private uint? __pbn__MinResolutionPixels; + + [global::ProtoBuf.ProtoMember(2)] + public uint MaxResolutionPixels + { + get => __pbn__MaxResolutionPixels.GetValueOrDefault(); + set => __pbn__MaxResolutionPixels = value; + } + public bool ShouldSerializeMaxResolutionPixels() => __pbn__MaxResolutionPixels != null; + public void ResetMaxResolutionPixels() => __pbn__MaxResolutionPixels = null; + private uint? __pbn__MaxResolutionPixels; + + [global::ProtoBuf.ProtoMember(3)] + public License.KeyContainer.OutputProtection RequiredProtection { get; set; } + + } + + [global::ProtoBuf.ProtoContract()] + public enum KeyType + { + [global::ProtoBuf.ProtoEnum(Name = @"SIGNING")] + Signing = 1, + [global::ProtoBuf.ProtoEnum(Name = @"CONTENT")] + Content = 2, + [global::ProtoBuf.ProtoEnum(Name = @"KEY_CONTROL")] + KeyControl = 3, + [global::ProtoBuf.ProtoEnum(Name = @"OPERATOR_SESSION")] + OperatorSession = 4, + } + + [global::ProtoBuf.ProtoContract()] + public enum SecurityLevel + { + [global::ProtoBuf.ProtoEnum(Name = @"SW_SECURE_CRYPTO")] + SwSecureCrypto = 1, + [global::ProtoBuf.ProtoEnum(Name = @"SW_SECURE_DECODE")] + SwSecureDecode = 2, + [global::ProtoBuf.ProtoEnum(Name = @"HW_SECURE_CRYPTO")] + HwSecureCrypto = 3, + [global::ProtoBuf.ProtoEnum(Name = @"HW_SECURE_DECODE")] + HwSecureDecode = 4, + [global::ProtoBuf.ProtoEnum(Name = @"HW_SECURE_ALL")] + HwSecureAll = 5, + } + + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class LicenseError : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(Error.InvalidDeviceCertificate)] + public Error ErrorCode + { + get => __pbn__ErrorCode ?? Error.InvalidDeviceCertificate; + set => __pbn__ErrorCode = value; + } + public bool ShouldSerializeErrorCode() => __pbn__ErrorCode != null; + public void ResetErrorCode() => __pbn__ErrorCode = null; + private Error? __pbn__ErrorCode; + + [global::ProtoBuf.ProtoContract()] + public enum Error + { + [global::ProtoBuf.ProtoEnum(Name = @"INVALID_DEVICE_CERTIFICATE")] + InvalidDeviceCertificate = 1, + [global::ProtoBuf.ProtoEnum(Name = @"REVOKED_DEVICE_CERTIFICATE")] + RevokedDeviceCertificate = 2, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_UNAVAILABLE")] + ServiceUnavailable = 3, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class LicenseRequest : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public ClientIdentification ClientId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public ContentIdentification ContentId { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + [global::System.ComponentModel.DefaultValue(RequestType.New)] + public RequestType Type + { + get => __pbn__Type ?? RequestType.New; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private RequestType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(4)] + public uint RequestTime + { + get => __pbn__RequestTime.GetValueOrDefault(); + set => __pbn__RequestTime = value; + } + public bool ShouldSerializeRequestTime() => __pbn__RequestTime != null; + public void ResetRequestTime() => __pbn__RequestTime = null; + private uint? __pbn__RequestTime; + + [global::ProtoBuf.ProtoMember(5)] + public byte[] KeyControlNonceDeprecated + { + get => __pbn__KeyControlNonceDeprecated; + set => __pbn__KeyControlNonceDeprecated = value; + } + public bool ShouldSerializeKeyControlNonceDeprecated() => __pbn__KeyControlNonceDeprecated != null; + public void ResetKeyControlNonceDeprecated() => __pbn__KeyControlNonceDeprecated = null; + private byte[] __pbn__KeyControlNonceDeprecated; + + [global::ProtoBuf.ProtoMember(6)] + [global::System.ComponentModel.DefaultValue(ProtocolVersion.Current)] + public ProtocolVersion ProtocolVersion + { + get => __pbn__ProtocolVersion ?? ProtocolVersion.Current; + set => __pbn__ProtocolVersion = value; + } + public bool ShouldSerializeProtocolVersion() => __pbn__ProtocolVersion != null; + public void ResetProtocolVersion() => __pbn__ProtocolVersion = null; + private ProtocolVersion? __pbn__ProtocolVersion; + + [global::ProtoBuf.ProtoMember(7)] + public uint KeyControlNonce + { + get => __pbn__KeyControlNonce.GetValueOrDefault(); + set => __pbn__KeyControlNonce = value; + } + public bool ShouldSerializeKeyControlNonce() => __pbn__KeyControlNonce != null; + public void ResetKeyControlNonce() => __pbn__KeyControlNonce = null; + private uint? __pbn__KeyControlNonce; + + [global::ProtoBuf.ProtoMember(8)] + public EncryptedClientIdentification EncryptedClientId { get; set; } + + [global::ProtoBuf.ProtoContract()] + public partial class ContentIdentification : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public Cenc CencId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public WebM WebmId { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + public ExistingLicense License { get; set; } + + [global::ProtoBuf.ProtoContract(Name = @"CENC")] + public partial class Cenc : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public WidevineCencHeader Pssh { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue(LicenseType.Zero)] + public LicenseType LicenseType + { + get => __pbn__LicenseType ?? LicenseType.Zero; + set => __pbn__LicenseType = value; + } + public bool ShouldSerializeLicenseType() => __pbn__LicenseType != null; + public void ResetLicenseType() => __pbn__LicenseType = null; + private LicenseType? __pbn__LicenseType; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] RequestId + { + get => __pbn__RequestId; + set => __pbn__RequestId = value; + } + public bool ShouldSerializeRequestId() => __pbn__RequestId != null; + public void ResetRequestId() => __pbn__RequestId = null; + private byte[] __pbn__RequestId; + + } + + [global::ProtoBuf.ProtoContract()] + public partial class WebM : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public byte[] Header + { + get => __pbn__Header; + set => __pbn__Header = value; + } + public bool ShouldSerializeHeader() => __pbn__Header != null; + public void ResetHeader() => __pbn__Header = null; + private byte[] __pbn__Header; + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue(LicenseType.Zero)] + public LicenseType LicenseType + { + get => __pbn__LicenseType ?? LicenseType.Zero; + set => __pbn__LicenseType = value; + } + public bool ShouldSerializeLicenseType() => __pbn__LicenseType != null; + public void ResetLicenseType() => __pbn__LicenseType = null; + private LicenseType? __pbn__LicenseType; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] RequestId + { + get => __pbn__RequestId; + set => __pbn__RequestId = value; + } + public bool ShouldSerializeRequestId() => __pbn__RequestId != null; + public void ResetRequestId() => __pbn__RequestId = null; + private byte[] __pbn__RequestId; + + } + + [global::ProtoBuf.ProtoContract()] + public partial class ExistingLicense : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public LicenseIdentification LicenseId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public uint SecondsSinceStarted + { + get => __pbn__SecondsSinceStarted.GetValueOrDefault(); + set => __pbn__SecondsSinceStarted = value; + } + public bool ShouldSerializeSecondsSinceStarted() => __pbn__SecondsSinceStarted != null; + public void ResetSecondsSinceStarted() => __pbn__SecondsSinceStarted = null; + private uint? __pbn__SecondsSinceStarted; + + [global::ProtoBuf.ProtoMember(3)] + public uint SecondsSinceLastPlayed + { + get => __pbn__SecondsSinceLastPlayed.GetValueOrDefault(); + set => __pbn__SecondsSinceLastPlayed = value; + } + public bool ShouldSerializeSecondsSinceLastPlayed() => __pbn__SecondsSinceLastPlayed != null; + public void ResetSecondsSinceLastPlayed() => __pbn__SecondsSinceLastPlayed = null; + private uint? __pbn__SecondsSinceLastPlayed; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionUsageTableEntry + { + get => __pbn__SessionUsageTableEntry; + set => __pbn__SessionUsageTableEntry = value; + } + public bool ShouldSerializeSessionUsageTableEntry() => __pbn__SessionUsageTableEntry != null; + public void ResetSessionUsageTableEntry() => __pbn__SessionUsageTableEntry = null; + private byte[] __pbn__SessionUsageTableEntry; + + } + + } + + [global::ProtoBuf.ProtoContract()] + public enum RequestType + { + [global::ProtoBuf.ProtoEnum(Name = @"NEW")] + New = 1, + [global::ProtoBuf.ProtoEnum(Name = @"RENEWAL")] + Renewal = 2, + [global::ProtoBuf.ProtoEnum(Name = @"RELEASE")] + Release = 3, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class LicenseRequestRaw : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public ClientIdentification ClientId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public ContentIdentification ContentId { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + [global::System.ComponentModel.DefaultValue(RequestType.New)] + public RequestType Type + { + get => __pbn__Type ?? RequestType.New; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private RequestType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(4)] + public uint RequestTime + { + get => __pbn__RequestTime.GetValueOrDefault(); + set => __pbn__RequestTime = value; + } + public bool ShouldSerializeRequestTime() => __pbn__RequestTime != null; + public void ResetRequestTime() => __pbn__RequestTime = null; + private uint? __pbn__RequestTime; + + [global::ProtoBuf.ProtoMember(5)] + public byte[] KeyControlNonceDeprecated + { + get => __pbn__KeyControlNonceDeprecated; + set => __pbn__KeyControlNonceDeprecated = value; + } + public bool ShouldSerializeKeyControlNonceDeprecated() => __pbn__KeyControlNonceDeprecated != null; + public void ResetKeyControlNonceDeprecated() => __pbn__KeyControlNonceDeprecated = null; + private byte[] __pbn__KeyControlNonceDeprecated; + + [global::ProtoBuf.ProtoMember(6)] + [global::System.ComponentModel.DefaultValue(ProtocolVersion.Current)] + public ProtocolVersion ProtocolVersion + { + get => __pbn__ProtocolVersion ?? ProtocolVersion.Current; + set => __pbn__ProtocolVersion = value; + } + public bool ShouldSerializeProtocolVersion() => __pbn__ProtocolVersion != null; + public void ResetProtocolVersion() => __pbn__ProtocolVersion = null; + private ProtocolVersion? __pbn__ProtocolVersion; + + [global::ProtoBuf.ProtoMember(7)] + public uint KeyControlNonce + { + get => __pbn__KeyControlNonce.GetValueOrDefault(); + set => __pbn__KeyControlNonce = value; + } + public bool ShouldSerializeKeyControlNonce() => __pbn__KeyControlNonce != null; + public void ResetKeyControlNonce() => __pbn__KeyControlNonce = null; + private uint? __pbn__KeyControlNonce; + + [global::ProtoBuf.ProtoMember(8)] + public EncryptedClientIdentification EncryptedClientId { get; set; } + + [global::ProtoBuf.ProtoContract()] + public partial class ContentIdentification : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public Cenc CencId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public WebM WebmId { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + public ExistingLicense License { get; set; } + + [global::ProtoBuf.ProtoContract(Name = @"CENC")] + public partial class Cenc : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public byte[] Pssh + { + get => __pbn__Pssh; + set => __pbn__Pssh = value; + } + public bool ShouldSerializePssh() => __pbn__Pssh != null; + public void ResetPssh() => __pbn__Pssh = null; + private byte[] __pbn__Pssh; + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue(LicenseType.Zero)] + public LicenseType LicenseType + { + get => __pbn__LicenseType ?? LicenseType.Zero; + set => __pbn__LicenseType = value; + } + public bool ShouldSerializeLicenseType() => __pbn__LicenseType != null; + public void ResetLicenseType() => __pbn__LicenseType = null; + private LicenseType? __pbn__LicenseType; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] RequestId + { + get => __pbn__RequestId; + set => __pbn__RequestId = value; + } + public bool ShouldSerializeRequestId() => __pbn__RequestId != null; + public void ResetRequestId() => __pbn__RequestId = null; + private byte[] __pbn__RequestId; + + } + + [global::ProtoBuf.ProtoContract()] + public partial class WebM : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public byte[] Header + { + get => __pbn__Header; + set => __pbn__Header = value; + } + public bool ShouldSerializeHeader() => __pbn__Header != null; + public void ResetHeader() => __pbn__Header = null; + private byte[] __pbn__Header; + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue(LicenseType.Zero)] + public LicenseType LicenseType + { + get => __pbn__LicenseType ?? LicenseType.Zero; + set => __pbn__LicenseType = value; + } + public bool ShouldSerializeLicenseType() => __pbn__LicenseType != null; + public void ResetLicenseType() => __pbn__LicenseType = null; + private LicenseType? __pbn__LicenseType; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] RequestId + { + get => __pbn__RequestId; + set => __pbn__RequestId = value; + } + public bool ShouldSerializeRequestId() => __pbn__RequestId != null; + public void ResetRequestId() => __pbn__RequestId = null; + private byte[] __pbn__RequestId; + + } + + [global::ProtoBuf.ProtoContract()] + public partial class ExistingLicense : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public LicenseIdentification LicenseId { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public uint SecondsSinceStarted + { + get => __pbn__SecondsSinceStarted.GetValueOrDefault(); + set => __pbn__SecondsSinceStarted = value; + } + public bool ShouldSerializeSecondsSinceStarted() => __pbn__SecondsSinceStarted != null; + public void ResetSecondsSinceStarted() => __pbn__SecondsSinceStarted = null; + private uint? __pbn__SecondsSinceStarted; + + [global::ProtoBuf.ProtoMember(3)] + public uint SecondsSinceLastPlayed + { + get => __pbn__SecondsSinceLastPlayed.GetValueOrDefault(); + set => __pbn__SecondsSinceLastPlayed = value; + } + public bool ShouldSerializeSecondsSinceLastPlayed() => __pbn__SecondsSinceLastPlayed != null; + public void ResetSecondsSinceLastPlayed() => __pbn__SecondsSinceLastPlayed = null; + private uint? __pbn__SecondsSinceLastPlayed; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionUsageTableEntry + { + get => __pbn__SessionUsageTableEntry; + set => __pbn__SessionUsageTableEntry = value; + } + public bool ShouldSerializeSessionUsageTableEntry() => __pbn__SessionUsageTableEntry != null; + public void ResetSessionUsageTableEntry() => __pbn__SessionUsageTableEntry = null; + private byte[] __pbn__SessionUsageTableEntry; + + } + + } + + [global::ProtoBuf.ProtoContract()] + public enum RequestType + { + [global::ProtoBuf.ProtoEnum(Name = @"NEW")] + New = 1, + [global::ProtoBuf.ProtoEnum(Name = @"RENEWAL")] + Renewal = 2, + [global::ProtoBuf.ProtoEnum(Name = @"RELEASE")] + Release = 3, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class ProvisionedDeviceInfo : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public uint SystemId + { + get => __pbn__SystemId.GetValueOrDefault(); + set => __pbn__SystemId = value; + } + public bool ShouldSerializeSystemId() => __pbn__SystemId != null; + public void ResetSystemId() => __pbn__SystemId = null; + private uint? __pbn__SystemId; + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue("")] + public string Soc + { + get => __pbn__Soc ?? ""; + set => __pbn__Soc = value; + } + public bool ShouldSerializeSoc() => __pbn__Soc != null; + public void ResetSoc() => __pbn__Soc = null; + private string __pbn__Soc; + + [global::ProtoBuf.ProtoMember(3)] + [global::System.ComponentModel.DefaultValue("")] + public string Manufacturer + { + get => __pbn__Manufacturer ?? ""; + set => __pbn__Manufacturer = value; + } + public bool ShouldSerializeManufacturer() => __pbn__Manufacturer != null; + public void ResetManufacturer() => __pbn__Manufacturer = null; + private string __pbn__Manufacturer; + + [global::ProtoBuf.ProtoMember(4)] + [global::System.ComponentModel.DefaultValue("")] + public string Model + { + get => __pbn__Model ?? ""; + set => __pbn__Model = value; + } + public bool ShouldSerializeModel() => __pbn__Model != null; + public void ResetModel() => __pbn__Model = null; + private string __pbn__Model; + + [global::ProtoBuf.ProtoMember(5)] + [global::System.ComponentModel.DefaultValue("")] + public string DeviceType + { + get => __pbn__DeviceType ?? ""; + set => __pbn__DeviceType = value; + } + public bool ShouldSerializeDeviceType() => __pbn__DeviceType != null; + public void ResetDeviceType() => __pbn__DeviceType = null; + private string __pbn__DeviceType; + + [global::ProtoBuf.ProtoMember(6)] + public uint ModelYear + { + get => __pbn__ModelYear.GetValueOrDefault(); + set => __pbn__ModelYear = value; + } + public bool ShouldSerializeModelYear() => __pbn__ModelYear != null; + public void ResetModelYear() => __pbn__ModelYear = null; + private uint? __pbn__ModelYear; + + [global::ProtoBuf.ProtoMember(7)] + [global::System.ComponentModel.DefaultValue(WvSecurityLevel.LevelUnspecified)] + public WvSecurityLevel SecurityLevel + { + get => __pbn__SecurityLevel ?? WvSecurityLevel.LevelUnspecified; + set => __pbn__SecurityLevel = value; + } + public bool ShouldSerializeSecurityLevel() => __pbn__SecurityLevel != null; + public void ResetSecurityLevel() => __pbn__SecurityLevel = null; + private WvSecurityLevel? __pbn__SecurityLevel; + + [global::ProtoBuf.ProtoMember(8)] + public uint TestDevice + { + get => __pbn__TestDevice.GetValueOrDefault(); + set => __pbn__TestDevice = value; + } + public bool ShouldSerializeTestDevice() => __pbn__TestDevice != null; + public void ResetTestDevice() => __pbn__TestDevice = null; + private uint? __pbn__TestDevice; + + [global::ProtoBuf.ProtoContract()] + public enum WvSecurityLevel + { + [global::ProtoBuf.ProtoEnum(Name = @"LEVEL_UNSPECIFIED")] + LevelUnspecified = 0, + [global::ProtoBuf.ProtoEnum(Name = @"LEVEL_1")] + Level1 = 1, + [global::ProtoBuf.ProtoEnum(Name = @"LEVEL_2")] + Level2 = 2, + [global::ProtoBuf.ProtoEnum(Name = @"LEVEL_3")] + Level3 = 3, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class RemoteAttestation : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + public EncryptedClientIdentification Certificate { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + [global::System.ComponentModel.DefaultValue("")] + public string Salt + { + get => __pbn__Salt ?? ""; + set => __pbn__Salt = value; + } + public bool ShouldSerializeSalt() => __pbn__Salt != null; + public void ResetSalt() => __pbn__Salt = null; + private string __pbn__Salt; + + [global::ProtoBuf.ProtoMember(3)] + [global::System.ComponentModel.DefaultValue("")] + public string Signature + { + get => __pbn__Signature ?? ""; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private string __pbn__Signature; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class ProvisioningOptions : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(CertificateType.WidevineDrm)] + public CertificateType certificate_type + { + get => __pbn__certificate_type ?? CertificateType.WidevineDrm; + set => __pbn__certificate_type = value; + } + public bool ShouldSerializecertificate_type() => __pbn__certificate_type != null; + public void Resetcertificate_type() => __pbn__certificate_type = null; + private CertificateType? __pbn__certificate_type; + + [global::ProtoBuf.ProtoMember(2, Name = @"certificate_authority")] + [global::System.ComponentModel.DefaultValue("")] + public string CertificateAuthority + { + get => __pbn__CertificateAuthority ?? ""; + set => __pbn__CertificateAuthority = value; + } + public bool ShouldSerializeCertificateAuthority() => __pbn__CertificateAuthority != null; + public void ResetCertificateAuthority() => __pbn__CertificateAuthority = null; + private string __pbn__CertificateAuthority; + + [global::ProtoBuf.ProtoContract()] + public enum CertificateType + { + [global::ProtoBuf.ProtoEnum(Name = @"WIDEVINE_DRM")] + WidevineDrm = 0, + X509 = 1, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class ProvisioningRequest : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"client_id")] + public ClientIdentification ClientId { get; set; } + + [global::ProtoBuf.ProtoMember(5, Name = @"encrypted_client_id")] + public EncryptedClientIdentification EncryptedClientId { get; set; } + + [global::ProtoBuf.ProtoMember(2, Name = @"nonce")] + public byte[] Nonce + { + get => __pbn__Nonce; + set => __pbn__Nonce = value; + } + public bool ShouldSerializeNonce() => __pbn__Nonce != null; + public void ResetNonce() => __pbn__Nonce = null; + private byte[] __pbn__Nonce; + + [global::ProtoBuf.ProtoMember(3, Name = @"options")] + public ProvisioningOptions Options { get; set; } + + [global::ProtoBuf.ProtoMember(4, Name = @"stable_id")] + public byte[] StableId + { + get => __pbn__StableId; + set => __pbn__StableId = value; + } + public bool ShouldSerializeStableId() => __pbn__StableId != null; + public void ResetStableId() => __pbn__StableId = null; + private byte[] __pbn__StableId; + + [global::ProtoBuf.ProtoMember(6, Name = @"provider_id")] + public byte[] ProviderId + { + get => __pbn__ProviderId; + set => __pbn__ProviderId = value; + } + public bool ShouldSerializeProviderId() => __pbn__ProviderId != null; + public void ResetProviderId() => __pbn__ProviderId = null; + private byte[] __pbn__ProviderId; + + [global::ProtoBuf.ProtoMember(7, Name = @"spoid")] + public byte[] Spoid + { + get => __pbn__Spoid; + set => __pbn__Spoid = value; + } + public bool ShouldSerializeSpoid() => __pbn__Spoid != null; + public void ResetSpoid() => __pbn__Spoid = null; + private byte[] __pbn__Spoid; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class ProvisioningResponse : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"device_rsa_key")] + public byte[] DeviceRsaKey + { + get => __pbn__DeviceRsaKey; + set => __pbn__DeviceRsaKey = value; + } + public bool ShouldSerializeDeviceRsaKey() => __pbn__DeviceRsaKey != null; + public void ResetDeviceRsaKey() => __pbn__DeviceRsaKey = null; + private byte[] __pbn__DeviceRsaKey; + + [global::ProtoBuf.ProtoMember(2, Name = @"device_rsa_key_iv")] + public byte[] DeviceRsaKeyIv + { + get => __pbn__DeviceRsaKeyIv; + set => __pbn__DeviceRsaKeyIv = value; + } + public bool ShouldSerializeDeviceRsaKeyIv() => __pbn__DeviceRsaKeyIv != null; + public void ResetDeviceRsaKeyIv() => __pbn__DeviceRsaKeyIv = null; + private byte[] __pbn__DeviceRsaKeyIv; + + [global::ProtoBuf.ProtoMember(3, Name = @"device_certificate")] + public SignedDeviceCertificate DeviceCertificate { get; set; } + + [global::ProtoBuf.ProtoMember(4, Name = @"nonce")] + public byte[] Nonce + { + get => __pbn__Nonce; + set => __pbn__Nonce = value; + } + public bool ShouldSerializeNonce() => __pbn__Nonce != null; + public void ResetNonce() => __pbn__Nonce = null; + private byte[] __pbn__Nonce; + + [global::ProtoBuf.ProtoMember(5, Name = @"wrapping_key")] + public byte[] WrappingKey + { + get => __pbn__WrappingKey; + set => __pbn__WrappingKey = value; + } + public bool ShouldSerializeWrappingKey() => __pbn__WrappingKey != null; + public void ResetWrappingKey() => __pbn__WrappingKey = null; + private byte[] __pbn__WrappingKey; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedProvisioningMessage : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"message")] + public ProvisioningResponse Message { get; set; } + + [global::ProtoBuf.ProtoMember(2, Name = @"signature")] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(3)] + [global::System.ComponentModel.DefaultValue(ProtocolVersion.Version2)] + public ProtocolVersion protocol_version + { + get => __pbn__protocol_version ?? ProtocolVersion.Version2; + set => __pbn__protocol_version = value; + } + public bool ShouldSerializeprotocol_version() => __pbn__protocol_version != null; + public void Resetprotocol_version() => __pbn__protocol_version = null; + private ProtocolVersion? __pbn__protocol_version; + + [global::ProtoBuf.ProtoContract()] + public enum ProtocolVersion + { + [global::ProtoBuf.ProtoEnum(Name = @"VERSION_2")] + Version2 = 2, + [global::ProtoBuf.ProtoEnum(Name = @"VERSION_3")] + Version3 = 3, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class DeviceCertificateHack0 : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"message")] + public DeviceCertificateHack1 Message { get; set; } + + [global::ProtoBuf.ProtoMember(2, Name = @"signature")] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class DeviceCertificateHack1 : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(3, Name = @"message")] + public DeviceCertificateHack2 Message { get; set; } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class DeviceCertificateHack2 : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"device_certificate")] + public SignedDeviceCertificate DeviceCertificate { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public byte[] extraData + { + get => __pbn__extraData; + set => __pbn__extraData = value; + } + public bool ShouldSerializeextraData() => __pbn__extraData != null; + public void ResetextraData() => __pbn__extraData = null; + private byte[] __pbn__extraData; + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedDeviceCertificate : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"_DeviceCertificate")] + public DeviceCertificate DeviceCertificate { get; set; } + + [global::ProtoBuf.ProtoMember(2)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(3)] + public SignedDeviceCertificate Signer { get; set; } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedMessage : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(MessageType.LicenseRequest)] + public MessageType Type + { + get => __pbn__Type ?? MessageType.LicenseRequest; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private MessageType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(2)] + public byte[] Msg + { + get => __pbn__Msg; + set => __pbn__Msg = value; + } + public bool ShouldSerializeMsg() => __pbn__Msg != null; + public void ResetMsg() => __pbn__Msg = null; + private byte[] __pbn__Msg; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionKey + { + get => __pbn__SessionKey; + set => __pbn__SessionKey = value; + } + public bool ShouldSerializeSessionKey() => __pbn__SessionKey != null; + public void ResetSessionKey() => __pbn__SessionKey = null; + private byte[] __pbn__SessionKey; + + [global::ProtoBuf.ProtoMember(5)] + public RemoteAttestation RemoteAttestation { get; set; } + + [global::ProtoBuf.ProtoContract()] + public enum MessageType + { + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE_REQUEST")] + LicenseRequest = 1, + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE")] + License = 2, + [global::ProtoBuf.ProtoEnum(Name = @"ERROR_RESPONSE")] + ErrorResponse = 3, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE_REQUEST")] + ServiceCertificateRequest = 4, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE")] + ServiceCertificate = 5, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class WidevineCencHeader : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(Algorithm.Unencrypted)] + public Algorithm algorithm + { + get => __pbn__algorithm ?? Algorithm.Unencrypted; + set => __pbn__algorithm = value; + } + public bool ShouldSerializealgorithm() => __pbn__algorithm != null; + public void Resetalgorithm() => __pbn__algorithm = null; + private Algorithm? __pbn__algorithm; + + [global::ProtoBuf.ProtoMember(2, Name = @"key_id")] + public global::System.Collections.Generic.List KeyIds { get; } = new global::System.Collections.Generic.List(); + + [global::ProtoBuf.ProtoMember(3, Name = @"provider")] + [global::System.ComponentModel.DefaultValue("")] + public string Provider + { + get => __pbn__Provider ?? ""; + set => __pbn__Provider = value; + } + public bool ShouldSerializeProvider() => __pbn__Provider != null; + public void ResetProvider() => __pbn__Provider = null; + private string __pbn__Provider; + + [global::ProtoBuf.ProtoMember(4, Name = @"content_id")] + public byte[] ContentId + { + get => __pbn__ContentId; + set => __pbn__ContentId = value; + } + public bool ShouldSerializeContentId() => __pbn__ContentId != null; + public void ResetContentId() => __pbn__ContentId = null; + private byte[] __pbn__ContentId; + + [global::ProtoBuf.ProtoMember(5, Name = @"track_type_deprecated")] + [global::System.ComponentModel.DefaultValue("")] + public string TrackTypeDeprecated + { + get => __pbn__TrackTypeDeprecated ?? ""; + set => __pbn__TrackTypeDeprecated = value; + } + public bool ShouldSerializeTrackTypeDeprecated() => __pbn__TrackTypeDeprecated != null; + public void ResetTrackTypeDeprecated() => __pbn__TrackTypeDeprecated = null; + private string __pbn__TrackTypeDeprecated; + + [global::ProtoBuf.ProtoMember(6, Name = @"policy")] + [global::System.ComponentModel.DefaultValue("")] + public string Policy + { + get => __pbn__Policy ?? ""; + set => __pbn__Policy = value; + } + public bool ShouldSerializePolicy() => __pbn__Policy != null; + public void ResetPolicy() => __pbn__Policy = null; + private string __pbn__Policy; + + [global::ProtoBuf.ProtoMember(7, Name = @"crypto_period_index")] + public uint CryptoPeriodIndex + { + get => __pbn__CryptoPeriodIndex.GetValueOrDefault(); + set => __pbn__CryptoPeriodIndex = value; + } + public bool ShouldSerializeCryptoPeriodIndex() => __pbn__CryptoPeriodIndex != null; + public void ResetCryptoPeriodIndex() => __pbn__CryptoPeriodIndex = null; + private uint? __pbn__CryptoPeriodIndex; + + [global::ProtoBuf.ProtoMember(8, Name = @"grouped_license")] + public byte[] GroupedLicense + { + get => __pbn__GroupedLicense; + set => __pbn__GroupedLicense = value; + } + public bool ShouldSerializeGroupedLicense() => __pbn__GroupedLicense != null; + public void ResetGroupedLicense() => __pbn__GroupedLicense = null; + private byte[] __pbn__GroupedLicense; + + [global::ProtoBuf.ProtoMember(9, Name = @"protection_scheme")] + public uint ProtectionScheme + { + get => __pbn__ProtectionScheme.GetValueOrDefault(); + set => __pbn__ProtectionScheme = value; + } + public bool ShouldSerializeProtectionScheme() => __pbn__ProtectionScheme != null; + public void ResetProtectionScheme() => __pbn__ProtectionScheme = null; + private uint? __pbn__ProtectionScheme; + + [global::ProtoBuf.ProtoMember(10, Name = @"crypto_period_seconds")] + public uint CryptoPeriodSeconds + { + get => __pbn__CryptoPeriodSeconds.GetValueOrDefault(); + set => __pbn__CryptoPeriodSeconds = value; + } + public bool ShouldSerializeCryptoPeriodSeconds() => __pbn__CryptoPeriodSeconds != null; + public void ResetCryptoPeriodSeconds() => __pbn__CryptoPeriodSeconds = null; + private uint? __pbn__CryptoPeriodSeconds; + + [global::ProtoBuf.ProtoContract()] + public enum Algorithm + { + [global::ProtoBuf.ProtoEnum(Name = @"UNENCRYPTED")] + Unencrypted = 0, + [global::ProtoBuf.ProtoEnum(Name = @"AESCTR")] + Aesctr = 1, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedLicenseRequest : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(MessageType.LicenseRequest)] + public MessageType Type + { + get => __pbn__Type ?? MessageType.LicenseRequest; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private MessageType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(2)] + public LicenseRequest Msg { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionKey + { + get => __pbn__SessionKey; + set => __pbn__SessionKey = value; + } + public bool ShouldSerializeSessionKey() => __pbn__SessionKey != null; + public void ResetSessionKey() => __pbn__SessionKey = null; + private byte[] __pbn__SessionKey; + + [global::ProtoBuf.ProtoMember(5)] + public RemoteAttestation RemoteAttestation { get; set; } + + [global::ProtoBuf.ProtoContract()] + public enum MessageType + { + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE_REQUEST")] + LicenseRequest = 1, + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE")] + License = 2, + [global::ProtoBuf.ProtoEnum(Name = @"ERROR_RESPONSE")] + ErrorResponse = 3, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE_REQUEST")] + ServiceCertificateRequest = 4, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE")] + ServiceCertificate = 5, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedLicenseRequestRaw : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(MessageType.LicenseRequest)] + public MessageType Type + { + get => __pbn__Type ?? MessageType.LicenseRequest; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private MessageType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(2)] + public LicenseRequestRaw Msg { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionKey + { + get => __pbn__SessionKey; + set => __pbn__SessionKey = value; + } + public bool ShouldSerializeSessionKey() => __pbn__SessionKey != null; + public void ResetSessionKey() => __pbn__SessionKey = null; + private byte[] __pbn__SessionKey; + + [global::ProtoBuf.ProtoMember(5)] + public RemoteAttestation RemoteAttestation { get; set; } + + [global::ProtoBuf.ProtoContract()] + public enum MessageType + { + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE_REQUEST")] + LicenseRequest = 1, + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE")] + License = 2, + [global::ProtoBuf.ProtoEnum(Name = @"ERROR_RESPONSE")] + ErrorResponse = 3, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE_REQUEST")] + ServiceCertificateRequest = 4, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE")] + ServiceCertificate = 5, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedLicense : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(MessageType.LicenseRequest)] + public MessageType Type + { + get => __pbn__Type ?? MessageType.LicenseRequest; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private MessageType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(2)] + public License Msg { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionKey + { + get => __pbn__SessionKey; + set => __pbn__SessionKey = value; + } + public bool ShouldSerializeSessionKey() => __pbn__SessionKey != null; + public void ResetSessionKey() => __pbn__SessionKey = null; + private byte[] __pbn__SessionKey; + + [global::ProtoBuf.ProtoMember(5)] + public RemoteAttestation RemoteAttestation { get; set; } + + [global::ProtoBuf.ProtoContract()] + public enum MessageType + { + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE_REQUEST")] + LicenseRequest = 1, + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE")] + License = 2, + [global::ProtoBuf.ProtoEnum(Name = @"ERROR_RESPONSE")] + ErrorResponse = 3, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE_REQUEST")] + ServiceCertificateRequest = 4, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE")] + ServiceCertificate = 5, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class SignedServiceCertificate : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1)] + [global::System.ComponentModel.DefaultValue(MessageType.LicenseRequest)] + public MessageType Type + { + get => __pbn__Type ?? MessageType.LicenseRequest; + set => __pbn__Type = value; + } + public bool ShouldSerializeType() => __pbn__Type != null; + public void ResetType() => __pbn__Type = null; + private MessageType? __pbn__Type; + + [global::ProtoBuf.ProtoMember(2)] + public SignedDeviceCertificate Msg { get; set; } + + [global::ProtoBuf.ProtoMember(3)] + public byte[] Signature + { + get => __pbn__Signature; + set => __pbn__Signature = value; + } + public bool ShouldSerializeSignature() => __pbn__Signature != null; + public void ResetSignature() => __pbn__Signature = null; + private byte[] __pbn__Signature; + + [global::ProtoBuf.ProtoMember(4)] + public byte[] SessionKey + { + get => __pbn__SessionKey; + set => __pbn__SessionKey = value; + } + public bool ShouldSerializeSessionKey() => __pbn__SessionKey != null; + public void ResetSessionKey() => __pbn__SessionKey = null; + private byte[] __pbn__SessionKey; + + [global::ProtoBuf.ProtoMember(5)] + public RemoteAttestation RemoteAttestation { get; set; } + + [global::ProtoBuf.ProtoContract()] + public enum MessageType + { + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE_REQUEST")] + LicenseRequest = 1, + [global::ProtoBuf.ProtoEnum(Name = @"LICENSE")] + License = 2, + [global::ProtoBuf.ProtoEnum(Name = @"ERROR_RESPONSE")] + ErrorResponse = 3, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE_REQUEST")] + ServiceCertificateRequest = 4, + [global::ProtoBuf.ProtoEnum(Name = @"SERVICE_CERTIFICATE")] + ServiceCertificate = 5, + } + +} + +[global::ProtoBuf.ProtoContract()] +public partial class FileHashes : global::ProtoBuf.IExtensible +{ + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"signer")] + public byte[] Signer + { + get => __pbn__Signer; + set => __pbn__Signer = value; + } + public bool ShouldSerializeSigner() => __pbn__Signer != null; + public void ResetSigner() => __pbn__Signer = null; + private byte[] __pbn__Signer; + + [global::ProtoBuf.ProtoMember(2, Name = @"signatures")] + public global::System.Collections.Generic.List Signatures { get; } = new global::System.Collections.Generic.List(); + + [global::ProtoBuf.ProtoContract()] + public partial class Signature : global::ProtoBuf.IExtensible + { + private global::ProtoBuf.IExtension __pbn__extensionData; + global::ProtoBuf.IExtension global::ProtoBuf.IExtensible.GetExtensionObject(bool createIfMissing) + => global::ProtoBuf.Extensible.GetExtensionObject(ref __pbn__extensionData, createIfMissing); + + [global::ProtoBuf.ProtoMember(1, Name = @"filename")] + [global::System.ComponentModel.DefaultValue("")] + public string Filename + { + get => __pbn__Filename ?? ""; + set => __pbn__Filename = value; + } + public bool ShouldSerializeFilename() => __pbn__Filename != null; + public void ResetFilename() => __pbn__Filename = null; + private string __pbn__Filename; + + [global::ProtoBuf.ProtoMember(2, Name = @"test_signing")] + public bool TestSigning + { + get => __pbn__TestSigning.GetValueOrDefault(); + set => __pbn__TestSigning = value; + } + public bool ShouldSerializeTestSigning() => __pbn__TestSigning != null; + public void ResetTestSigning() => __pbn__TestSigning = null; + private bool? __pbn__TestSigning; + + [global::ProtoBuf.ProtoMember(3)] + public byte[] SHA512Hash + { + get => __pbn__SHA512Hash; + set => __pbn__SHA512Hash = value; + } + public bool ShouldSerializeSHA512Hash() => __pbn__SHA512Hash != null; + public void ResetSHA512Hash() => __pbn__SHA512Hash = null; + private byte[] __pbn__SHA512Hash; + + [global::ProtoBuf.ProtoMember(4, Name = @"main_exe")] + public bool MainExe + { + get => __pbn__MainExe.GetValueOrDefault(); + set => __pbn__MainExe = value; + } + public bool ShouldSerializeMainExe() => __pbn__MainExe != null; + public void ResetMainExe() => __pbn__MainExe = null; + private bool? __pbn__MainExe; + + [global::ProtoBuf.ProtoMember(5)] + public byte[] signature + { + get => __pbn__signature; + set => __pbn__signature = value; + } + public bool ShouldSerializesignature() => __pbn__signature != null; + public void Resetsignature() => __pbn__signature = null; + private byte[] __pbn__signature; + + } + +} + +[global::ProtoBuf.ProtoContract()] +public enum LicenseType +{ + [global::ProtoBuf.ProtoEnum(Name = @"ZERO")] + Zero = 0, + [global::ProtoBuf.ProtoEnum(Name = @"DEFAULT")] + Default = 1, + [global::ProtoBuf.ProtoEnum(Name = @"OFFLINE")] + Offline = 2, +} + +[global::ProtoBuf.ProtoContract()] +public enum ProtocolVersion +{ + [global::ProtoBuf.ProtoEnum(Name = @"CURRENT")] + Current = 21, +} + +#pragma warning restore CS0612, CS0618, CS1591, CS3021, IDE0079, IDE1006, RCS1036, RCS1057, RCS1085, RCS1192 +#endregion diff --git a/Utils/Enums/EnumCollection.cs b/Utils/Enums/EnumCollection.cs new file mode 100644 index 0000000..a2671f3 --- /dev/null +++ b/Utils/Enums/EnumCollection.cs @@ -0,0 +1,82 @@ +using System; +using System.Runtime.Serialization; +using CRD.Utils.JsonConv; +using Newtonsoft.Json; + +namespace CRD.Utils; + +[DataContract] +[JsonConverter(typeof(LocaleConverter))] +public enum Locale{ + [EnumMember(Value = "")] DefaulT, + [EnumMember(Value = "un")] Unknown, + [EnumMember(Value = "en-US")] EnUs, + [EnumMember(Value = "es-LA")] EsLa, + [EnumMember(Value = "es-419")] Es419, + [EnumMember(Value = "es-ES")] EsEs, + [EnumMember(Value = "pt-BR")] PtBr, + [EnumMember(Value = "fr-FR")] FrFr, + [EnumMember(Value = "de-DE")] DeDe, + [EnumMember(Value = "ar-ME")] ArMe, + [EnumMember(Value = "ar-SA")] ArSa, + [EnumMember(Value = "it-IT")] ItIt, + [EnumMember(Value = "ru-RU")] RuRu, + [EnumMember(Value = "tr-TR")] TrTr, + [EnumMember(Value = "hi-IN")] HiIn, + [EnumMember(Value = "zh-CN")] ZhCn, + [EnumMember(Value = "ko-KR")] KoKr, + [EnumMember(Value = "ja-JP")] JaJp, + [EnumMember(Value = "id-ID")] IdId, +} + +public static class EnumExtensions{ + public static string GetEnumMemberValue(this Enum value){ + var type = value.GetType(); + var name = Enum.GetName(type, value); + if (name != null){ + var field = type.GetField(name); + if (field != null){ + var attr = Attribute.GetCustomAttribute(field, typeof(EnumMemberAttribute)) as EnumMemberAttribute; + if (attr != null){ + return attr.Value ?? string.Empty; + } + } + } + + return string.Empty; + } +} + +[DataContract] +public enum ChannelId{ + [EnumMember(Value = "crunchyroll")] Crunchyroll, +} + +[DataContract] +public enum ImageType{ + [EnumMember(Value = "poster_tall")] PosterTall, + + [EnumMember(Value = "poster_wide")] PosterWide, + + [EnumMember(Value = "promo_image")] PromoImage, + + [EnumMember(Value = "thumbnail")] Thumbnail, +} + +[DataContract] +public enum MaturityRating{ + [EnumMember(Value = "TV-14")] Tv14, +} + +[DataContract] +public enum MediaType{ + [EnumMember(Value = "episode")] Episode, +} + +[DataContract] +public enum DownloadMediaType{ + [EnumMember(Value = "Video")] Video, + [EnumMember(Value = "Audio")] Audio, + [EnumMember(Value = "Chapters")] Chapters, + [EnumMember(Value = "Subtitle")] Subtitle, +} \ No newline at end of file diff --git a/Utils/Files/CfgManager.cs b/Utils/Files/CfgManager.cs new file mode 100644 index 0000000..93a7644 --- /dev/null +++ b/Utils/Files/CfgManager.cs @@ -0,0 +1,182 @@ +using System; +using System.IO; +using CRD.Downloader; +using CRD.Utils.Structs; +using Newtonsoft.Json; +using YamlDotNet.Serialization; +using YamlDotNet.Serialization.NamingConventions; + +namespace CRD.Utils; + +public class CfgManager{ + private static string WorkingDirectory = Directory.GetCurrentDirectory(); + + public static readonly string PathCrToken = WorkingDirectory + "/config/cr_token.yml"; + public static readonly string PathCrDownloadOptions = WorkingDirectory + "/config/settings.yml"; + public static readonly string PathCrHistory = WorkingDirectory + "/config/history.json"; + + public static readonly string PathFFMPEG = WorkingDirectory + "/lib/ffmpeg.exe"; + public static readonly string PathMKVMERGE = WorkingDirectory + "/lib/mkvmerge.exe"; + public static readonly string PathMP4Decrypt = WorkingDirectory + "/lib/mp4decrypt.exe"; + + public static readonly string PathWIDEVINE_DIR = WorkingDirectory + "/widevine/"; + + public static readonly string PathVIDEOS_DIR = WorkingDirectory + "/video/"; + public static readonly string PathFONTS_DIR = WorkingDirectory + "/video/"; + + + public static void WriteJsonResponseToYamlFile(string jsonResponse, string filePath){ + // Convert JSON to an object + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) // Adjust this as needed + .Build(); + var jsonObject = deserializer.Deserialize(jsonResponse); + + // Convert the object to YAML + var serializer = new SerializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) // Ensure consistent naming convention + .Build(); + var yaml = serializer.Serialize(jsonObject); + + string dirPath = Path.GetDirectoryName(filePath) ?? string.Empty; + + if (!Directory.Exists(dirPath)){ + Directory.CreateDirectory(dirPath); + } + + if (!File.Exists(filePath)){ + using (var fileStream = File.Create(filePath)){ + } + } + + // Write the YAML to a file + File.WriteAllText(filePath, yaml); + } + + public static void WriteTokenToYamlFile(CrToken token, string filePath){ + // Convert the object to YAML + var serializer = new SerializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) // Ensure consistent naming convention + .Build(); + var yaml = serializer.Serialize(token); + + string dirPath = Path.GetDirectoryName(filePath) ?? string.Empty; + + if (!Directory.Exists(dirPath)){ + Directory.CreateDirectory(dirPath); + } + + if (!File.Exists(filePath)){ + using (var fileStream = File.Create(filePath)){ + } + } + + // Write the YAML to a file + File.WriteAllText(filePath, yaml); + } + + public static void WriteSettingsToFile(){ + var serializer = new SerializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) // Use the underscore style + .Build(); + + string dirPath = Path.GetDirectoryName(PathCrDownloadOptions) ?? string.Empty; + + if (!Directory.Exists(dirPath)){ + Directory.CreateDirectory(dirPath); + } + + if (!File.Exists(PathCrDownloadOptions)){ + using (var fileStream = File.Create(PathCrDownloadOptions)){ + } + } + + var yaml = serializer.Serialize(Crunchyroll.Instance.CrunOptions); + + // Write to file + File.WriteAllText(PathCrDownloadOptions, yaml); + } + + public static void UpdateSettingsFromFile(){ + string dirPath = Path.GetDirectoryName(PathCrDownloadOptions) ?? string.Empty; + + if (!Directory.Exists(dirPath)){ + Directory.CreateDirectory(dirPath); + } + + if (!File.Exists(PathCrDownloadOptions)){ + using (var fileStream = File.Create(PathCrDownloadOptions)){ + } + + return; + } + + var input = File.ReadAllText(PathCrDownloadOptions); + + if (input.Length <= 0){ + return; + } + + var deserializer = new DeserializerBuilder() + .WithNamingConvention(UnderscoredNamingConvention.Instance) + .IgnoreUnmatchedProperties() // Important to ignore properties not present in YAML + .Build(); + + var loadedOptions = deserializer.Deserialize(new StringReader(input)); + + Crunchyroll.Instance.CrunOptions.Hslang = loadedOptions.Hslang; + Crunchyroll.Instance.CrunOptions.Novids = loadedOptions.Novids; + Crunchyroll.Instance.CrunOptions.Noaudio = loadedOptions.Noaudio; + Crunchyroll.Instance.CrunOptions.FileName = loadedOptions.FileName; + Crunchyroll.Instance.CrunOptions.Numbers = loadedOptions.Numbers; + Crunchyroll.Instance.CrunOptions.DlSubs = loadedOptions.DlSubs; + Crunchyroll.Instance.CrunOptions.Mp4 = loadedOptions.Mp4; + Crunchyroll.Instance.CrunOptions.FfmpegOptions = loadedOptions.FfmpegOptions; + Crunchyroll.Instance.CrunOptions.MkvmergeOptions = loadedOptions.MkvmergeOptions; + Crunchyroll.Instance.CrunOptions.Chapters = loadedOptions.Chapters; + Crunchyroll.Instance.CrunOptions.SimultaneousDownloads = loadedOptions.SimultaneousDownloads; + Crunchyroll.Instance.CrunOptions.QualityAudio = loadedOptions.QualityAudio; + Crunchyroll.Instance.CrunOptions.QualityVideo = loadedOptions.QualityVideo; + Crunchyroll.Instance.CrunOptions.DubLang = loadedOptions.DubLang; + Crunchyroll.Instance.CrunOptions.Theme = loadedOptions.Theme; + Crunchyroll.Instance.CrunOptions.AccentColor = loadedOptions.AccentColor; + Crunchyroll.Instance.CrunOptions.History = loadedOptions.History; + } + + private static object fileLock = new object(); + + public static void WriteJsonToFile(string pathToFile, object obj){ + try{ + // Serialize the object to a JSON string. + var jsonString = JsonConvert.SerializeObject(obj, Formatting.Indented); + + // Check if the directory exists; if not, create it. + string directoryPath = Path.GetDirectoryName(pathToFile); + if (!Directory.Exists(directoryPath)){ + Directory.CreateDirectory(directoryPath); + } + + lock (fileLock){ + // Write the JSON string to file. Creates the file if it does not exist. + File.WriteAllText(pathToFile, jsonString); + } + } catch (Exception ex){ + Console.WriteLine($"An error occurred: {ex.Message}"); + } + } + + 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); + } + } +} \ No newline at end of file diff --git a/Utils/Files/FileNameManager.cs b/Utils/Files/FileNameManager.cs new file mode 100644 index 0000000..b041b77 --- /dev/null +++ b/Utils/Files/FileNameManager.cs @@ -0,0 +1,105 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using CRD.Utils.Structs; + +namespace CRD.Utils; + +public class FileNameManager{ + public static List ParseFileName(string input, List variables, int numbers, List @override){ + Regex varRegex = new Regex(@"\${[A-Za-z1-9]+}"); + var matches = varRegex.Matches(input).Cast().Select(m => m.Value).ToList(); + var overriddenVars = ParseOverride(variables, @override); + if (!matches.Any()) + return new List{ + input + }; + foreach (var match in matches){ + string varName = match.Substring(2, match.Length - 3); // Removing ${ and } + var variable = overriddenVars.FirstOrDefault(v => v.Name == varName); + + if (variable == null){ + Console.WriteLine($"[ERROR] Found variable '{match}' in fileName but no values was internally found!"); + continue; + } + + string replacement = variable.ReplaceWith.ToString(); + if (variable.Type == "int32"){ + int len = replacement.Length; + replacement = len < numbers ? new string('0', numbers - len) + replacement : replacement; + } else if (variable.Sanitize){ + replacement = CleanupFilename(replacement); + } + + input = input.Replace(match, replacement); + } + + return input.Split(Path.DirectorySeparatorChar).Select(CleanupFilename).ToList(); + } + + public static List ParseOverride(List variables, List? overrides){ + if (overrides == null){ + return variables; + } + foreach (var item in overrides){ + int index = item.IndexOf('='); + if (index == -1){ + Console.WriteLine($"Error: Invalid override format '{item}'"); + continue; + } + + string[] parts ={ item.Substring(0, index), item.Substring(index + 1) }; + if (!(parts[1].StartsWith("'") && parts[1].EndsWith("'") && parts[1].Length >= 2)){ + Console.WriteLine($"Error: Invalid value format for '{item}'"); + continue; + } + + parts[1] = parts[1][1..^1]; // Removing the surrounding single quotes + int alreadyIndex = variables.FindIndex(a => a.Name == parts[0]); + + if (alreadyIndex > -1){ + if (variables[alreadyIndex].Type == "number"){ + if (!float.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out float numberValue)){ + Console.WriteLine($"Error: Wrong type for '{item}'"); + continue; + } + + variables[alreadyIndex].ReplaceWith = numberValue; + } else{ + variables[alreadyIndex].ReplaceWith = parts[1]; + } + } else{ + bool isNumber = float.TryParse(parts[1], NumberStyles.Any, CultureInfo.InvariantCulture, out float parsedNumber); + variables.Add(new Variable{ + Name = parts[0], + ReplaceWith = isNumber ? parsedNumber : (object)parts[1], + Type = isNumber ? "number" : "string" + }); + } + } + + return variables; + } + + public static string CleanupFilename(string filename){ + string fixingChar = "_"; + Regex illegalRe = new Regex(@"[\/\?<>\\:\*\|"":]"); // Illegal Characters on most Operating Systems + Regex controlRe = new Regex(@"[\x00-\x1f\x80-\x9f]"); // Unicode Control codes: C0 and C1 + Regex reservedRe = new Regex(@"^\.\.?$"); // Reserved filenames on Unix-based systems (".", "..") + Regex windowsReservedRe = new Regex(@"^(con|prn|aux|nul|com[0-9]|lpt[0-9])(\..*)?$", RegexOptions.IgnoreCase); + /* Reserved filenames in Windows ("CON", "PRN", "AUX", "NUL", "COM1"-"COM9", "LPT1"-"LPT9") + case-insensitively and with or without filename extensions. */ + Regex windowsTrailingRe = new Regex(@"[\. ]+$"); + + filename = illegalRe.Replace(filename, fixingChar); + filename = controlRe.Replace(filename, fixingChar); + filename = reservedRe.Replace(filename, fixingChar); + filename = windowsReservedRe.Replace(filename, fixingChar); + filename = windowsTrailingRe.Replace(filename, fixingChar); + + return filename; + } +} \ No newline at end of file diff --git a/Utils/HLS/HLSDownloader.cs b/Utils/HLS/HLSDownloader.cs new file mode 100644 index 0000000..07b65da --- /dev/null +++ b/Utils/HLS/HLSDownloader.cs @@ -0,0 +1,585 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Net.Http; +using System.Security.Cryptography; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using CRD.Downloader; +using CRD.Utils.Parser.Utils; +using CRD.Utils.Structs; +using Newtonsoft.Json; + +namespace CRD.Utils.HLS; + +public class HlsDownloader{ + private Data _data = new(); + + private CrunchyEpMeta _currentEpMeta; + private bool _isVideo; + private bool _isAudio; + + public HlsDownloader(HlsOptions options, CrunchyEpMeta meta, bool isVideo, bool isAudio){ + if (options == null || options.M3U8Json == null || options.M3U8Json.Segments == null){ + throw new Exception("Playlist is empty"); + } + + _currentEpMeta = meta; + + _isVideo = isVideo; + _isAudio = isAudio; + + if (options?.M3U8Json != null) + _data = new Data{ + Parts = new PartsData{ + First = options.M3U8Json.MediaSequence ?? 0, + Total = options.M3U8Json.Segments?.Count, + Completed = 0, + }, + M3U8Json = options.M3U8Json, + OutputFile = options.Output ?? "stream.ts", + Threads = options.Threads ?? 5, + Retries = options.Retries ?? 4, + Offset = options.Offset ?? 0, + BaseUrl = options.BaseUrl, + SkipInit = options.SkipInit ?? false, + Timeout = options.Timeout ?? 60 * 1000, + CheckPartLength = true, + IsResume = options.Offset.HasValue && options.Offset.Value > 0, + BytesDownloaded = 0, + WaitTime = options.FsRetryTime ?? 1000 * 5, + Override = options.Override, + DateStart = 0 + }; + } + + + public async Task<(bool Ok, PartsData Parts)> Download(){ + string fn = _data.OutputFile ?? string.Empty; + + if (File.Exists(fn) && File.Exists($"{fn}.resume") && _data.Offset < 1){ + try{ + Console.WriteLine("Resume data found! Trying to resume..."); + string resumeFileContent = File.ReadAllText($"{fn}.resume"); + var resumeData = JsonConvert.DeserializeObject(resumeFileContent); + + if (resumeData != null){ + if (resumeData.Total == _data.M3U8Json?.Segments.Count && + resumeData.Completed != resumeData.Total && + !double.IsNaN(resumeData.Completed)){ + Console.WriteLine("Resume data is ok!"); + _data.Offset = resumeData.Completed; + _data.IsResume = true; + } else{ + Console.WriteLine("Resume data is wrong!"); + Console.WriteLine($"Resume: {{ total: {resumeData.Total}, dled: {resumeData.Completed} }}, " + + $"Current: {{ total: {_data.M3U8Json?.Segments.Count} }}"); + } + } else{ + Console.WriteLine("Resume data is wrong!"); + Console.WriteLine($"Resume: {{ total: {resumeData?.Total}, dled: {resumeData?.Completed} }}, " + + $"Current: {{ total: {_data.M3U8Json?.Segments.Count} }}"); + } + } catch (Exception e){ + Console.WriteLine("Resume failed, downloading will not be resumed!"); + Console.WriteLine(e.Message); + } + } + + // Check if the file exists and it is not a resume download + if (File.Exists(fn) && !_data.IsResume){ + string rwts = _data.Override ?? "Y"; + rwts = rwts.ToUpper(); // ?? "N" + + if (rwts.StartsWith("Y")){ + Console.WriteLine($"Deleting «{fn}»..."); + File.Delete(fn); + } else if (rwts.StartsWith("C")){ + return (Ok: true, _data.Parts); + } else{ + return (Ok: false, _data.Parts); + } + } + + // Show output filename based on whether it's a resume + if (File.Exists(fn) && _data.IsResume){ + Console.WriteLine($"Adding content to «{fn}»..."); + } else{ + Console.WriteLine($"Saving stream to «{fn}»..."); + } + + + // Start time + _data.DateStart = DateTimeOffset.Now.ToUnixTimeMilliseconds(); + + if (_data.M3U8Json != null){ + List segments = _data.M3U8Json.Segments; + + // map has init uri outside is none init uri + // Download init part + if (segments[0].map != null && _data.Offset == 0 && !_data.SkipInit){ + Console.WriteLine("Download and save init part..."); + Segment initSeg = new Segment(); + initSeg.Uri = ObjectUtilities.GetMemberValue(segments[0].map, "uri"); + initSeg.Key = ObjectUtilities.GetMemberValue(segments[0].map, "key"); + initSeg.ByteRange = ObjectUtilities.GetMemberValue(segments[0].map, "byteRange"); + + if (ObjectUtilities.GetMemberValue(segments[0], "key") != null){ + initSeg.Key = segments[0].Key; + } + + try{ + var initDl = await DownloadPart(initSeg, 0, 0); + await File.WriteAllBytesAsync(fn, initDl); + await File.WriteAllTextAsync($"{fn}.resume", JsonConvert.SerializeObject(new{ completed = 0, total = segments.Count })); + Console.WriteLine("Init part downloaded."); + } catch (Exception e){ + Console.Error.WriteLine($"Part init download error:\n\t{e.Message}"); + return (false, this._data.Parts); + } + } else if (segments[0].map != null && this._data.Offset == 0 && this._data.SkipInit){ + Console.WriteLine("Skipping init part can lead to broken video!"); + } + + // Resuming ... + if (_data.Offset > 0){ + segments = segments.GetRange(_data.Offset, segments.Count - _data.Offset); + Console.WriteLine($"Resuming download from part {_data.Offset + 1}..."); + _data.Parts.Completed = _data.Offset; + } + + for (int p = 0; p < Math.Ceiling((double)segments.Count / _data.Threads); p++){ + int offset = p * _data.Threads; + int dlOffset = Math.Min(offset + _data.Threads, segments.Count); + + int errorCount = 0; + Dictionary keyTasks = new Dictionary(); + Dictionary> partTasks = new Dictionary>(); + List results = new List(new byte[dlOffset - offset][]); + + // Download keys + for (int px = offset; px < dlOffset; px++){ + var curSegment = segments[px]; + var key = ObjectUtilities.GetMemberValue(curSegment, "key"); + if (key != null && !keyTasks.ContainsKey(key?.Uri) && !_data.Keys.ContainsKey(key?.Uri)){ + keyTasks[curSegment.Key.Uri] = DownloadKey(curSegment.Key, px, _data.Offset); + } + } + + try{ + await Task.WhenAll(keyTasks.Values); + } catch (Exception ex){ + Console.WriteLine($"Error downloading keys: {ex.Message}"); + throw; + } + + for (int px = offset; px < dlOffset && px < segments.Count; px++){ + var segment = new Segment(); + segment.Uri = ObjectUtilities.GetMemberValue(segments[px], "uri"); + segment.Key = ObjectUtilities.GetMemberValue(segments[px], "key"); + segment.ByteRange = ObjectUtilities.GetMemberValue(segments[px], "byteRange"); + partTasks[px] = DownloadPart(segment, px, _data.Offset); + } + + while (partTasks.Count > 0){ + Task completedTask = await Task.WhenAny(partTasks.Values); + int completedIndex = -1; + foreach (var task in partTasks){ + if (task.Value == completedTask){ + completedIndex = task.Key; + break; + } + } + + if (completedIndex != -1){ + try{ + byte[] result = await completedTask; + results[completedIndex - offset] = result; + partTasks.Remove(completedIndex); + } catch (Exception ex){ + Console.Error.WriteLine($"Part {completedIndex + 1 + _data.Offset} download error:\n\t{ex.Message}"); + partTasks.Remove(completedIndex); + errorCount++; + } + } + } + + if (errorCount > 0){ + Console.Error.WriteLine($"{errorCount} parts not downloaded"); + return (false, _data.Parts); + } + + foreach (var part in results){ + int attempt = 0; + bool writeSuccess = false; + + while (attempt < 3 && !writeSuccess){ + try{ + using (var stream = new FileStream(fn, FileMode.Append, FileAccess.Write, FileShare.None)){ + await stream.WriteAsync(part, 0, part.Length); + } + + writeSuccess = true; + } catch (Exception ex){ + Console.Error.WriteLine(ex); + Console.Error.WriteLine($"Unable to write to file '{fn}' (Attempt {attempt + 1}/3)"); + Console.WriteLine($"Waiting {Math.Round(_data.WaitTime / 1000.0)}s before retrying"); + await Task.Delay(_data.WaitTime); + attempt++; + } + } + + if (!writeSuccess){ + Console.Error.WriteLine($"Unable to write content to '{fn}'."); + return (Ok: false, _data.Parts); + } + } + + int totalSeg = _data.Parts.Total; // + _data.Offset + int downloadedSeg = Math.Min(dlOffset, totalSeg); + _data.Parts.Completed = downloadedSeg + _data.Offset; // + + var dataLog = GetDownloadInfo(_data.DateStart, _data.Parts.Completed, totalSeg, _data.BytesDownloaded); + + // Save resume data to file + string resumeDataJson = JsonConvert.SerializeObject(new{ _data.Parts.Completed, Total = totalSeg }); + File.WriteAllText($"{fn}.resume", resumeDataJson); + + // Log progress + Console.WriteLine($"{_data.Parts.Completed} of {totalSeg} parts downloaded [{dataLog.Percent}%] ({FormatTime(dataLog.Time / 1000)} | {dataLog.DownloadSpeed / 1000000.0:F2}Mb/s)"); + + _currentEpMeta.DownloadProgress = new DownloadProgress(){ + IsDownloading = true, + Percent = dataLog.Percent, + Time = dataLog.Time, + DownloadSpeed = dataLog.DownloadSpeed, + Doing = _isAudio ? "Downloading Audio" : (_isVideo ? "Downloading Video" : "") + }; + + if (!Crunchyroll.Instance.Queue.Contains(_currentEpMeta)){ + return (Ok: false, _data.Parts); + } + + Crunchyroll.Instance.Queue.Refresh(); + + while (_currentEpMeta.Paused){ + await Task.Delay(500); + if (!Crunchyroll.Instance.Queue.Contains(_currentEpMeta)){ + return (Ok: false, _data.Parts); + } + } + } + } + + return (Ok: true, _data.Parts); + } + + public static Info GetDownloadInfo(long dateStartUnix, int partsDownloaded, int partsTotal, long downloadedBytes){ + // Convert Unix timestamp to DateTime + DateTime dateStart = DateTimeOffset.FromUnixTimeMilliseconds(dateStartUnix).UtcDateTime; + double dateElapsed = (DateTime.UtcNow - dateStart).TotalMilliseconds; + + // Calculate percentage + int percentFixed = (int)((double)partsDownloaded / partsTotal * 100); + int percent = percentFixed < 100 ? percentFixed : (partsTotal == partsDownloaded ? 100 : 99); + + // Calculate remaining time estimate + double remainingTime = dateElapsed * (partsTotal / (double)partsDownloaded - 1); + + // Calculate download speed (bytes per second) + double downloadSpeed = downloadedBytes / (dateElapsed / 1000); + + return new Info{ + Percent = percent, + Time = remainingTime, + DownloadSpeed = downloadSpeed + }; + } + + private string FormatTime(double seconds){ + TimeSpan timeSpan = TimeSpan.FromSeconds(seconds); + return timeSpan.ToString(@"hh\:mm\:ss"); + } + + public async Task DownloadPart(Segment seg, int segIndex, int segOffset){ + string sUri = GetUri(seg.Uri ?? "", _data.BaseUrl); + byte[]? dec = null; + int p = segIndex; + try{ + byte[]? part; + if (seg.Key != null){ + var decipher = await GetKey(seg.Key, p, segOffset); + part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary(), segOffset, false, _data.Timeout, _data.Retries); + var partContent = part; + using (decipher){ + if (partContent != null) dec = decipher.TransformFinalBlock(partContent, 0, partContent.Length); + } + + if (dec != null) _data.BytesDownloaded += dec.Length; + } else{ + part = await GetData(p, sUri, seg.ByteRange != null ? seg.ByteRange.ToDictionary() : new Dictionary(), segOffset, false, _data.Timeout, _data.Retries); + dec = part; + if (dec != null) _data.BytesDownloaded += dec.Length; + } + } catch (Exception ex){ + throw new Exception($"Error at segment {p}: {ex.Message}", ex); + } + + return dec ?? Array.Empty(); + } + + private async Task GetKey(Key key, int segIndex, int segOffset){ + string kUri = GetUri(key.Uri ?? "", _data.BaseUrl); + int p = segIndex; + if (!_data.Keys.ContainsKey(kUri)){ + try{ + var rkey = await DownloadKey(key, segIndex, segOffset); + if (rkey == null) + throw new Exception("Failed to download key"); + _data.Keys[kUri] = rkey; + } catch (Exception ex){ + throw new Exception($"Error at segment {p}: {ex.Message}", ex); + } + } + + byte[] iv = new byte[16]; + var ivs = key.Iv; //?? new List{ 0, 0, 0, p + 1 } + for (int i = 0; i < ivs.Count; i++){ + byte[] bytes = BitConverter.GetBytes(ivs[i]); + + // Ensure the bytes are in big-endian order + if (BitConverter.IsLittleEndian){ + Array.Reverse(bytes); + } + + bytes.CopyTo(iv, i * 4); + } + + ICryptoTransform decryptor; + using (Aes aes = Aes.Create()){ + aes.Key = _data.Keys[kUri]; + aes.IV = iv; + aes.Mode = CipherMode.CBC; + aes.Padding = PaddingMode.PKCS7; + decryptor = aes.CreateDecryptor(); + } + + // var decryptor = new AesCryptoServiceProvider{ + // Key = _data.Keys[kUri], + // IV = iv, + // Mode = CipherMode.CBC, + // Padding = PaddingMode.PKCS7 + // }.CreateDecryptor(); + return decryptor; + } + + public async Task DownloadKey(Key key, int segIndex, int segOffset){ + string kUri = GetUri(key.Uri ?? "", _data.BaseUrl); + if (!_data.Keys.ContainsKey(kUri)){ + try{ + var rkey = await GetData(segIndex, kUri, new Dictionary(), segOffset, true, _data.Timeout, _data.Retries); + if (rkey == null || rkey.Length != 16){ + throw new Exception("Key not fully downloaded or is incorrect."); + } + + _data.Keys[kUri] = rkey; + return rkey; + } catch (Exception ex){ + ex.Data["SegmentIndex"] = segIndex; // Adding custom data to the exception + throw; + } + } + + return _data.Keys[kUri]; + } + + public async Task GetData(int partIndex, string uri, IDictionary headers, int segOffset, bool isKey, int timeout, int retryCount){ + // Handle local file URI + if (uri.StartsWith("file://")){ + string path = new Uri(uri).LocalPath; + return File.ReadAllBytes(path); + } + + // Setup request headers + HttpRequestMessage request = new HttpRequestMessage(HttpMethod.Get, uri); + foreach (var header in headers){ + request.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + // Set default user-agent if not provided + if (!request.Headers.Contains("User-Agent")){ + request.Headers.Add("User-Agent", "Mozilla/5.0 (Windows NT 10.0; WOW64; rv:70.0) Gecko/20100101 Firefox/70.0"); + } + + return await SendRequestWithRetry(request, partIndex, segOffset, isKey, retryCount); + } + + private async Task SendRequestWithRetry(HttpRequestMessage requestPara, int partIndex, int segOffset, bool isKey, int retryCount){ + HttpResponseMessage response; + for (int attempt = 0; attempt < retryCount + 1; attempt++){ + using (var request = CloneHttpRequestMessage(requestPara)){ + try{ + response = await HttpClientReq.Instance.GetHttpClient().SendAsync(request, HttpCompletionOption.ResponseContentRead); + response.EnsureSuccessStatusCode(); + return await response.Content.ReadAsByteArrayAsync(); + } catch (HttpRequestException ex){ + // Log retry attempts + string partType = isKey ? "Key" : "Part"; + int partIndx = partIndex + 1 + segOffset; + Console.WriteLine($"{partType} {partIndx}: Attempt {attempt + 1} to retrieve data failed."); + Console.WriteLine($"\tError: {ex.Message}"); + if (attempt == retryCount) + throw; // rethrow after last retry + } + } + } + + return null; // Should not reach here + } + + private HttpRequestMessage CloneHttpRequestMessage(HttpRequestMessage originalRequest){ + var clone = new HttpRequestMessage(originalRequest.Method, originalRequest.RequestUri){ + Content = originalRequest.Content?.Clone(), + Version = originalRequest.Version + }; + foreach (var header in originalRequest.Headers){ + clone.Headers.TryAddWithoutValidation(header.Key, header.Value); + } + + foreach (var property in originalRequest.Properties){ + clone.Properties.Add(property); + } + + return clone; + } + + + private static string GetUri(string uri, string? baseUrl = null){ + bool httpUri = Regex.IsMatch(uri, @"^https?:", RegexOptions.IgnoreCase); + if (string.IsNullOrEmpty(baseUrl) && !httpUri){ + throw new ArgumentException("No base and not http(s) uri"); + } else if (httpUri){ + return uri; + } + + return baseUrl + uri; + } +} + +public static class HttpContentExtensions{ + public static HttpContent Clone(this HttpContent content){ + if (content == null) return null; + var memStream = new MemoryStream(); + content.CopyToAsync(memStream).Wait(); + memStream.Position = 0; + var newContent = new StreamContent(memStream); + foreach (var header in content.Headers){ + newContent.Headers.Add(header.Key, header.Value); + } + + return newContent; + } +} + +public class Info{ + public int Percent{ get; set; } + public double Time{ get; set; } // Remaining time estimate + public double DownloadSpeed{ get; set; } // Bytes per second +} + +public class ResumeData{ + public int Total{ get; set; } + public int Completed{ get; set; } +} + +public class M3U8Json{ + public dynamic Segments{ get; set; } = new List(); + public int? MediaSequence{ get; set; } +} + +public class Segment{ + public string? Uri{ get; set; } + public Key? Key{ get; set; } + public ByteRange? ByteRange{ get; set; } +} + +public class Key{ + public string? Uri{ get; set; } + public List Iv{ get; set; } = new List(); +} + +public class ByteRange{ + public long Offset{ get; set; } + public long Length{ get; set; } + + public IDictionary ToDictionary(){ + return new Dictionary{ + { "Offset", Offset.ToString() }, + { "Length", Length.ToString() } + }; + } +} + +public class HlsOptions{ + public M3U8Json? M3U8Json{ get; set; } + public string? Output{ get; set; } + public int? Threads{ get; set; } + public int? Retries{ get; set; } + public int? Offset{ get; set; } + public string? BaseUrl{ get; set; } + public bool? SkipInit{ get; set; } + public int? Timeout{ get; set; } + public int? FsRetryTime{ get; set; } + public string? Override{ get; set; } +} + +public class Data{ + public PartsData Parts{ get; set; } = new PartsData(); + public M3U8Json? M3U8Json{ get; set; } + public string? OutputFile{ get; set; } + public int Threads{ get; set; } + public int Retries{ get; set; } + public int Offset{ get; set; } + public string? BaseUrl{ get; set; } + public bool SkipInit{ get; set; } + public Dictionary Keys{ get; set; } = new Dictionary(); // Object can be Buffer or string + public int Timeout{ get; set; } + public bool CheckPartLength{ get; set; } + public bool IsResume{ get; set; } + public long BytesDownloaded{ get; set; } + public int WaitTime{ get; set; } + public string? Override{ get; set; } + public long DateStart{ get; set; } +} + +public class ProgressData{ + public int Total{ get; set; } + + public int Cur{ get; set; } + + // Considering the dual type in TypeScript (number|string), you might opt for string in C# to accommodate both numeric and text representations. + // Alternatively, you could use a custom setter to handle numeric inputs as strings, or define two separate properties if the usage context is clear. + public string? Percent{ get; set; } + public double Time{ get; set; } // Assuming this represents a duration or timestamp, you might consider TimeSpan or DateTime based on context. + public double DownloadSpeed{ get; set; } + public long Bytes{ get; set; } +} + +public class DownloadInfo{ + public string? Image{ get; set; } + + public Parent? Parent{ get; set; } + public string? Title{ get; set; } + public LanguageItem? Language{ get; set; } + public string? FileName{ get; set; } +} + +public class Parent{ + public string? Title{ get; set; } +} + +public class PartsData{ + public int First{ get; set; } + public int Total{ get; set; } + public int Completed{ get; set; } +} \ No newline at end of file diff --git a/Utils/Helpers.cs b/Utils/Helpers.cs new file mode 100644 index 0000000..edfd4bc --- /dev/null +++ b/Utils/Helpers.cs @@ -0,0 +1,80 @@ +using System; +using System.Diagnostics; +using System.Runtime.Serialization; +using System.Threading.Tasks; +using Newtonsoft.Json; + +namespace CRD.Utils; + +public class Helpers{ + /// + /// Deserializes a JSON string into a specified .NET type. + /// + /// The type of the object to deserialize to. + /// The JSON string to deserialize. + /// The settings for deserialization if null default settings will be used + /// The deserialized object of type T. + public static T? Deserialize(string json,JsonSerializerSettings? serializerSettings){ + try{ + return JsonConvert.DeserializeObject(json,serializerSettings); + } catch (JsonException ex){ + Console.WriteLine($"Error deserializing JSON: {ex.Message}"); + throw; + } + } + + public static Locale ConvertStringToLocale(string? value){ + foreach (Locale locale in Enum.GetValues(typeof(Locale))){ + var type = typeof(Locale); + var memInfo = type.GetMember(locale.ToString()); + var attributes = memInfo[0].GetCustomAttributes(typeof(EnumMemberAttribute), false); + var description = ((EnumMemberAttribute)attributes[0]).Value; + + if (description == value){ + return locale; + } + } + + return Locale.DefaulT; // Return default if not found + } + + public static string GenerateSessionId(){ + // Get UTC milliseconds + var utcNow = DateTime.UtcNow; + var milliseconds = utcNow.Millisecond.ToString().PadLeft(3, '0'); + + // Get a high-resolution timestamp + long timestamp = Stopwatch.GetTimestamp(); + double timestampToMilliseconds = (double)timestamp / Stopwatch.Frequency * 1000; + string highResTimestamp = timestampToMilliseconds.ToString("F0").PadLeft(13, '0'); + + return milliseconds + highResTimestamp; + } + + public static async Task<(bool IsOk, int ErrorCode)> ExecuteCommandAsync(string type, string bin, string command){ + using (var process = new Process()){ + process.StartInfo.FileName = bin; + process.StartInfo.Arguments = command; + process.StartInfo.RedirectStandardOutput = true; + process.StartInfo.RedirectStandardError = true; + process.StartInfo.UseShellExecute = false; + process.StartInfo.CreateNoWindow = true; + + process.Start(); + + // To log the output or errors, you might use process.StandardOutput.ReadToEndAsync() + // string output = await process.StandardOutput.ReadToEndAsync(); + string errors = await process.StandardError.ReadToEndAsync(); + + await process.WaitForExitAsync(); + + if (!string.IsNullOrEmpty(errors)) + Console.WriteLine($"Error: {errors}"); + + // Define success condition more appropriately based on the application + bool isSuccess = process.ExitCode == 0; + + return (IsOk: isSuccess, ErrorCode: process.ExitCode); + } + } +} \ No newline at end of file diff --git a/Utils/Http/HttpClientReq.cs b/Utils/Http/HttpClientReq.cs new file mode 100644 index 0000000..bf1b4d5 --- /dev/null +++ b/Utils/Http/HttpClientReq.cs @@ -0,0 +1,147 @@ +using System; +using System.Net; +using System.Net.Http; +using System.Collections.Generic; +using System.Collections.Specialized; +using System.Net.Http.Headers; +using System.Threading.Tasks; +using CRD.Downloader; + +namespace CRD.Utils; + +public class HttpClientReq{ + #region Singelton + + private static HttpClientReq? instance; + private static readonly object padlock = new object(); + + public static HttpClientReq Instance{ + get{ + if (instance == null){ + lock (padlock){ + if (instance == null){ + instance = new HttpClientReq(); + } + } + } + + return instance; + } + } + + #endregion + + + private HttpClient client; + private HttpClientHandler handler; + + public HttpClientReq(){ + // Initialize the HttpClientHandler + handler = new HttpClientHandler(); + handler.CookieContainer = new CookieContainer(); + handler.UseCookies = true; + + // Initialize the HttpClient with the handler + client = new HttpClient(handler); + + client.DefaultRequestHeaders.UserAgent.ParseAdd("Mozilla/5.0 (Windows NT 10.0; Win64; x64; rv:124.0) Gecko/20100101 Firefox/124.0"); + + // // Set Accept headers + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("text/html")); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xhtml+xml")); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/xml", 0.9)); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/avif")); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/webp")); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("image/apng")); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("*/*", 0.8)); + // client.DefaultRequestHeaders.Accept.Add(new MediaTypeWithQualityHeaderValue("application/signed-exchange", 0.7)); + // + // // Set Accept-Language + // client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en-US")); + // client.DefaultRequestHeaders.AcceptLanguage.Add(new StringWithQualityHeaderValue("en", 0.9)); + // + // // Set Cache-Control and Pragma for no caching + // client.DefaultRequestHeaders.CacheControl = new CacheControlHeaderValue{ NoCache = true }; + // client.DefaultRequestHeaders.Pragma.Add(new NameValueHeaderValue("no-cache")); + // + // // Set other headers + // client.DefaultRequestHeaders.Add("sec-ch-ua", "\"Google Chrome\";v=\"123\", \"Not:A-Brand\";v=\"8\", \"Chromium\";v=\"123\""); + // client.DefaultRequestHeaders.Add("sec-ch-ua-mobile", "?0"); + // client.DefaultRequestHeaders.Add("sec-ch-ua-platform", "\"Windows\""); + // client.DefaultRequestHeaders.Add("sec-fetch-dest", "document"); + // client.DefaultRequestHeaders.Add("sec-fetch-mode", "navigate"); + // client.DefaultRequestHeaders.Add("sec-fetch-site", "none"); + // client.DefaultRequestHeaders.Add("sec-fetch-user", "?1"); + // client.DefaultRequestHeaders.Add("upgrade-insecure-requests", "1"); + } + + public void SetETPCookie(string refresh_token){ + var cookie = new Cookie("etp_rt", refresh_token){ + Domain = "crunchyroll.com", + Path = "/", + }; + + handler.CookieContainer.Add(cookie); + } + + public async Task<(bool IsOk, string ResponseContent)> SendHttpRequest(HttpRequestMessage request){ + try{ + HttpResponseMessage response = await client.SendAsync(request); + + response.EnsureSuccessStatusCode(); + + string content = await response.Content.ReadAsStringAsync(); + return (IsOk: true, ResponseContent: content); + } catch (Exception e){ + Console.WriteLine(e); + return (IsOk: false, ResponseContent: String.Empty); + } + } + + public static HttpRequestMessage CreateRequestMessage(string uri, HttpMethod requestMethod, bool authHeader, bool disableDrmHeader, NameValueCollection? query){ + UriBuilder uriBuilder = new UriBuilder(uri); + + if (query != null){ + uriBuilder.Query = query.ToString(); + } + + var request = new HttpRequestMessage(requestMethod, uriBuilder.ToString()); + + if (authHeader){ + request.Headers.Authorization = new AuthenticationHeaderValue("Bearer", Crunchyroll.Instance.Token?.access_token); + } + + if (disableDrmHeader){ + request.Headers.Add("X-Cr-Disable-Drm", "true"); + } + + + return request; + } + + public HttpClient GetHttpClient(){ + return client; + } + +} + +public static class Api{ + public static readonly string ApiBeta = "https://beta-api.crunchyroll.com"; + public static readonly string ApiN = "https://crunchyroll.com"; + + public static readonly string BetaAuth = ApiBeta + "/auth/v1/token"; + public static readonly string BetaProfile = ApiBeta + "/accounts/v1/me/profile"; + public static readonly string BetaCmsToken = ApiBeta + "/index/v2"; + public static readonly string Search = ApiBeta + "/content/v2/discover/search"; + public static readonly string Cms = ApiBeta + "/content/v2/cms"; + public static readonly string BetaBrowse = ApiBeta + "/content/v1/browse"; + public static readonly string BetaCms = ApiBeta + "/cms/v2"; + + + public static readonly string CmsN = ApiN + "/content/v2/cms"; + + + public static readonly string authBasic = "bm9haWhkZXZtXzZpeWcwYThsMHE6"; + public static readonly string authBasicMob = "bm12anNoZmtueW14eGtnN2ZiaDk6WllJVnJCV1VQYmNYRHRiRDIyVlNMYTZiNFdRb3Mzelg="; + public static readonly string authBasicSwitch = "dC1rZGdwMmg4YzNqdWI4Zm4wZnE6eWZMRGZNZnJZdktYaDRKWFMxTEVJMmNDcXUxdjVXYW4="; +} \ No newline at end of file diff --git a/Utils/JsonConv/LocaleConverter.cs b/Utils/JsonConv/LocaleConverter.cs new file mode 100644 index 0000000..6ee7ec7 --- /dev/null +++ b/Utils/JsonConv/LocaleConverter.cs @@ -0,0 +1,38 @@ +using System; +using System.Reflection; +using System.Runtime.Serialization; +using Newtonsoft.Json; + +namespace CRD.Utils.JsonConv; + +public class LocaleConverter : JsonConverter{ + public override bool CanConvert(Type objectType){ + return objectType == typeof(Locale); + } + + public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer){ + if (reader.TokenType == JsonToken.Null) + return Locale.Unknown; + + var value = reader.Value?.ToString(); + + foreach (Locale locale in Enum.GetValues(typeof(Locale))){ + FieldInfo fi = typeof(Locale).GetField(locale.ToString()); + EnumMemberAttribute[] attributes = (EnumMemberAttribute[])fi.GetCustomAttributes(typeof(EnumMemberAttribute), false); + if (attributes.Length > 0 && attributes[0].Value == value) + return locale; + } + + return Locale.Unknown; // Default to defaulT if no match is found + } + + public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer){ + FieldInfo? fi = value?.GetType().GetField(value.ToString() ?? string.Empty); + EnumMemberAttribute[] attributes = (EnumMemberAttribute[])fi.GetCustomAttributes(typeof(EnumMemberAttribute), false); + + if (attributes.Length > 0 && !string.IsNullOrEmpty(attributes[0].Value)) + writer.WriteValue(attributes[0].Value); + else + writer.WriteValue(value?.ToString()); + } +} \ No newline at end of file diff --git a/Utils/Muxing/FontsManager.cs b/Utils/Muxing/FontsManager.cs new file mode 100644 index 0000000..5127016 --- /dev/null +++ b/Utils/Muxing/FontsManager.cs @@ -0,0 +1,143 @@ +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using System.Text.RegularExpressions; +using CRD.Utils.Structs; + +namespace CRD.Utils.Muxing; + +public class FontsManager{ + #region Singelton + + private static FontsManager? instance; + private static readonly object padlock = new object(); + + public static FontsManager Instance{ + get{ + if (instance == null){ + lock (padlock){ + if (instance == null){ + instance = new FontsManager(); + } + } + } + + return instance; + } + } + + #endregion + + public Dictionary> Fonts{ get; private set; } = new(){ + { "Adobe Arabic", new List{ "AdobeArabic-Bold.otf" } }, + { "Andale Mono", new List{ "andalemo.ttf" } }, + { "Arial", new List{ "arial.ttf", "arialbd.ttf", "arialbi.ttf", "ariali.ttf" } }, + { "Arial Unicode MS", new List{ "arialuni.ttf" } }, + { "Arial Black", new List{ "ariblk.ttf" } }, + { "Comic Sans MS", new List{ "comic.ttf", "comicbd.ttf" } }, + { "Courier New", new List{ "cour.ttf", "courbd.ttf", "courbi.ttf", "couri.ttf" } }, + { "DejaVu LGC Sans Mono", new List{ "DejaVuLGCSansMono-Bold.ttf", "DejaVuLGCSansMono-BoldOblique.ttf", "DejaVuLGCSansMono-Oblique.ttf", "DejaVuLGCSansMono.ttf" } }, + { "DejaVu Sans", new List{ "DejaVuSans-Bold.ttf", "DejaVuSans-BoldOblique.ttf", "DejaVuSans-ExtraLight.ttf", "DejaVuSans-Oblique.ttf", "DejaVuSans.ttf" } }, + { "DejaVu Sans Condensed", new List{ "DejaVuSansCondensed-Bold.ttf", "DejaVuSansCondensed-BoldOblique.ttf", "DejaVuSansCondensed-Oblique.ttf", "DejaVuSansCondensed.ttf" } }, + { "DejaVu Sans Mono", new List{ "DejaVuSansMono-Bold.ttf", "DejaVuSansMono-BoldOblique.ttf", "DejaVuSansMono-Oblique.ttf", "DejaVuSansMono.ttf" } }, + { "Georgia", new List{ "georgia.ttf", "georgiab.ttf", "georgiai.ttf", "georgiaz.ttf" } }, + { "Impact", new List{ "impact.ttf" } }, + { "Rubik Black", new List{ "Rubik-Black.ttf", "Rubik-BlackItalic.ttf" } }, + { "Rubik", new List{ "Rubik-Bold.ttf", "Rubik-BoldItalic.ttf", "Rubik-Italic.ttf", "Rubik-Light.ttf", "Rubik-LightItalic.ttf", "Rubik-Medium.ttf", "Rubik-MediumItalic.ttf", "Rubik-Regular.ttf" } }, + { "Tahoma", new List{ "tahoma.ttf" } }, + { "Times New Roman", new List{ "times.ttf", "timesbd.ttf", "timesbi.ttf", "timesi.ttf" } }, + { "Trebuchet MS", new List{ "trebuc.ttf", "trebucbd.ttf", "trebucbi.ttf", "trebucit.ttf" } }, + { "Verdana", new List{ "verdana.ttf", "verdanab.ttf", "verdanai.ttf", "verdanaz.ttf" } }, + { "Webdings", new List{ "webdings.ttf" } }, + }; + + public string root = "https://static.crunchyroll.com/vilos-v2/web/vilos/assets/libass-fonts/"; + + public static List ExtractFontsFromAss(string ass){ + var lines = ass.Replace("\r", "").Split('\n'); + var styles = new List(); + + foreach (var line in lines){ + if (line.StartsWith("Style: ")){ + var parts = line.Split(','); + if (parts.Length > 1) + styles.Add(parts[1].Trim()); + } + } + + var fontMatches = Regex.Matches(ass, @"\\fn([^\\}]+)"); + foreach (Match match in fontMatches){ + if (match.Groups.Count > 1) + styles.Add(match.Groups[1].Value); + } + + return styles.Distinct().ToList(); // Using Linq to remove duplicates + } + + public Dictionary> GetDictFromKeyList(List keysList){ + + Dictionary> filteredDictionary = new Dictionary>(); + + foreach (string key in keysList){ + if (Fonts.TryGetValue(key, out var font)){ + filteredDictionary.Add(key, font); + } + } + + return filteredDictionary; + + } + + + public static string GetFontMimeType(string fontFile){ + if (Regex.IsMatch(fontFile, @"\.otf$")) + return "application/vnd.ms-opentype"; + else if (Regex.IsMatch(fontFile, @"\.ttf$")) + return "application/x-truetype-font"; + else + return "application/octet-stream"; + } + + public List MakeFontsList(string fontsDir, List subs){ + Dictionary> fontsNameList = new Dictionary>(); + List subsList = new List(); + List fontsList = new List(); + bool isNstr = true; + + foreach (var s in subs){ + foreach (var keyValuePair in s.Fonts){ + fontsNameList.Add(keyValuePair.Key,keyValuePair.Value); + } + subsList.Add(s.Language.Locale); + } + + if (subsList.Count > 0){ + Console.WriteLine("\nSubtitles: {0} (Total: {1})", string.Join(", ", subsList), subsList.Count); + isNstr = false; + } + + if (fontsNameList.Count > 0){ + Console.WriteLine((isNstr ? "\n" : "") + "Required fonts: {0} (Total: {1})", string.Join(", ", fontsNameList), fontsNameList.Count); + } + + foreach (var f in fontsNameList){ + if (Fonts.TryGetValue(f.Key, out var fontFiles)){ + foreach (var fontFile in fontFiles){ + string fontPath = Path.Combine(fontsDir, fontFile); + string mime = GetFontMimeType(fontFile); + if (File.Exists(fontPath) && new FileInfo(fontPath).Length != 0){ + fontsList.Add(new ParsedFont{ Name = fontFile, Path = fontPath, Mime = mime }); + } + } + } + } + + return fontsList; + } +} + +public class SubtitleFonts{ + public LanguageItem Language{ get; set; } + public Dictionary> Fonts{ get; set; } +} \ No newline at end of file diff --git a/Utils/Muxing/Merger.cs b/Utils/Muxing/Merger.cs new file mode 100644 index 0000000..aba1320 --- /dev/null +++ b/Utils/Muxing/Merger.cs @@ -0,0 +1,404 @@ +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Threading.Tasks; +using CRD.Utils.Structs; + +namespace CRD.Utils.Muxing; + +public class Merger{ + private MergerOptions options; + + public Merger(MergerOptions options){ + this.options = options; + if (this.options.SkipSubMux != null && this.options.SkipSubMux == true){ + this.options.Subtitles = new List(); + } + + if (this.options.VideoTitle != null && this.options.VideoTitle.Length > 0){ + this.options.VideoTitle = this.options.VideoTitle.Replace("\"", "'"); + } + } + + public string FFmpeg(){ + List args = new List(); + + List metaData = new List(); + + var index = 0; + var audioIndex = 0; + var hasVideo = false; + + if (!options.mp3){ + foreach (var vid in options.VideoAndAudio){ + if (vid.Delay != null && hasVideo){ + args.Add($"-itsoffset -{Math.Ceiling((double)vid.Delay * 1000)}ms"); + } + + args.Add($"-i \"{vid.Path}\""); + if (!hasVideo || options.KeepAllVideos == true){ + metaData.Add($"-map {index}:a -map {index}:v"); + metaData.Add($"-metadata:s:a:{audioIndex} language={vid.Language.Code}"); + metaData.Add($"-metadata:s:v:{index} title=\"{options.VideoTitle}\""); + hasVideo = true; + } else{ + metaData.Add($"-map {index}:a"); + metaData.Add($"-metadata:s:a:{audioIndex} language={vid.Language.Code}"); + } + + audioIndex++; + index++; + } + + foreach (var vid in options.OnlyVid){ + if (!hasVideo || options.KeepAllVideos == true){ + args.Add($"-i \"{vid.Path}\""); + metaData.Add($"-map {index} -map -{index}:a"); + metaData.Add($"-metadata:s:v:{index} title=\"{options.VideoTitle}\""); + hasVideo = true; + index++; + } + } + + foreach (var aud in options.OnlyAudio){ + args.Add($"-i \"{aud.Path}\""); + metaData.Add($"-map {index}"); + metaData.Add($"-metadata:s:a:{audioIndex} language={aud.Language.Code}"); + index++; + audioIndex++; + } + + foreach (var sub in options.Subtitles.Select((value, i) => new{ value, i })){ + if (sub.value.Delay != null){ + args.Add($"-itsoffset -{Math.Ceiling((double)sub.value.Delay * 1000)}ms"); + } + + args.Add($"-i \"{sub.value.File}\""); + } + + if (options.Output.EndsWith(".mkv", StringComparison.OrdinalIgnoreCase)){ + if (options.Fonts != null){ + int fontIndex = 0; + foreach (var font in options.Fonts){ + args.Add($"-attach {font.Path} -metadata:s:t:{fontIndex} mimetype={font.Mime}"); + fontIndex++; + } + } + } + + args.AddRange(metaData); + args.AddRange(options.Subtitles.Select((sub, subIndex) => $"-map {subIndex + index}")); + args.Add("-c:v copy"); + args.Add("-c:a copy"); + args.Add(options.Output.EndsWith(".mp4", StringComparison.OrdinalIgnoreCase) ? "-c:s mov_text" : "-c:s ass"); + args.AddRange(options.Subtitles.Select((sub, subindex) => + $"-metadata:s:s:{subindex} title=\"{sub.Language.Language ?? sub.Language.Name}{(sub.ClosedCaption == true ? $" {options.CcTag}" : "")}{(sub.Signs == true ? " Signs" : "")}\" -metadata:s:s:{subindex} language={sub.Language.Code}")); + if (options.Options.ffmpeg?.Count > 0){ + args.AddRange(options.Options.ffmpeg); + } + args.Add($"\"{options.Output}\""); + + return string.Join(" ", args); + } + + + args.Add($"-i \"{options.OnlyAudio[0].Path}\""); + args.Add("-acodec libmp3lame"); + args.Add("-ab 192k"); + args.Add($"\"{options.Output}\""); + return string.Join(" ", args); + } + + public string MkvMerge(){ + List args = new List(); + + bool hasVideo = false; + + args.Add($"-o \"{options.Output}\""); + if (options.Options.mkvmerge != null){ + args.AddRange(options.Options.mkvmerge); + } + + + foreach (var vid in options.OnlyVid){ + if (!hasVideo || options.KeepAllVideos == true){ + args.Add("--video-tracks 0"); + args.Add("--no-audio"); + + string trackName = $"{(options.VideoTitle ?? vid.Language.Name)}{(options.Simul == true ? " [Simulcast]" : " [Uncut]")}"; + args.Add($"--track-name 0:\"{trackName}\""); + args.Add($"--language 0:{vid.Language.Code}"); + + hasVideo = true; + args.Add($"\"{vid.Path}\""); + } + } + + foreach (var vid in options.VideoAndAudio){ + string audioTrackNum = options.InverseTrackOrder == true ? "0" : "1"; + string videoTrackNum = options.InverseTrackOrder == true ? "1" : "0"; + + if (vid.Delay.HasValue){ + double delay = vid.Delay ?? 0; + args.Add($"--sync {audioTrackNum}:-{Math.Ceiling(delay * 1000)}"); + } + + if (!hasVideo || options.KeepAllVideos == true){ + args.Add($"--video-tracks {videoTrackNum}"); + args.Add($"--audio-tracks {audioTrackNum}"); + + string trackName = $"{(options.VideoTitle ?? vid.Language.Name)}{(options.Simul == true ? " [Simulcast]" : " [Uncut]")}"; + args.Add($"--track-name 0:\"{trackName}\""); // Assuming trackName applies to video if present + args.Add($"--language {audioTrackNum}:{vid.Language.Code}"); + + if (options.Defaults.Audio.Code == vid.Language.Code){ + args.Add($"--default-track {audioTrackNum}"); + } else{ + args.Add($"--default-track {audioTrackNum}:0"); + } + + hasVideo = true; + } else{ + args.Add("--no-video"); + args.Add($"--audio-tracks {audioTrackNum}"); + + if (options.Defaults.Audio.Code == vid.Language.Code){ + args.Add($"--default-track {audioTrackNum}"); + } else{ + args.Add($"--default-track {audioTrackNum}:0"); + } + + args.Add($"--track-name {audioTrackNum}:\"{vid.Language.Name}\""); + args.Add($"--language {audioTrackNum}:{vid.Language.Code}"); + } + + args.Add($"\"{vid.Path}\""); + } + + foreach (var aud in options.OnlyAudio){ + string trackName = aud.Language.Name; + args.Add($"--track-name 0:\"{trackName}\""); + args.Add($"--language 0:{aud.Language.Code}"); + args.Add("--no-video"); + args.Add("--audio-tracks 0"); + + if (options.Defaults.Audio.Code == aud.Language.Code){ + args.Add("--default-track 0"); + } else{ + args.Add("--default-track 0:0"); + } + + args.Add($"\"{aud.Path}\""); + } + + if (options.Subtitles.Count > 0){ + foreach (var subObj in options.Subtitles){ + if (subObj.Delay.HasValue){ + double delay = subObj.Delay ?? 0; + args.Add($"--sync 0:-{Math.Ceiling(delay * 1000)}"); + } + + string trackNameExtra = subObj.ClosedCaption == true ? $" {options.CcTag}" : ""; + trackNameExtra += subObj.Signs == true ? " Signs" : ""; + + string trackName = $"0:\"{(subObj.Language.Language ?? subObj.Language.Name) + trackNameExtra}\""; + args.Add($"--track-name {trackName}"); + args.Add($"--language 0:\"{subObj.Language.Code}\""); + + if (options.Defaults.Sub.Code == subObj.Language.Code && subObj.ClosedCaption == false){ + args.Add("--default-track 0"); + } else{ + args.Add("--default-track 0:0"); + } + + args.Add($"\"{subObj.File}\""); + } + } else{ + args.Add("--no-subtitles"); + } + + if (options.Fonts != null && options.Fonts.Count > 0){ + foreach (var font in options.Fonts){ + args.Add($"--attachment-name \"{font.Name}\""); + args.Add($"--attachment-mime-type \"{font.Mime}\""); + args.Add($"--attach-file \"{font.Path}\""); + } + } else{ + args.Add("--no-attachments"); + } + + if (options.Chapters != null && options.Chapters.Count > 0){ + args.Add($"--chapters \"{options.Chapters[0].Path}\""); + } + + + return string.Join(" ", args); + } + + // public async Task CreateDelays(){ + // // Don't bother scanning if there is only 1 vna stream + // if (options.VideoAndAudio.Count > 1){ + // var bin = await YamlCfg.LoadBinCfg(); + // var vnas = this.options.VideoAndAudio; + // + // // Get and set durations on each videoAndAudio Stream + // foreach (var vna in vnas){ + // var streamInfo = await FFProbe(vna.Path, bin.FFProbe); + // var videoInfo = streamInfo.Streams.Where(stream => stream.CodecType == "video").FirstOrDefault(); + // vna.Duration = int.Parse(videoInfo.Duration); + // } + // + // // Sort videoAndAudio streams by duration (shortest first) + // vnas.Sort((a, b) => { + // if (a.Duration == 0 || b.Duration == 0) return -1; + // return a.Duration.CompareTo(b.Duration); + // }); + // + // // Set Delays + // var shortestDuration = vnas[0].Duration; + // foreach (var (vna, index) in vnas.Select((vna, index) => (vna, index))){ + // // Don't calculate the shortestDuration track + // if (index == 0){ + // if (!vna.IsPrimary) + // Console.WriteLine("Shortest video isn't primary, this might lead to problems with subtitles. Please report on github or discord if you experience issues."); + // continue; + // } + // + // if (vna.Duration > 0 && shortestDuration > 0){ + // // Calculate the tracks delay + // vna.Delay = Math.Ceiling((vna.Duration - shortestDuration) * 1000) / 1000; + // + // var subtitles = this.options.Subtitles.Where(sub => sub.Language.Code == vna.Lang.Code).ToList(); + // foreach (var (sub, subIndex) in subtitles.Select((sub, subIndex) => (sub, subIndex))){ + // if (vna.IsPrimary) + // subtitles[subIndex].Delay = vna.Delay; + // else if (sub.ClosedCaption) + // subtitles[subIndex].Delay = vna.Delay; + // } + // } + // } + // } + // } + + + public async Task Merge(string type, string bin){ + string command = type switch{ + "ffmpeg" => FFmpeg(), + "mkvmerge" => MkvMerge(), + _ => "" + }; + + if (string.IsNullOrEmpty(command)){ + Console.WriteLine("Unable to merge files."); + return; + } + + Console.WriteLine($"[{type}] Started merging"); + var result = await Helpers.ExecuteCommandAsync(type, bin, command); + + if (!result.IsOk && type == "mkvmerge" && result.ErrorCode == 1){ + Console.WriteLine($"[{type}] Mkvmerge finished with at least one warning"); + } else if (!result.IsOk){ + Console.WriteLine($"[{type}] Merging failed with exit code {result.ErrorCode}"); + } else{ + Console.WriteLine($"[{type} Done]"); + } + } + + + public void CleanUp(){ + // Combine all media file lists and iterate through them + var allMediaFiles = options.OnlyAudio.Concat(options.OnlyVid) + .Concat(options.VideoAndAudio).ToList(); + allMediaFiles.ForEach(file => DeleteFile(file.Path)); + allMediaFiles.ForEach(file => DeleteFile(file.Path + ".resume")); + + // Delete chapter files if any + options.Chapters?.ForEach(chapter => DeleteFile(chapter.Path)); + + // Delete subtitle files + options.Subtitles.ForEach(subtitle => DeleteFile(subtitle.File)); + } + + private void DeleteFile(string filePath){ + try{ + if (File.Exists(filePath)){ + File.Delete(filePath); + } + } catch (Exception ex){ + Console.WriteLine($"Failed to delete file {filePath}. Error: {ex.Message}"); + // Handle exceptions if you need to log them or throw + } + } +} + +public class MergerInput{ + public string Path{ get; set; } + public LanguageItem Language{ get; set; } + public int? Duration{ get; set; } + public int? Delay{ get; set; } + public bool? IsPrimary{ get; set; } +} + +public class SubtitleInput{ + public LanguageItem Language{ get; set; } + public string File{ get; set; } + public bool? ClosedCaption{ get; set; } + public bool? Signs{ get; set; } + public int? Delay{ get; set; } +} + +public class ParsedFont{ + public string Name{ get; set; } + public string Path{ get; set; } + public string Mime{ get; set; } +} + +public class CrunchyMuxOptions{ + public string Output{ get; set; } + public bool? SkipSubMux{ get; set; } + public bool? KeepAllVideos{ get; set; } + public bool? Novids{ get; set; } + public bool Mp4{ get; set; } + public string ForceMuxer{ get; set; } + public bool? NoCleanup{ get; set; } + public string VideoTitle{ get; set; } + public List FfmpegOptions{ get; set; } = new List(); + public List MkvmergeOptions{ get; set; } = new List(); + public LanguageItem DefaultSub{ get; set; } + public LanguageItem DefaultAudio{ get; set; } + public string CcTag{ get; set; } + public bool SyncTiming{ get; set; } +} + +public class MergerOptions{ + public List VideoAndAudio{ get; set; } = new List(); + public List OnlyVid{ get; set; } = new List(); + public List OnlyAudio{ get; set; } = new List(); + public List Subtitles{ get; set; } = new List(); + public List Chapters{ get; set; } = new List(); + public string CcTag{ get; set; } + public string Output{ get; set; } + public string VideoTitle{ get; set; } + public bool? Simul{ get; set; } + public bool? InverseTrackOrder{ get; set; } + public bool? KeepAllVideos{ get; set; } + public List Fonts{ get; set; } = new List(); + public bool? SkipSubMux{ get; set; } + public MuxOptions Options{ get; set; } + public Defaults Defaults{ get; set; } + + public bool mp3{ get; set; } +} + +public class MuxOptions{ + public List? ffmpeg{ get; set; } + public List? mkvmerge{ get; set; } +} + +public class Defaults{ + public LanguageItem Audio{ get; set; } + public LanguageItem Sub{ get; set; } +} \ No newline at end of file diff --git a/Utils/Parser/DashParser.cs b/Utils/Parser/DashParser.cs new file mode 100644 index 0000000..15ec54f --- /dev/null +++ b/Utils/Parser/DashParser.cs @@ -0,0 +1,58 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Xml; +using CRD.Utils.Parser.Utils; +using Newtonsoft.Json; + +namespace CRD.Utils.Parser; + +public class DashParser{ + + public static dynamic Parse(string manifest, dynamic? options = null){ + var parsedManifestInfo = InheritAttributes.InheritAttributesFun(StringToMpdXml(manifest)); + List playlists = ToPlaylistsClass.ToPlaylists(parsedManifestInfo.representationInfo); + + dynamic parsedElement = new{ + dashPlaylist = playlists, + locations= parsedManifestInfo.locations, + contentSteering= parsedManifestInfo.contentSteeringInfo, + sidxMapping= options != null ? ObjectUtilities.GetMemberValue(options,"sidxMapping") : null, + previousManifest= options != null ? ObjectUtilities.GetMemberValue(options,"previousManifest") : null, + eventStream= ObjectUtilities.GetMemberValue(parsedManifestInfo,"eventStream") + }; + + return ToM3u8Class.ToM3u8(parsedElement); + // string jsonString = JsonConvert.SerializeObject(M3u8); + + Console.WriteLine("Hallo"); + } + + private static XmlElement StringToMpdXml(string manifestString){ + if (string.IsNullOrEmpty(manifestString)) + { + throw new Exception(Errors.DASH_EMPTY_MANIFEST); + } + + XmlDocument xml = new XmlDocument(); + XmlElement mpd = null; + + try + { + xml.LoadXml(manifestString); + mpd = xml.DocumentElement.Name == "MPD" ? xml.DocumentElement : null; + } + catch (XmlException) + { + // ie 11 throws on invalid xml + } + + if (mpd == null || (mpd != null && mpd.GetElementsByTagName("parsererror").Count > 0)) + { + throw new Exception(Errors.DASH_INVALID_XML); + } + + return mpd; + } + +} \ No newline at end of file diff --git a/Utils/Parser/M3u8/ToM3u8Class.cs b/Utils/Parser/M3u8/ToM3u8Class.cs new file mode 100644 index 0000000..ec44ace --- /dev/null +++ b/Utils/Parser/M3u8/ToM3u8Class.cs @@ -0,0 +1,495 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using CRD.Utils.Parser.Segments; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser; + +public class ToM3u8Class{ + public static dynamic ToM3u8(dynamic parsedPlaylists){ + List dashPlaylist = ObjectUtilities.GetMemberValue(parsedPlaylists, "dashPlaylist"); + dynamic locations = ObjectUtilities.GetMemberValue(parsedPlaylists, "locations"); + dynamic contentSteering = ObjectUtilities.GetMemberValue(parsedPlaylists, "contentSteering"); + dynamic sidxMapping = ObjectUtilities.GetMemberValue(parsedPlaylists, "sidxMapping"); + dynamic previousManifest = ObjectUtilities.GetMemberValue(parsedPlaylists, "previousManifest"); + dynamic eventStream = ObjectUtilities.GetMemberValue(parsedPlaylists, "eventStream"); + + if (dashPlaylist == null || dashPlaylist.Count == 0){ + return new{ }; + } + + dynamic attributes = dashPlaylist[0].attributes; + + dynamic duration = ObjectUtilities.GetMemberValue(attributes, "sourceDuration"); + dynamic type = ObjectUtilities.GetMemberValue(attributes, "type"); + dynamic suggestedPresentationDelay = ObjectUtilities.GetMemberValue(attributes, "suggestedPresentationDelay"); + dynamic minimumUpdatePeriod = ObjectUtilities.GetMemberValue(attributes, "minimumUpdatePeriod"); + + + List videoPlaylists = MergeDiscontiguousPlaylists(dashPlaylist.FindAll(VideoOnly)).Select(FormatVideoPlaylist).ToList(); + List audioPlaylists = MergeDiscontiguousPlaylists(dashPlaylist.FindAll(AudioOnly)); + List vttPlaylists = MergeDiscontiguousPlaylists(dashPlaylist.FindAll(VttOnly)); + List captions = dashPlaylist + .Select(playlist => ObjectUtilities.GetMemberValue(playlist.attributes, "captionServices")) + .Where(captionService => captionService != null) // Filtering out null values + .ToList(); + + dynamic manifest = new ExpandoObject(); + manifest.allowCache = true; + manifest.discontinuityStarts = new List(); + manifest.segments = new List(); + manifest.endList = true; + manifest.mediaGroups = new ExpandoObject(); + manifest.mediaGroups.AUDIO = new ExpandoObject(); + manifest.mediaGroups.VIDEO = new ExpandoObject(); + manifest.mediaGroups.SUBTITLES = new ExpandoObject(); + manifest.uri = ""; + manifest.duration = duration; + manifest.playlists = AddSidxSegmentsToPlaylists(videoPlaylists, sidxMapping); + + var mediaGroupsDict = (IDictionary)manifest.mediaGroups; + mediaGroupsDict["CLOSED-CAPTIONS"] = new ExpandoObject(); + + if (minimumUpdatePeriod != null && minimumUpdatePeriod >= 0){ + manifest.minimumUpdatePeriod = minimumUpdatePeriod * 1000; + } + + if (locations != null){ + manifest.locations = locations; + } + + if (contentSteering != null){ + manifest.contentSteering = contentSteering; + } + + if (type != null && type == "dynamic"){ + manifest.suggestedPresentationDelay = suggestedPresentationDelay; + } + + if (eventStream != null && eventStream.Count > 0){ + manifest.eventStream = eventStream; + } + + + var isAudioOnly = ((List)manifest.playlists).Count == 0; + var organizedAudioGroup = audioPlaylists.Count > 0 ? OrganizeAudioPlaylists(audioPlaylists, sidxMapping, isAudioOnly) : null; + var organizedVttGroup = vttPlaylists.Count > 0 ? OrganizeVttPlaylists(vttPlaylists, sidxMapping) : null; + + List formattedPlaylists = new List(videoPlaylists); + + formattedPlaylists.AddRange(FlattenMediaGroupPlaylists(organizedAudioGroup)); + formattedPlaylists.AddRange(FlattenMediaGroupPlaylists(organizedVttGroup)); + + + dynamic playlistTimelineStarts = formattedPlaylists.Select(playlist => playlist.timelineStarts).ToList(); + + List> convertedToList = new List>(); + foreach (var item in playlistTimelineStarts){ + if (item is List){ + convertedToList.Add(item); + } + } + + manifest.timelineStarts = PlaylistMerge.GetUniqueTimelineStarts(convertedToList); + + AddMediaSequenceValues(formattedPlaylists, manifest.timelineStarts); + + if (organizedAudioGroup != null){ + manifest.mediaGroups.AUDIO.audio = organizedAudioGroup; + } + + if (organizedVttGroup != null){ + manifest.mediaGroups.SUBTITLES.subs = organizedVttGroup; + } + + if (captions.Count > 0){ + dynamic closedCaptions = mediaGroupsDict["CLOSED-CAPTIONS"]; + closedCaptions.cc = OrganizeCaptionServices(captions); + } + + if (previousManifest != null){ + return PlaylistMerge.PositionManifestOnTimeline(previousManifest, manifest); + } + + return manifest; + } + + public static bool VideoOnly(dynamic item){ + var attributes = item.attributes; + return ObjectUtilities.GetMemberValue(attributes, "mimeType") == "video/mp4" || ObjectUtilities.GetMemberValue(attributes, "mimeType") == "video/webm" || + ObjectUtilities.GetMemberValue(attributes, "contentType") == "video"; + } + + public static bool AudioOnly(dynamic item){ + var attributes = item.attributes; + return ObjectUtilities.GetMemberValue(attributes, "mimeType") == "audio/mp4" || ObjectUtilities.GetMemberValue(attributes, "mimeType") == "audio/webm" || + ObjectUtilities.GetMemberValue(attributes, "contentType") == "audio"; + } + + public static bool VttOnly(dynamic item){ + var attributes = item.attributes; + return ObjectUtilities.GetMemberValue(attributes, "mimeType") == "text/vtt" || ObjectUtilities.GetMemberValue(attributes, "contentType") == "text"; + } + + public static dynamic FormatVideoPlaylist(dynamic item){ + dynamic playlist = new ExpandoObject(); + playlist.attributes = new ExpandoObject(); + playlist.attributes.NAME = item.attributes.id; + playlist.attributes.AUDIO = "audio"; + playlist.attributes.SUBTITLES = "subs"; + playlist.attributes.RESOLUTION = new ExpandoObject(); + playlist.attributes.RESOLUTION.width = item.attributes.width; + playlist.attributes.RESOLUTION.height = item.attributes.height; + playlist.attributes.CODECS = item.attributes.codecs; + playlist.attributes.BANDWIDTH = item.attributes.bandwidth; + playlist.uri = ""; + playlist.endList = item.attributes.type == "static"; + playlist.timeline = item.attributes.periodStart; + playlist.resolvedUri = item.attributes.baseUrl ?? ""; + playlist.targetDuration = item.attributes.duration; + playlist.discontinuityStarts = item.discontinuityStarts; + playlist.timelineStarts = item.attributes.timelineStarts; + playlist.segments = item.segments; + + var attributesDict = (IDictionary)playlist.attributes; + attributesDict["PROGRAM-ID"] = 1; + + if (ObjectUtilities.GetMemberValue(item.attributes, "frameRate") != null){ + attributesDict["FRAME-RATE"] = item.attributes.frameRate; + } + + if (ObjectUtilities.GetMemberValue(item.attributes, "contentProtection") != null){ + playlist.contentProtection = item.attributes.contentProtection; + } + + if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){ + playlist.attributes.serviceLocation = item.attributes.serviceLocation; + } + + if (ObjectUtilities.GetMemberValue(item, "sidx") != null){ + playlist.sidx = item.sidx; + } + + return playlist; + } + + public static dynamic FormatAudioPlaylist(dynamic item, bool isAudioOnly){ + dynamic playlist = new ExpandoObject(); + playlist.attributes = new ExpandoObject(); + playlist.attributes.NAME = item.attributes.id; + playlist.attributes.BANDWIDTH = item.attributes.bandwidth; + playlist.attributes.CODECS = item.attributes.codecs; + playlist.uri = string.Empty; + playlist.endList = item.attributes.type == "static"; + playlist.timeline = item.attributes.periodStart; + playlist.resolvedUri = item.attributes.baseUrl ?? string.Empty; + playlist.targetDuration = item.attributes.duration; + playlist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence"); + playlist.discontinuityStarts = item.discontinuityStarts; + playlist.timelineStarts = item.attributes.timelineStarts; + playlist.mediaSequence = ObjectUtilities.GetMemberValue(item, "mediaSequence"); + playlist.segments = item.segments; + + var attributesDict = (IDictionary)playlist.attributes; + attributesDict["PROGRAM-ID"] = 1; + + if (ObjectUtilities.GetMemberValue(item.attributes, "contentProtection") != null){ + playlist.contentProtection = item.attributes.contentProtection; + } + + if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){ + playlist.attributes.serviceLocation = item.attributes.serviceLocation; + } + + if (ObjectUtilities.GetMemberValue(item, "sidx") != null){ + playlist.sidx = item.sidx; + } + + if (isAudioOnly){ + playlist.attributes.AUDIO = "audio"; + playlist.attributes.SUBTITLES = "subs"; + } + + return playlist; + } + + public static dynamic FormatVttPlaylist(dynamic item){ + if (ObjectUtilities.GetMemberValue(item,"segments") == null){ + // VTT tracks may use a single file in BaseURL + var segment = new ExpandoObject() as IDictionary; + segment["uri"] = item.attributes.baseUrl; + segment["timeline"] = item.attributes.periodStart; + segment["resolvedUri"] = item.attributes.baseUrl ?? string.Empty; + segment["duration"] = item.attributes.sourceDuration; + segment["number"] = 0; + + item.segments = new List{ segment }; + + // TargetDuration should be the same duration as the only segment + item.attributes.duration = item.attributes.sourceDuration; + } + + var m3u8Attributes = new ExpandoObject() as IDictionary; + m3u8Attributes["NAME"] = item.attributes.id; + m3u8Attributes["BANDWIDTH"] = item.attributes.bandwidth; + m3u8Attributes["PROGRAM-ID"] = 1; + + + + if (ObjectUtilities.GetMemberValue(item.attributes,"codecs") != null){ + m3u8Attributes["CODECS"] = item.attributes.codecs; + } + + dynamic vttPlaylist = new ExpandoObject(); + vttPlaylist.attributes = m3u8Attributes; + vttPlaylist.uri = string.Empty; + vttPlaylist.endList = item.attributes.type == "static"; + vttPlaylist.timeline = item.attributes.periodStart; + vttPlaylist.resolvedUri = item.attributes.baseUrl ?? string.Empty; + vttPlaylist.targetDuration = item.attributes.duration; + vttPlaylist.timelineStarts = item.attributes.timelineStarts; + vttPlaylist.discontinuityStarts = item.discontinuityStarts; + vttPlaylist.discontinuitySequence = ObjectUtilities.GetMemberValue(item, "discontinuitySequence"); + vttPlaylist.mediaSequence = ObjectUtilities.GetMemberValue(item,"mediaSequence"); + vttPlaylist.segments = item.segments; + + if (ObjectUtilities.GetMemberValue(item.attributes, "serviceLocation") != null){ + vttPlaylist.attributes.serviceLocation = item.attributes.serviceLocation; + } + + return vttPlaylist; + } + + public static dynamic OrganizeCaptionServices(List captionServices){ + var svcObj = new ExpandoObject() as IDictionary; + + foreach (var svc in captionServices){ + if (svc == null) continue; + + foreach (var service in svc){ + string channel = service.channel; + string language = service.language; + + var serviceDetails = new ExpandoObject() as IDictionary; + serviceDetails["autoselect"] = false; + serviceDetails["default"] = false; + serviceDetails["instreamId"] = channel; + serviceDetails["language"] = language; + + // Optionally add properties if they exist + if (((IDictionary)service).ContainsKey("aspectRatio")){ + serviceDetails["aspectRatio"] = service.aspectRatio; + } + + if (((IDictionary)service).ContainsKey("easyReader")){ + serviceDetails["easyReader"] = service.easyReader; + } + + if (((IDictionary)service).ContainsKey("3D")){ + serviceDetails["3D"] = service["3D"]; + } + + svcObj[language] = serviceDetails; + } + } + + return svcObj; + } + + public static List FlattenMediaGroupPlaylists(dynamic mediaGroupObject){ + if (mediaGroupObject == null) return new List(); + + var result = new List(); + foreach (var key in ((IDictionary)mediaGroupObject).Keys){ + var labelContents = mediaGroupObject[key]; + if (labelContents.playlists != null && labelContents.playlists is List){ + result.AddRange(labelContents.playlists); + } + } + + return result; + } + + + public static List MergeDiscontiguousPlaylists(List playlists){ + // Break out playlists into groups based on their baseUrl + var playlistsByBaseUrl = playlists.GroupBy( + p => p.attributes.baseUrl, + p => p, + (key, g) => new{ BaseUrl = key, Playlists = g.ToList() }) + .ToDictionary(g => g.BaseUrl, g => g.Playlists); + + var allPlaylists = new List(); + + foreach (var playlistGroup in playlistsByBaseUrl.Values){ + var mergedPlaylists = playlistGroup + .GroupBy( + p => p.attributes.id + (ObjectUtilities.GetMemberValue(p.attributes, "lang") ?? ""), + p => p, + (key, g) => new{ Name = key, Playlists = g.ToList() }) + .Select(g => { + dynamic mergedPlaylist = new ExpandoObject(); + mergedPlaylist.attributes = new ExpandoObject(); + mergedPlaylist.attributes.timelineStarts = new List(); + + foreach (var playlist in g.Playlists){ + if (ObjectUtilities.GetMemberValue(mergedPlaylist, "segments") == null){ + mergedPlaylist = playlist; + mergedPlaylist.attributes.timelineStarts = new List(); + } else{ + if (playlist.segments != null && playlist.segments.Count > 0){ + playlist.segments[0].discontinuity = true; + foreach (var segment in playlist.segments){ + mergedPlaylist.segments.Add(segment); + } + } + + if (playlist.attributes.contentProtection != null){ + mergedPlaylist.attributes.contentProtection = playlist.attributes.contentProtection; + } + } + + mergedPlaylist.attributes.timelineStarts.Add(new{ + start = playlist.attributes.periodStart, + timeline = playlist.attributes.periodStart + }); + } + + return mergedPlaylist; + }) + .ToList(); + + allPlaylists.AddRange(mergedPlaylists); + } + + return allPlaylists.Select(playlist => { + playlist.discontinuityStarts = FindIndexes((List) ObjectUtilities.GetMemberValue(playlists,"segments") ?? new List(), "discontinuity"); + return playlist; + }).ToList(); + } + + public static IDictionary OrganizeAudioPlaylists(List playlists, IDictionary? sidxMapping = null, bool isAudioOnly = false){ + sidxMapping ??= new Dictionary(); // Ensure sidxMapping is not null + dynamic mainPlaylist = null; + + var formattedPlaylists = playlists.Aggregate(new Dictionary(), (acc, playlist) => { + var role = ObjectUtilities.GetMemberValue(playlist.attributes, "role") != null && ObjectUtilities.GetMemberValue(playlist.attributes.role, "value") != null ? playlist.attributes.role.value : string.Empty; + var language = ObjectUtilities.GetMemberValue(playlist.attributes, "lang") ?? string.Empty; + + var label = ObjectUtilities.GetMemberValue(playlist.attributes, "label") ?? "main"; + if (!string.IsNullOrEmpty(language) && string.IsNullOrEmpty(playlist.attributes.label)){ + var roleLabel = !string.IsNullOrEmpty(role) ? $" ({role})" : string.Empty; + label = $"{language}{roleLabel}"; + } + + if (!acc.ContainsKey(label)){ + acc[label] = new ExpandoObject(); + acc[label].language = language; + acc[label].autoselect = true; + acc[label].@default = role == "main"; + acc[label].playlists = new List(); + acc[label].uri = string.Empty; + } + + var formatted = AddSidxSegmentsToPlaylist(FormatAudioPlaylist(playlist, isAudioOnly), sidxMapping); + acc[label].playlists.Add(formatted); + + if (mainPlaylist == null && role == "main"){ + mainPlaylist = playlist; + mainPlaylist.@default = true; // Use '@' to escape reserved keyword + } + + return acc; + }); + + // If no playlists have role "main", mark the first as main + if (mainPlaylist == null && formattedPlaylists.Count > 0){ + var firstLabel = formattedPlaylists.Keys.First(); + formattedPlaylists[firstLabel].@default = true; // Use '@' to escape reserved keyword + } + + return formattedPlaylists; + } + + public static IDictionary OrganizeVttPlaylists(List playlists, IDictionary? sidxMapping = null){ + sidxMapping ??= new Dictionary(); // Ensure sidxMapping is not null + + var organizedPlaylists = playlists.Aggregate(new Dictionary(), (acc, playlist) => { + var label = playlist.attributes.label ?? playlist.attributes.lang ?? "text"; + + if (!acc.ContainsKey(label)){ + dynamic playlistGroup = new ExpandoObject(); + playlistGroup.language = label; + playlistGroup.@default = false; // '@' is used to escape C# keyword + playlistGroup.autoselect = false; + playlistGroup.playlists = new List(); + playlistGroup.uri = string.Empty; + + acc[label] = playlistGroup; + } + + acc[label].playlists.Add(AddSidxSegmentsToPlaylist(FormatVttPlaylist(playlist), sidxMapping)); + + return acc; + }); + + return organizedPlaylists; + } + + + public static void AddMediaSequenceValues(List playlists, List timelineStarts){ + foreach (var playlist in playlists){ + playlist.mediaSequence = 0; + playlist.discontinuitySequence = timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline); + + if (playlist.segments == null) continue; + + for (int i = 0; i < playlist.segments.Count; i++){ + playlist.segments[i].number = i; + } + } + } + + public static List FindIndexes(List list, string key){ + var indexes = new List(); + for (int i = 0; i < list.Count; i++){ + var expandoDict = list[i] as IDictionary; + if (expandoDict != null && expandoDict.ContainsKey(key) && expandoDict[key] != null){ + indexes.Add(i); + } + } + + return indexes; + } + + public static dynamic AddSidxSegmentsToPlaylist(dynamic playlist, IDictionary sidxMapping){ + string sidxKey = GenerateSidxKey(ObjectUtilities.GetMemberValue(playlist, "sidx")); + if (!string.IsNullOrEmpty(sidxKey) && sidxMapping.ContainsKey(sidxKey)){ + var sidxMatch = sidxMapping[sidxKey]; + if (sidxMatch != null){ + SegmentBase.AddSidxSegmentsToPlaylist(playlist, sidxMatch.sidx, playlist.sidx.resolvedUri); + } + } + + return playlist; + } + + public static List AddSidxSegmentsToPlaylists(List playlists, IDictionary? sidxMapping = null){ + sidxMapping ??= new Dictionary(); + + if (sidxMapping.Count == 0){ + return playlists; + } + + for (int i = 0; i < playlists.Count; i++){ + playlists[i] = AddSidxSegmentsToPlaylist(playlists[i], sidxMapping); + } + + return playlists; + } + + public static string GenerateSidxKey(dynamic sidx){ + return sidx != null ? $"{sidx.uri}-{UrlType.ByteRangeToString(sidx.byterange)}" : null; + } +} \ No newline at end of file diff --git a/Utils/Parser/MPDTransformer.cs b/Utils/Parser/MPDTransformer.cs new file mode 100644 index 0000000..87e43d3 --- /dev/null +++ b/Utils/Parser/MPDTransformer.cs @@ -0,0 +1,168 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Xml.Linq; +using CRD.Downloader; +using CRD.Utils.HLS; +using CRD.Utils.Parser; +using CRD.Utils.Parser.Utils; +using CRD.Utils.Structs; + +namespace CRD.Utils; + +public class Segment{ + public string uri{ get; set; } + public double timeline{ get; set; } + public double duration{ get; set; } + public Map map{ get; set; } + + public ByteRange? byteRange { get; set; } + public double? number{ get; set; } + public double? presentationTime{ get; set; } +} + +public class Map{ + public string uri { get; set; } + + public ByteRange? byteRange { get; set; } +} + +public class PlaylistItem{ + public string? pssh{ get; set; } + public int bandwidth{ get; set; } + public List segments{ get; set; } +} + +public class AudioPlaylist : PlaylistItem{ + public LanguageItem? language{ get; set; } + public bool @default{ get; set; } +} + +public class VideoPlaylist : PlaylistItem{ + public Quality quality{ get; set; } +} + +public class VideoItem: VideoPlaylist{ + public string resolutionText{ get; set; } +} + +public class AudioItem: AudioPlaylist{ + public string resolutionText{ get; set; } +} + +public class Quality{ + public int width{ get; set; } + public int height{ get; set; } +} + +public class MPDParsed{ + public Dictionary Data{ get; set; } +} + +public class ServerData{ + public List audio{ get; set; } + public List video{ get; set; } +} + +public static class MPDParser{ + public static MPDParsed Parse(string manifest, LanguageItem? language, string? url){ + if (!manifest.Contains("BaseURL") && url != null){ + XDocument doc = XDocument.Parse(manifest); + XElement mpd = doc.Element("MPD"); + mpd.AddFirst(new XElement("BaseURL", url)); + manifest = doc.ToString(); + } + + dynamic parsed = DashParser.Parse(manifest); + + MPDParsed ret = new MPDParsed{ Data = new Dictionary() }; + + foreach (var item in parsed.mediaGroups.AUDIO.audio.Values){ + foreach (var playlist in item.playlists){ + var host = new Uri(playlist.resolvedUri).Host; + EnsureHostEntryExists(ret, host); + + List segments = playlist.segments; + + if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){ + throw new NotImplementedException(); + } + + var foundLanguage = Languages.FindLang(Languages.languages.FirstOrDefault(a => a.Code == item.language).CrLocale ?? "unknown"); + LanguageItem? audioLang = item.language != null ? foundLanguage : (language != null ? language : foundLanguage); + + var pItem = new AudioPlaylist{ + bandwidth = playlist.attributes.BANDWIDTH, + language = audioLang, + @default = item.@default, + segments = segments.Select(segment => new Segment{ + duration = segment.duration, + map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")}, + number = segment.number, + presentationTime = segment.presentationTime, + timeline = segment.timeline, + uri = segment.resolvedUri, + byteRange = ObjectUtilities.GetMemberValue(segment,"byterange") + }).ToList() + }; + + var contentProtectionDict = (IDictionary)ObjectUtilities.GetMemberValue(playlist,"contentProtection"); + + if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null) + pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh); + + ret.Data[host].audio.Add(pItem); + } + } + + foreach (var playlist in parsed.playlists){ + var host = new Uri(playlist.resolvedUri).Host; + EnsureHostEntryExists(ret, host); + + List segments = playlist.segments; + + if (ObjectUtilities.GetMemberValue(playlist,"sidx") != null && segments.Count == 0){ + throw new NotImplementedException(); + } + + + dynamic resolution = ObjectUtilities.GetMemberValue(playlist.attributes,"RESOLUTION"); + resolution = resolution != null ? resolution : new Quality(); + + var pItem = new VideoPlaylist{ + bandwidth = playlist.attributes.BANDWIDTH, + quality = new Quality{height = resolution.height,width = resolution.width}, + segments = segments.Select(segment => new Segment{ + duration = segment.duration, + map = new Map{uri = segment.map.resolvedUri,byteRange = ObjectUtilities.GetMemberValue(segment.map,"byterange")}, + number = segment.number, + presentationTime = segment.presentationTime, + timeline = segment.timeline, + uri = segment.resolvedUri, + byteRange = ObjectUtilities.GetMemberValue(segment,"byterange") + }).ToList() + }; + + var contentProtectionDict = (IDictionary)ObjectUtilities.GetMemberValue(playlist,"contentProtection"); + + if (contentProtectionDict != null && contentProtectionDict.ContainsKey("com.widevine.alpha") && contentProtectionDict["com.widevine.alpha"].pssh != null) + pItem.pssh = ArrayBufferToBase64(contentProtectionDict["com.widevine.alpha"].pssh); + + + ret.Data[host].video.Add(pItem); + } + + return ret; + } + + private static void EnsureHostEntryExists(MPDParsed ret, string host){ + if (!ret.Data.ContainsKey(host)){ + ret.Data[host] = new ServerData{ audio = new List(), video = new List() }; + } + } + + public static string ArrayBufferToBase64(byte[] buffer){ + return Convert.ToBase64String(buffer); + } +} \ No newline at end of file diff --git a/Utils/Parser/Playlists/Errors.cs b/Utils/Parser/Playlists/Errors.cs new file mode 100644 index 0000000..c93d8e4 --- /dev/null +++ b/Utils/Parser/Playlists/Errors.cs @@ -0,0 +1,13 @@ +namespace CRD.Utils.Parser; + +public class Errors{ + public static string INVALID_NUMBER_OF_PERIOD = "INVALID_NUMBER_OF_PERIOD"; + public static string INVALID_NUMBER_OF_CONTENT_STEERING = "INVALID_NUMBER_OF_CONTENT_STEERING"; + public static string DASH_EMPTY_MANIFEST = "DASH_EMPTY_MANIFEST"; + public static string DASH_INVALID_XML = "DASH_INVALID_XML"; + public static string NO_BASE_URL = "NO_BASE_URL"; + public static string MISSING_SEGMENT_INFORMATION = "MISSING_SEGMENT_INFORMATION"; + public static string SEGMENT_TIME_UNSPECIFIED = "SEGMENT_TIME_UNSPECIFIED"; + public static string UNSUPPORTED_UTC_TIMING_SCHEME = "UNSUPPORTED_UTC_TIMING_SCHEME"; + +} \ No newline at end of file diff --git a/Utils/Parser/Playlists/InheritAttributes.cs b/Utils/Parser/Playlists/InheritAttributes.cs new file mode 100644 index 0000000..b486b6b --- /dev/null +++ b/Utils/Parser/Playlists/InheritAttributes.cs @@ -0,0 +1,460 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Xml; +using Avalonia.Logging; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser; + +public class InheritAttributes{ + public static Dictionary KeySystemsMap = new Dictionary{ + { "urn:uuid:1077efec-c0b2-4d02-ace3-3c1e52e2fb4b", "org.w3.clearkey" }, + { "urn:uuid:edef8ba9-79d6-4ace-a3c8-27dcd51d21ed", "com.widevine.alpha" }, + { "urn:uuid:9a04f079-9840-4286-ab92-e65be0885f95", "com.microsoft.playready" }, + { "urn:uuid:f239e769-efa3-4850-9c16-a903c6932efb", "com.adobe.primetime" }, + { "urn:mpeg:dash:mp4protection:2011", "mp4protection" } + }; + + public static dynamic GenerateKeySystemInformation(List contentProtectionNodes){ + var keySystemInfo = new ExpandoObject() as IDictionary; + + foreach (var node in contentProtectionNodes){ + dynamic attributes = ParseAttribute.ParseAttributes(node); // Assume this returns a dictionary + var testAttributes = attributes as IDictionary; + + if (testAttributes != null && testAttributes.TryGetValue("schemeIdUri", out var attribute)){ + string? schemeIdUri = attribute.ToString()?.ToLower(); + if (schemeIdUri != null && KeySystemsMap.TryGetValue(schemeIdUri, out var keySystem)){ + dynamic info = new ExpandoObject(); + info.attributes = attributes; + + var psshNode = XMLUtils.FindChildren(node, "cenc:pssh").FirstOrDefault(); + if (psshNode != null){ + string pssh = psshNode.InnerText; // Assume this returns the inner text/content + if (!string.IsNullOrEmpty(pssh)){ + info.pssh = DecodeB64ToUint8Array(pssh); // Convert base64 string to byte array + } + } + + // Instead of using a dictionary key, add the key system directly as a member of the ExpandoObject + keySystemInfo[keySystem] = info; + } + } + } + + return keySystemInfo; + } + + private static byte[] DecodeB64ToUint8Array(string base64String){ + return Convert.FromBase64String(base64String); + } + + + public static string GetContent(XmlElement element) => element.InnerText.Trim(); + + public static List BuildBaseUrls(List references, List baseUrlElements){ + if (!baseUrlElements.Any()){ + return references; + } + + return references.SelectMany(reference => + baseUrlElements.Select(baseUrlElement => { + var initialBaseUrl = GetContent(baseUrlElement); + // var resolvedBaseUrl = ResolveUrl(reference.BaseUrl, initialBaseUrl); + // var baseUri = new Uri(reference.baseUrl); + // string resolvedBaseUrl = new Uri(baseUri, initialBaseUrl).ToString(); + + string resolvedBaseUrl = UrlUtils.ResolveUrl(reference.baseUrl, initialBaseUrl); + + dynamic finalBaseUrl = new ExpandoObject(); + finalBaseUrl.baseUrl = resolvedBaseUrl; + + ObjectUtilities.MergeExpandoObjects(finalBaseUrl, ParseAttribute.ParseAttributes(baseUrlElement)); + + if (resolvedBaseUrl != initialBaseUrl && finalBaseUrl.serviceLocation == null && reference.serviceLocation != null){ + finalBaseUrl.ServiceLocation = reference.ServiceLocation; + } + + return finalBaseUrl; + }) + ).ToList(); + } + + + + + public static double? GetPeriodStart(dynamic attributes, dynamic? priorPeriodAttributes, string mpdType){ + // Summary of period start time calculation from DASH spec section 5.3.2.1 + // + // A period's start is the first period's start + time elapsed after playing all + // prior periods to this one. Periods continue one after the other in time (without + // gaps) until the end of the presentation. + // + // The value of Period@start should be: + // 1. if Period@start is present: value of Period@start + // 2. if previous period exists and it has @duration: previous Period@start + + // previous Period@duration + // 3. if this is first period and MPD@type is 'static': 0 + // 4. in all other cases, consider the period an "early available period" (note: not + // currently supported) + + var attributesL = attributes as IDictionary; + // (1) + if (attributesL != null && attributesL.ContainsKey("start") && (attributesL["start"] is double || attributesL["start"] is long || attributesL["start"] is float || attributesL["start"] is int)){ + return (double)attributes.start; + } + + var priorPeriodAttributesL = priorPeriodAttributes as IDictionary; + // (2) + if (priorPeriodAttributesL != null && priorPeriodAttributesL.ContainsKey("start") && priorPeriodAttributesL.ContainsKey("duration") && + (priorPeriodAttributesL["start"] is double || priorPeriodAttributesL["start"] is long || priorPeriodAttributesL["start"] is float || priorPeriodAttributesL["start"] is int) && + (priorPeriodAttributesL["duration"] is double || priorPeriodAttributesL["duration"] is long || priorPeriodAttributesL["duration"] is float || priorPeriodAttributesL["duration"] is int)){ + return (double)priorPeriodAttributes.start + (double)priorPeriodAttributes.duration; + } + + // (3) + if (priorPeriodAttributesL == null && string.Equals(mpdType, "static", StringComparison.OrdinalIgnoreCase)){ + return 0; + } + + + // (4) + // There is currently no logic for calculating the Period@start value if there is + // no Period@start or prior Period@start and Period@duration available. This is not made + // explicit by the DASH interop guidelines or the DASH spec, however, since there's + // nothing about any other resolution strategies, it's implied. Thus, this case should + // be considered an early available period, or error, and null should suffice for both + // of those cases. + return null; + } + + + public class ContentSteeringInfo{ + public string ServerURL{ get; set; } + + public bool QueryBeforeStart{ get; set; } + // Add other properties if needed + } + + public static ContentSteeringInfo GenerateContentSteeringInformation(List contentSteeringNodes){ + // If there are more than one ContentSteering tags, throw a warning + if (contentSteeringNodes.Count > 1){ + Console.WriteLine("The MPD manifest should contain no more than one ContentSteering tag"); + } + + // Return null if there are no ContentSteering tags + if (contentSteeringNodes.Count == 0){ + return null; + } + + // Extract information from the first ContentSteering tag + XmlElement firstContentSteeringNode = contentSteeringNodes[0]; + ContentSteeringInfo infoFromContentSteeringTag = new ContentSteeringInfo{ + ServerURL = XMLUtils.GetContent(firstContentSteeringNode), + // Assuming 'queryBeforeStart' is a boolean attribute + QueryBeforeStart = Convert.ToBoolean(firstContentSteeringNode.GetAttribute("queryBeforeStart")) + }; + + return infoFromContentSteeringTag; + } + + private static dynamic CreateExpandoWithTag(string tag){ + dynamic expando = new ExpandoObject(); + expando.tag = tag; + return expando; + } + + public static dynamic GetSegmentInformation(XmlElement adaptationSet){ + dynamic segmentInfo = new ExpandoObject(); + + var segmentTemplate = XMLUtils.FindChildren(adaptationSet, "SegmentTemplate").FirstOrDefault(); + var segmentList = XMLUtils.FindChildren(adaptationSet, "SegmentList").FirstOrDefault(); + var segmentUrls = segmentList != null + ? XMLUtils.FindChildren(segmentList, "SegmentURL").Select(s => ObjectUtilities.MergeExpandoObjects(CreateExpandoWithTag("SegmentURL"), ParseAttribute.ParseAttributes(s))).ToList() + : null; + var segmentBase = XMLUtils.FindChildren(adaptationSet, "SegmentBase").FirstOrDefault(); + var segmentTimelineParentNode = segmentList ?? segmentTemplate; + var segmentTimeline = segmentTimelineParentNode != null ? XMLUtils.FindChildren(segmentTimelineParentNode, "SegmentTimeline").FirstOrDefault() : null; + var segmentInitializationParentNode = segmentList ?? segmentBase ?? segmentTemplate; + var segmentInitialization = segmentInitializationParentNode != null ? XMLUtils.FindChildren(segmentInitializationParentNode, "Initialization").FirstOrDefault() : null; + + dynamic template = segmentTemplate != null ? ParseAttribute.ParseAttributes(segmentTemplate) : null; + + if (template != null && segmentInitialization != null){ + template.initialization = ParseAttribute.ParseAttributes(segmentInitialization); + } else if (template != null && template.initialization != null){ + dynamic init = new ExpandoObject(); + init.sourceURL = template.initialization; + template.initialization = init; + } + + segmentInfo.template = template; + segmentInfo.segmentTimeline = segmentTimeline != null ? XMLUtils.FindChildren(segmentTimeline, "S").Select(s => ParseAttribute.ParseAttributes(s)).ToList() : null; + segmentInfo.list = segmentList != null + ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentList), new{ segmentUrls, initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) + : null; + segmentInfo.baseInfo = segmentBase != null ? ObjectUtilities.MergeExpandoObjects(ParseAttribute.ParseAttributes(segmentBase), new{ initialization = ParseAttribute.ParseAttributes(segmentInitialization) }) : null; + + // Clean up null entries + var dict = (IDictionary)segmentInfo; + var keys = dict.Keys.ToList(); + foreach (var key in keys){ + if (dict[key] == null){ + dict.Remove(key); + } + } + + return segmentInfo; + } + + public static List ParseCaptionServiceMetadata(dynamic service){ + List parsedMetadata = new List(); + + var tempTestService = service as IDictionary; + + if (tempTestService == null || !tempTestService.ContainsKey("schemeIdUri")){ + return parsedMetadata; + } + + // 608 captions + if (service.schemeIdUri == "urn:scte:dash:cc:cea-608:2015"){ + var values = service.value is string ? service.value.Split(';') : new string[0]; + + foreach (var value in values){ + dynamic metadata = new ExpandoObject(); + string channel = null; + string language = value; + + if (System.Text.RegularExpressions.Regex.IsMatch(value, @"^CC\d=")){ + var parts = value.Split('='); + channel = parts[0]; + language = parts[1]; + } else if (System.Text.RegularExpressions.Regex.IsMatch(value, @"^CC\d$")){ + channel = value; + } + + metadata.channel = channel; + metadata.language = language; + + parsedMetadata.Add(metadata); + } + } else if (service.schemeIdUri == "urn:scte:dash:cc:cea-708:2015"){ + var values = service.value is string ? service.value.Split(';') : new string[0]; + + foreach (var value in values){ + dynamic metadata = new ExpandoObject(); + metadata.channel = default(string); + metadata.language = default(string); + metadata.aspectRatio = 1; + metadata.easyReader = 0; + metadata._3D = 0; + + if (value.Contains("=")){ + var parts = value.Split('='); + var channel = parts[0]; + var opts = parts.Length > 1 ? parts[1] : ""; + + metadata.channel = "SERVICE" + channel; + metadata.language = value; + + var options = opts.Split(','); + foreach (var opt in options){ + var optionParts = opt.Split(':'); + var name = optionParts[0]; + var val = optionParts.Length > 1 ? optionParts[1] : ""; + + switch (name){ + case "lang": + metadata.language = val; + break; + case "er": + metadata.easyReader = Convert.ToInt32(val); + break; + case "war": + metadata.aspectRatio = Convert.ToInt32(val); + break; + case "3D": + metadata._3D = Convert.ToInt32(val); + break; + } + } + } else{ + metadata.language = value; + } + + parsedMetadata.Add(metadata); + } + } + + return parsedMetadata; + } + + public static List ToRepresentations(dynamic periodAttributes, dynamic periodBaseUrls, dynamic periodSegmentInfo, XmlElement adaptationSet){ + dynamic adaptationSetAttributes = ParseAttribute.ParseAttributes(adaptationSet); + var adaptationSetBaseUrls = BuildBaseUrls(periodBaseUrls, XMLUtils.FindChildren(adaptationSet, "BaseURL")); + var role = XMLUtils.FindChildren(adaptationSet, "Role").FirstOrDefault(); + dynamic roleAttributes = new ExpandoObject(); + roleAttributes.role = ParseAttribute.ParseAttributes(role); + + dynamic attrs = ObjectUtilities.MergeExpandoObjects(periodAttributes, adaptationSetAttributes); + attrs = ObjectUtilities.MergeExpandoObjects(attrs, roleAttributes); + + var accessibility = XMLUtils.FindChildren(adaptationSet, "Accessibility").FirstOrDefault(); + var captionServices = ParseCaptionServiceMetadata(ParseAttribute.ParseAttributes(accessibility)); + + if (captionServices != null){ + attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ captionServices }); + } + + XmlElement label = XMLUtils.FindChildren(adaptationSet, "Label").FirstOrDefault(); + if (label != null && label.ChildNodes.Count > 0){ + var labelVal = label.ChildNodes[0].ToString().Trim(); + attrs = ObjectUtilities.MergeExpandoObjects(attrs, new{ label = labelVal }); + } + + var contentProtection = GenerateKeySystemInformation(XMLUtils.FindChildren(adaptationSet, "ContentProtection")); + var tempTestContentProtection = contentProtection as IDictionary; + if (tempTestContentProtection != null && tempTestContentProtection.Count > 0){ + dynamic contentProt = new ExpandoObject(); + contentProt.contentProtection = contentProtection; + attrs = ObjectUtilities.MergeExpandoObjects(attrs, contentProt ); + } + + var segmentInfo = GetSegmentInformation(adaptationSet); + var representations = XMLUtils.FindChildren(adaptationSet, "Representation"); + var adaptationSetSegmentInfo = ObjectUtilities.MergeExpandoObjects(periodSegmentInfo, segmentInfo); + + List list = new List(); + for (int i = 0; i < representations.Count; i++){ + List res = InheritBaseUrls(attrs, adaptationSetBaseUrls, adaptationSetSegmentInfo, representations[i]); + foreach (dynamic re in res){ + list.Add(re); + } + } + // return representations.Select(representation => InheritBaseUrls(attrs, adaptationSetBaseUrls, adaptationSetSegmentInfo, representation)); + + return list; + } + + public static List InheritBaseUrls(dynamic adaptationSetAttributes, dynamic adaptationSetBaseUrls, dynamic adaptationSetSegmentInfo, XmlElement representation){ + var repBaseUrlElements = XMLUtils.FindChildren(representation, "BaseURL"); + List repBaseUrls = BuildBaseUrls(adaptationSetBaseUrls, repBaseUrlElements); + var attributes = ObjectUtilities.MergeExpandoObjects(adaptationSetAttributes, ParseAttribute.ParseAttributes(representation)); + var representationSegmentInfo = GetSegmentInformation(representation); + + return repBaseUrls.Select(baseUrl => { + dynamic result = new ExpandoObject(); + result.segmentInfo = ObjectUtilities.MergeExpandoObjects(adaptationSetSegmentInfo, representationSegmentInfo); + result.attributes = ObjectUtilities.MergeExpandoObjects(attributes, baseUrl); + return result; + }).ToList(); + } + + + private static List ToAdaptationSets(ExpandoObject mpdAttributes, dynamic mpdBaseUrls, dynamic period, int index){ + dynamic periodBaseUrls = BuildBaseUrls(mpdBaseUrls, XMLUtils.FindChildren(period.node, "BaseURL")); + dynamic start = new ExpandoObject(); + start.periodStart = period.attributes.start; + dynamic periodAttributes = ObjectUtilities.MergeExpandoObjects(mpdAttributes, start); + + var tempTestAttributes = period.attributes as IDictionary; + if (tempTestAttributes != null && tempTestAttributes.ContainsKey("duration") && + (tempTestAttributes["duration"] is double || tempTestAttributes["duration"] is long || tempTestAttributes["duration"] is float || tempTestAttributes["duration"] is int)){ + periodAttributes.periodDuration = period.attributes.duration; + } + + List adaptationSets = XMLUtils.FindChildren(period.node, "AdaptationSet"); + dynamic periodSegmentInfo = GetSegmentInformation(period.node); + + List list = new List(); + + for (int i = 0; i < adaptationSets.Count; i++){ + List res = ToRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo, adaptationSets[i]); + foreach (dynamic re in res){ + list.Add(re); + } + } + + + return list; + + + // return adaptationSets.Select(adaptationSet => + // ToRepresentations(periodAttributes, periodBaseUrls, periodSegmentInfo, adaptationSet)); + } + + public static ManifestInfo InheritAttributesFun(XmlElement mpd, Dictionary? options = null){ + if (options == null) + options = new Dictionary(); + + string manifestUri = options.ContainsKey("manifestUri") ? (string)options["manifestUri"] : string.Empty; + long NOW = options.ContainsKey("NOW") ? (long)options["NOW"] : DateTimeOffset.UtcNow.ToUnixTimeMilliseconds(); + int clientOffset = options.ContainsKey("clientOffset") ? (int)options["clientOffset"] : 0; + Action eventHandler = options.ContainsKey("eventHandler") ? (Action)options["eventHandler"] : () => { }; + + List periodNodes = XMLUtils.FindChildren(mpd, "Period"); + + if (periodNodes.Count == 0){ + throw new Exception(Errors.INVALID_NUMBER_OF_PERIOD); + } + + List locations = XMLUtils.FindChildren(mpd, "Location"); + dynamic mpdAttributes = ParseAttribute.ParseAttributes(mpd); + dynamic baseUrl = new ExpandoObject(); + baseUrl.baseUrl = manifestUri; + dynamic mpdBaseUrls = BuildBaseUrls(new List{ baseUrl }, XMLUtils.FindChildren(mpd, "BaseUrl")); + List contentSteeringNodes = XMLUtils.FindChildren(mpd, "ContentSteering"); + + // See DASH spec section 5.3.1.2, Semantics of MPD element. Default type to 'static'. + + ObjectUtilities.SetAttributeWithDefault(mpdAttributes, "type", "static"); + ObjectUtilities.SetFieldFromOrToDefault(mpdAttributes, "sourceDuration", "mediaPresentationDuration", 0); + mpdAttributes.NOW = NOW; + mpdAttributes.clientOffset = clientOffset; + + if (locations.Count > 0){ + mpdAttributes.locations = locations.Cast().Select(location => location.InnerText).ToList(); + } + + List periods = new List(); + + for (int i = 0; i < periodNodes.Count; i++){ + XmlElement periodNode = periodNodes[i]; + dynamic attributes = ParseAttribute.ParseAttributes(periodNode); + + int getIndex = i - 1; + + dynamic? priorPeriod = null; + if (getIndex >= 0 && getIndex < periods.Count){ + priorPeriod = periods[getIndex]; + } + + attributes.start = GetPeriodStart(attributes, priorPeriod, mpdAttributes.type); + + dynamic finalPeriod = new ExpandoObject(); + finalPeriod.node = periodNode; + finalPeriod.attributes = attributes; + + periods.Add(finalPeriod); + } + + + List representationInfo = new List(); + + for (int i = 0; i < periods.Count; i++){ + List result = ToAdaptationSets(mpdAttributes, mpdBaseUrls, periods[i], i); + foreach (dynamic re in result){ + representationInfo.Add(re); + } + } + + return new ManifestInfo{ + locations = ObjectUtilities.GetAttributeWithDefault(mpdAttributes, "locations", null), + contentSteeringInfo = GenerateContentSteeringInformation(contentSteeringNodes.Cast().ToList()), + representationInfo = representationInfo, + // eventStream = periods.SelectMany(period => ToEventStream(period)).ToList() + }; + } +} \ No newline at end of file diff --git a/Utils/Parser/Playlists/ParseAttribute.cs b/Utils/Parser/Playlists/ParseAttribute.cs new file mode 100644 index 0000000..e4df478 --- /dev/null +++ b/Utils/Parser/Playlists/ParseAttribute.cs @@ -0,0 +1,321 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Xml; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser; + +public class ParseAttribute{ + public static Dictionary> ParsersDictionary = new Dictionary>{ + { "mediaPresentationDuration", MediaPresentationDuration }, + { "availabilityStartTime", AvailabilityStartTime }, + { "minimumUpdatePeriod", MinimumUpdatePeriod }, + { "suggestedPresentationDelay", SuggestedPresentationDelay }, + { "type", Type }, + { "timeShiftBufferDepth", TimeShiftBufferDepth }, + { "start", Start }, + { "width", Width }, + { "height", Height }, + { "bandwidth", Bandwidth }, + { "frameRate", FrameRate }, + { "startNumber", StartNumber }, + { "timescale", Timescale }, + { "presentationTimeOffset", PresentationTimeOffset }, + { "duration", Duration }, + { "d", D }, + { "t", T }, + { "r", R }, + { "presentationTime", PresentationTime }, + { "DEFAULT", DefaultParser } + }; + + public static object MediaPresentationDuration(string value) => DurationParser.ParseDuration(value); + public static object AvailabilityStartTime(string value) => DurationParser.ParseDate(value) / 1000; + public static object MinimumUpdatePeriod(string value) => DurationParser.ParseDuration(value); + public static object SuggestedPresentationDelay(string value) => DurationParser.ParseDuration(value); + public static object Type(string value) => value; + public static object TimeShiftBufferDepth(string value) => DurationParser.ParseDuration(value); + public static object Start(string value) => DurationParser.ParseDuration(value); + public static object Width(string value) => int.Parse(value); + public static object Height(string value) => int.Parse(value); + public static object Bandwidth(string value) => int.Parse(value); + public static object FrameRate(string value) => DivisionValueParser.ParseDivisionValue(value); + public static object StartNumber(string value) => int.Parse(value); + public static object Timescale(string value) => int.Parse(value); + public static object PresentationTimeOffset(string value) => int.Parse(value); + + public static object Duration(string value){ + if (int.TryParse(value, out int parsedValue)){ + return parsedValue; + } + + return DurationParser.ParseDuration(value); + } + + public static object D(string value) => int.Parse(value); + public static object T(string value) => int.Parse(value); + public static object R(string value) => int.Parse(value); + public static object PresentationTime(string value) => int.Parse(value); + public static object DefaultParser(string value) => value; + + // public static Dictionary ParseAttributes(XmlNode el) + // { + // if (!(el != null && el.Attributes != null)) + // { + // return new Dictionary(); + // } + // + // return el.Attributes.Cast() + // .ToDictionary(attr => attr.Name, attr => + // { + // Func parseFn; + // if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)) + // { + // return parseFn(attr.Value); + // } + // return DefaultParser(attr.Value); + // }); + // } + + public static dynamic ParseAttributes(XmlNode el){ + var expandoObj = new ExpandoObject() as IDictionary; + + if (el != null && el.Attributes != null){ + foreach (XmlAttribute attr in el.Attributes){ + Func parseFn; + if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){ + expandoObj[attr.Name] = parseFn(attr.Value); + } else{ + expandoObj[attr.Name] = DefaultParser(attr.Value); + } + } + } + + return expandoObj; + } +} + +// public class ParsedAttributes{ +// public double MediaPresentationDuration{ get; set; } +// public long AvailabilityStartTime{ get; set; } +// public double MinimumUpdatePeriod{ get; set; } +// public double SuggestedPresentationDelay{ get; set; } +// public string Type{ get; set; } +// public double TimeShiftBufferDepth{ get; set; } +// public double? Start{ get; set; } +// public int Width{ get; set; } +// public int Height{ get; set; } +// public int Bandwidth{ get; set; } +// public double FrameRate{ get; set; } +// public int StartNumber{ get; set; } +// public int Timescale{ get; set; } +// public int PresentationTimeOffset{ get; set; } +// public double? Duration{ get; set; } +// public int D{ get; set; } +// public int T{ get; set; } +// public int R{ get; set; } +// public int PresentationTime{ get; set; } +// +// public int clientOffset{ get; set; } +// +// public long NOW{ get; set; } +// public double sourceDuration{ get; set; } +// public List locations{ get; set; } +// public string baseUrl{ get; set; } +// public string? serviceLocation{ get; set; } +// +// public ParsedAttributes(){ +// +// } +// +// public ParsedAttributes( +// double mediaPresentationDuration, +// long availabilityStartTime, +// double minimumUpdatePeriod, +// double suggestedPresentationDelay, +// string type, +// double timeShiftBufferDepth, +// double? start, +// int width, +// int height, +// int bandwidth, +// double frameRate, +// int startNumber, +// int timescale, +// int presentationTimeOffset, +// double? duration, +// int d, +// int t, +// int r, +// int presentationTime){ +// MediaPresentationDuration = mediaPresentationDuration; +// AvailabilityStartTime = availabilityStartTime; +// MinimumUpdatePeriod = minimumUpdatePeriod; +// SuggestedPresentationDelay = suggestedPresentationDelay; +// Type = type; +// TimeShiftBufferDepth = timeShiftBufferDepth; +// Start = start; +// Width = width; +// Height = height; +// Bandwidth = bandwidth; +// FrameRate = frameRate; +// StartNumber = startNumber; +// Timescale = timescale; +// PresentationTimeOffset = presentationTimeOffset; +// Duration = duration; +// D = d; +// T = t; +// R = r; +// PresentationTime = presentationTime; +// } +// } +// +// public class ParseAttribute{ +// public static Dictionary> ParsersDictionary = new Dictionary>{ +// { "mediaPresentationDuration", MediaPresentationDuration }, +// { "availabilityStartTime", AvailabilityStartTime }, +// { "minimumUpdatePeriod", MinimumUpdatePeriod }, +// { "suggestedPresentationDelay", SuggestedPresentationDelay }, +// { "type", Type }, +// { "timeShiftBufferDepth", TimeShiftBufferDepth }, +// { "start", Start }, +// { "width", Width }, +// { "height", Height }, +// { "bandwidth", Bandwidth }, +// { "frameRate", FrameRate }, +// { "startNumber", StartNumber }, +// { "timescale", Timescale }, +// { "presentationTimeOffset", PresentationTimeOffset }, +// { "duration", Duration }, +// { "d", D }, +// { "t", T }, +// { "r", R }, +// { "presentationTime", PresentationTime }, +// { "DEFAULT", DefaultParser } +// }; +// +// public static object MediaPresentationDuration(string value) => DurationParser.ParseDuration(value); +// public static object AvailabilityStartTime(string value) => DurationParser.ParseDate(value) / 1000; +// public static object MinimumUpdatePeriod(string value) => DurationParser.ParseDuration(value); +// public static object SuggestedPresentationDelay(string value) => DurationParser.ParseDuration(value); +// public static object Type(string value) => value; +// public static object TimeShiftBufferDepth(string value) => DurationParser.ParseDuration(value); +// public static object Start(string value) => DurationParser.ParseDuration(value); +// public static object Width(string value) => int.Parse(value); +// public static object Height(string value) => int.Parse(value); +// public static object Bandwidth(string value) => int.Parse(value); +// public static object FrameRate(string value) => DivisionValueParser.ParseDivisionValue(value); +// public static object StartNumber(string value) => int.Parse(value); +// public static object Timescale(string value) => int.Parse(value); +// public static object PresentationTimeOffset(string value) => int.Parse(value); +// +// public static object Duration(string value){ +// if (int.TryParse(value, out int parsedValue)){ +// return parsedValue; +// } +// +// return DurationParser.ParseDuration(value); +// } +// +// public static object D(string value) => int.Parse(value); +// public static object T(string value) => int.Parse(value); +// public static object R(string value) => int.Parse(value); +// public static object PresentationTime(string value) => int.Parse(value); +// public static object DefaultParser(string value) => value; +// +// public static ParsedAttributes ParseAttributes(XmlNode el){ +// if (!(el != null && el.Attributes != null)){ +// return new ParsedAttributes(0, 0, 0, 0, "", 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0, 0); +// } +// double mediaPresentationDuration = 0; +// long availabilityStartTime = 0; +// double minimumUpdatePeriod = 0; +// double suggestedPresentationDelay = 0; +// string type = ""; +// double timeShiftBufferDepth = 0; +// double? start = null; +// int width = 0; +// int height = 0; +// int bandwidth = 0; +// double frameRate = 0; +// int startNumber = 0; +// int timescale = 0; +// int presentationTimeOffset = 0; +// double? duration = null; +// int d = 0; +// int t = 0; +// int r = 0; +// int presentationTime = 0; +// +// foreach (XmlAttribute attr in el.Attributes){ +// Func parseFn; +// if (ParsersDictionary.TryGetValue(attr.Name, out parseFn)){ +// switch (attr.Name){ +// case "mediaPresentationDuration": +// mediaPresentationDuration = (double)parseFn(attr.Value); +// break; +// case "availabilityStartTime": +// availabilityStartTime = (long)parseFn(attr.Value); +// break; +// case "minimumUpdatePeriod": +// minimumUpdatePeriod = (double)parseFn(attr.Value); +// break; +// case "suggestedPresentationDelay": +// suggestedPresentationDelay = (double)parseFn(attr.Value); +// break; +// case "type": +// type = (string)parseFn(attr.Value); +// break; +// case "timeShiftBufferDepth": +// timeShiftBufferDepth = (double)parseFn(attr.Value); +// break; +// case "start": +// start = (double)parseFn(attr.Value); +// break; +// case "width": +// width = (int)parseFn(attr.Value); +// break; +// case "height": +// height = (int)parseFn(attr.Value); +// break; +// case "bandwidth": +// bandwidth = (int)parseFn(attr.Value); +// break; +// case "frameRate": +// frameRate = (double)parseFn(attr.Value); +// break; +// case "startNumber": +// startNumber = (int)parseFn(attr.Value); +// break; +// case "timescale": +// timescale = (int)parseFn(attr.Value); +// break; +// case "presentationTimeOffset": +// presentationTimeOffset = (int)parseFn(attr.Value); +// break; +// case "duration": +// duration = (double)parseFn(attr.Value); +// break; +// case "d": +// d = (int)parseFn(attr.Value); +// break; +// case "t": +// t = (int)parseFn(attr.Value); +// break; +// case "r": +// r = (int)parseFn(attr.Value); +// break; +// case "presentationTime": +// presentationTime = (int)parseFn(attr.Value); +// break; +// // Add cases for other attributes +// } +// } +// } +// +// return new ParsedAttributes(mediaPresentationDuration, availabilityStartTime, minimumUpdatePeriod, suggestedPresentationDelay, type, timeShiftBufferDepth, start, width, height, bandwidth, frameRate, startNumber, +// timescale, presentationTimeOffset, duration, d, t, r, presentationTime); +// } +// } \ No newline at end of file diff --git a/Utils/Parser/Playlists/PlaylistMerge.cs b/Utils/Parser/Playlists/PlaylistMerge.cs new file mode 100644 index 0000000..bfec9d2 --- /dev/null +++ b/Utils/Parser/Playlists/PlaylistMerge.cs @@ -0,0 +1,131 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CRD.Utils.Parser; + +public class PlaylistMerge{ + public static List Union(List> lists, Func keyFunction){ + var uniqueElements = new Dictionary(); + + foreach (var list in lists){ + foreach (var element in list){ + dynamic key = keyFunction(element); + if (!uniqueElements.ContainsKey(key)){ + uniqueElements[key] = element; + } + } + } + + // Return the values as a list + return uniqueElements.Values.ToList(); + } + + public static List GetUniqueTimelineStarts(List> timelineStarts){ + var uniqueStarts = Union(timelineStarts, el => el.timeline); + + // Sort the results based on the timeline + return uniqueStarts.OrderBy(el => el.timeline).ToList(); + } + + public static dynamic PositionManifestOnTimeline(dynamic oldManifest, dynamic newManifest){ + List oldPlaylists = ((List)oldManifest.playlists).AddRange(GetMediaGroupPlaylists(oldManifest)).ToList(); + List newPlaylists = ((List)newManifest.playlists).AddRange(GetMediaGroupPlaylists(newManifest)).ToList(); + + newManifest.timelineStarts = GetUniqueTimelineStarts(new List>{ oldManifest.timelineStarts, newManifest.timelineStarts }); + + // Assuming UpdateSequenceNumbers is implemented elsewhere + UpdateSequenceNumbers(oldPlaylists, newPlaylists, newManifest.timelineStarts); + + return newManifest; + } + + private static readonly string[] SupportedMediaTypes ={ "AUDIO", "SUBTITLES" }; + + public static List GetMediaGroupPlaylists(dynamic manifest){ + var mediaGroupPlaylists = new List(); + + foreach (var mediaType in SupportedMediaTypes){ + var mediaGroups = (IDictionary)manifest.mediaGroups[mediaType]; + foreach (var groupKey in mediaGroups.Keys){ + var labels = (IDictionary)mediaGroups[groupKey]; + foreach (var labelKey in labels.Keys){ + var properties = (dynamic)labels[labelKey]; + if (properties.playlists != null){ + mediaGroupPlaylists.AddRange(properties.playlists); + } + } + } + } + + return mediaGroupPlaylists; + } + + private const double TimeFudge = 1 / (double)60; + + public static void UpdateSequenceNumbers(List oldPlaylists, List newPlaylists, List timelineStarts){ + foreach (dynamic playlist in newPlaylists){ + playlist.discontinuitySequence = timelineStarts.FindIndex(ts => ts.timeline == playlist.timeline); + + dynamic oldPlaylist = FindPlaylistWithName(oldPlaylists, playlist.attributes.NAME); + + if (oldPlaylist == null){ + // New playlist, no further processing needed + continue; + } + + if (playlist.sidx != null){ + // Skip playlists with sidx + continue; + } + + if (!playlist.segments.Any()){ + // No segments to process + continue; + } + + dynamic firstNewSegment = playlist.segments[0]; + List segmentList = oldPlaylist.segments; + dynamic oldMatchingSegmentIndex = segmentList.FindIndex( + oldSegment => Math.Abs(oldSegment.presentationTime - firstNewSegment.presentationTime) < TimeFudge + ); + + if (oldMatchingSegmentIndex == -1){ + UpdateMediaSequenceForPlaylist(playlist, oldPlaylist.mediaSequence + oldPlaylist.segments.Count); + playlist.segments[0].discontinuity = true; + playlist.discontinuityStarts.Insert(0, 0); + + if ((!oldPlaylist.segments.Any() && playlist.timeline > oldPlaylist.timeline) || + (oldPlaylist.segments.Any() && playlist.timeline > oldPlaylist.segments.Last().timeline)){ + playlist.discontinuitySequence--; + } + + continue; + } + + var oldMatchingSegment = oldPlaylist.segments[oldMatchingSegmentIndex]; + + if (oldMatchingSegment.discontinuity && !firstNewSegment.discontinuity){ + firstNewSegment.discontinuity = true; + playlist.discontinuityStarts.Insert(0, 0); + playlist.discontinuitySequence--; + } + + UpdateMediaSequenceForPlaylist(playlist, oldPlaylist.segments[oldMatchingSegmentIndex].number); + } + } + + public static dynamic FindPlaylistWithName(List playlists, string name){ + return playlists.FirstOrDefault(playlist => playlist.attributes.NAME == name); + } + + public static void UpdateMediaSequenceForPlaylist(dynamic playlist, int mediaSequence){ + playlist.mediaSequence = mediaSequence; + + if (playlist.segments == null) return; + + for (int index = 0; index < playlist.segments.Count; index++){ + playlist.segments[index].number = playlist.mediaSequence + index; + } + } +} \ No newline at end of file diff --git a/Utils/Parser/Playlists/ToPlaylistsClass.cs b/Utils/Parser/Playlists/ToPlaylistsClass.cs new file mode 100644 index 0000000..39e7ccd --- /dev/null +++ b/Utils/Parser/Playlists/ToPlaylistsClass.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using CRD.Utils.Parser.Segments; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser; + +public class ToPlaylistsClass{ + public static List ToPlaylists(IEnumerable representations){ + return representations.Select(GenerateSegments).ToList(); + } + + public static dynamic GenerateSegments(dynamic input){ + dynamic segmentAttributes = new ExpandoObject(); + Func, List> segmentsFn = null; + + + if (ObjectUtilities.GetMemberValue(input.segmentInfo,"template") != null){ + segmentsFn = SegmentTemplate.SegmentsFromTemplate; + segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.template); + } else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"@base") != null){ + //TODO + Console.WriteLine("UNTESTED PARSING"); + segmentsFn = SegmentBase.SegmentsFromBase; + segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.@base); + } else if (ObjectUtilities.GetMemberValue(input.segmentInfo,"list") != null){ + //TODO + Console.WriteLine("UNTESTED PARSING"); + segmentsFn = SegmentList.SegmentsFromList; + segmentAttributes = ObjectUtilities.MergeExpandoObjects(input.attributes, input.segmentInfo.list); + } + + dynamic segmentsInfo = new ExpandoObject(); + segmentsInfo.attributes = input.attributes; + + if (segmentsFn == null){ + return segmentsInfo; + } + + List segments = segmentsFn(segmentAttributes, input.segmentInfo.segmentTimeline); + + // Duration processing + if (ObjectUtilities.GetMemberValue(segmentAttributes,"duration") != null){ + int timescale = ObjectUtilities.GetMemberValue(segmentAttributes,"timescale") ?? 1; + segmentAttributes.duration = ObjectUtilities.GetMemberValue(segmentAttributes,"duration") / timescale; + } else if (segments.Any()){ + segmentAttributes.duration = segments.Max(segment => Math.Ceiling(ObjectUtilities.GetMemberValue(segment,"duration"))); + } else{ + segmentAttributes.duration = 0; + } + + segmentsInfo.attributes = segmentAttributes; + segmentsInfo.segments = segments; + + // sidx box handling + if (ObjectUtilities.GetMemberValue(input.segmentInfo,"base") != null && ObjectUtilities.GetMemberValue(segmentAttributes,"indexRange") != null){ + segmentsInfo.sidx = segments.FirstOrDefault(); + segmentsInfo.segments = new List(); + } + + return segmentsInfo; + } +} \ No newline at end of file diff --git a/Utils/Parser/Segments/DurationTimeParser.cs b/Utils/Parser/Segments/DurationTimeParser.cs new file mode 100644 index 0000000..4478d19 --- /dev/null +++ b/Utils/Parser/Segments/DurationTimeParser.cs @@ -0,0 +1,89 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CRD.Utils.Parser.Segments; + +public class DurationTimeParser{ + public static int? ParseEndNumber(string endNumber){ + if (!int.TryParse(endNumber, out var parsedEndNumber)){ + return null; + } + + return parsedEndNumber; + } + + public static dynamic GetSegmentRangeStatic(dynamic attributes){ + int timescale = attributes.timescale ?? 1; + double segmentDuration = (double)attributes.duration / timescale; + int? endNumber = ParseEndNumber(attributes.endNumber as string); + + if (endNumber.HasValue){ + return new{ start = 0, end = endNumber.Value }; + } + + if (attributes.periodDuration is double periodDuration){ + return new{ start = 0, end = (int)(periodDuration / segmentDuration) }; + } + + return new{ start = 0, end = (int)(attributes.sourceDuration / segmentDuration) }; + } + + public static dynamic GetSegmentRangeDynamic(dynamic attributes){ + long now = (attributes.NOW + attributes.clientOffset) / 1000; + long periodStartWC = attributes.availabilityStartTime + attributes.periodStart; + long periodEndWC = now + attributes.minimumUpdatePeriod; + long periodDuration = periodEndWC - periodStartWC; + int timescale = attributes.timescale ?? 1; + int segmentCount = (int)Math.Ceiling(periodDuration * timescale / (double)attributes.duration); + int availableStart = (int)Math.Floor((now - periodStartWC - attributes.timeShiftBufferDepth) * timescale / (double)attributes.duration); + int availableEnd = (int)Math.Floor((now - periodStartWC) * timescale / (double)attributes.duration); + + int? endNumber = ParseEndNumber(attributes.endNumber as string); + int end = endNumber.HasValue ? endNumber.Value : Math.Min(segmentCount, availableEnd); + + return new{ start = Math.Max(0, availableStart), end = end }; + } + + public static List ToSegments(dynamic attributes, int number){ + int timescale = attributes.timescale ?? 1; + long periodStart = attributes.periodStart; + int startNumber = attributes.startNumber ?? 1; + + return new List{ + new{ + number = startNumber + number, + duration = (double)attributes.duration / timescale, + timeline = periodStart, + time = number * attributes.duration + } + }; + } + + public static IEnumerable ParseByDuration(dynamic attributes){ + var type = (string)attributes.type; + var rangeFunction = type == "static" ? (Func)GetSegmentRangeStatic : GetSegmentRangeDynamic; + dynamic times = rangeFunction(attributes); + List d = Range(times.start, times.end - times.start); + List segments = d.Select(number => ToSegments(attributes, number)).ToList(); + + + // Adjust the duration of the last segment for static type + if (type == "static" && segments.Any()){ + var lastSegmentIndex = segments.Count - 1; + double sectionDuration = attributes.periodDuration is double periodDuration ? periodDuration : attributes.sourceDuration; + segments[lastSegmentIndex].duration = sectionDuration - ((double)attributes.duration / (attributes.timescale ?? 1) * lastSegmentIndex); + } + + return segments; + } + + public static List Range(int start, int end){ + List res = new List(); + for (int i = start; i < end; i++){ + res.Add(i); + } + + return res; + } +} \ No newline at end of file diff --git a/Utils/Parser/Segments/SegmentBase.cs b/Utils/Parser/Segments/SegmentBase.cs new file mode 100644 index 0000000..f201040 --- /dev/null +++ b/Utils/Parser/Segments/SegmentBase.cs @@ -0,0 +1,111 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Numerics; + +namespace CRD.Utils.Parser.Segments; + +public class SegmentBase{ + public static List SegmentsFromBase(dynamic attributes, List segmentTimeline){ + if (attributes.baseUrl == null){ + throw new Exception("NO_BASE_URL"); + } + + var initialization = attributes.initialization ?? new ExpandoObject(); + var sourceDuration = attributes.sourceDuration; + var indexRange = attributes.indexRange ?? ""; + var periodStart = attributes.periodStart; + var presentationTime = attributes.presentationTime; + var number = attributes.number ?? 0; + var duration = attributes.duration; + + dynamic initSegment = UrlType.UrlTypeToSegment(new{ + baseUrl = attributes.baseUrl, + source = initialization.sourceURL, + range = initialization.range + }); + + dynamic segment = UrlType.UrlTypeToSegment(new{ + baseUrl = attributes.baseUrl, + source = attributes.baseUrl, + indexRange = indexRange + }); + + segment.map = initSegment; + + if (duration != null){ + var segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes); + if (segmentTimeInfo.Count > 0){ + segment.duration = segmentTimeInfo[0].duration; + segment.timeline = segmentTimeInfo[0].timeline; + } + } else if (sourceDuration != null){ + segment.duration = sourceDuration; + segment.timeline = periodStart; + } + + segment.presentationTime = presentationTime ?? periodStart; + segment.number = number; + + return new List{ segment }; + } + + + public static dynamic AddSidxSegmentsToPlaylist(dynamic playlist, dynamic sidx, string baseUrl){ + // Assume dynamic objects like sidx have properties similar to JavaScript objects + var initSegment = playlist.sidx.ContainsKey("map") ? playlist.sidx.map : null; + var sourceDuration = playlist.sidx.duration; + var timeline = playlist.timeline ?? 0; + dynamic sidxByteRange = playlist.sidx.byterange; + BigInteger sidxEnd = new BigInteger((long)sidxByteRange.offset + (long)sidxByteRange.length); + var timescale = (long)sidx.timescale; + var mediaReferences = ((List)sidx.references).Where(r => r.referenceType != 1).ToList(); + var segments = new List(); + var type = playlist.endList ? "static" : "dynamic"; + var periodStart = (long)playlist.sidx.timeline; + BigInteger presentationTime = new BigInteger(periodStart); + var number = playlist.mediaSequence ?? 0; + + BigInteger startIndex; + if (sidx.firstOffset is BigInteger){ + startIndex = sidxEnd + (BigInteger)sidx.firstOffset; + } else{ + startIndex = sidxEnd + new BigInteger((long)sidx.firstOffset); + } + + foreach (var reference in mediaReferences){ + var size = (long)reference.referencedSize; + var duration = (long)reference.subsegmentDuration; + BigInteger endIndex = startIndex + new BigInteger(size) - BigInteger.One; + var indexRange = $"{startIndex}-{endIndex}"; + + dynamic attributes = new ExpandoObject(); + attributes.baseUrl = baseUrl; + attributes.timescale = timescale; + attributes.timeline = timeline; + attributes.periodStart = periodStart; + attributes.presentationTime = (long)presentationTime; + attributes.number = number; + attributes.duration = duration; + attributes.sourceDuration = sourceDuration; + attributes.indexRange = indexRange; + attributes.type = type; + + var segment = SegmentsFromBase(attributes, new List())[0]; + + if (initSegment != null){ + segment.map = initSegment; + } + + segments.Add(segment); + startIndex += new BigInteger(size); + presentationTime += new BigInteger(duration) / new BigInteger(timescale); + number++; + } + + playlist.segments = segments; + + return playlist; + } +} \ No newline at end of file diff --git a/Utils/Parser/Segments/SegmentList.cs b/Utils/Parser/Segments/SegmentList.cs new file mode 100644 index 0000000..42e106b --- /dev/null +++ b/Utils/Parser/Segments/SegmentList.cs @@ -0,0 +1,57 @@ +using System; +using System.Collections.Generic; +using System.Linq; + +namespace CRD.Utils.Parser.Segments; + +public class SegmentList{ + public static List SegmentsFromList(dynamic attributes, List segmentTimeline){ + if ((!attributes.duration && segmentTimeline == null) || + (attributes.duration && segmentTimeline != null)){ + throw new Exception("Segment time unspecified"); + } + + List segmentUrls = ((List)attributes.segmentUrls)?.ToList() ?? new List(); + var segmentUrlMap = segmentUrls.Select(segmentUrlObject => SegmentURLToSegmentObject(attributes, segmentUrlObject)).ToList(); + + List segmentTimeInfo = null; + if (attributes.duration != null){ + segmentTimeInfo = DurationTimeParser.ParseByDuration(attributes); // Needs to be implemented + } else if (segmentTimeline != null){ + segmentTimeInfo = TimelineTimeParser.ParseByTimeline(attributes, segmentTimeline); // Needs to be implemented + } + + var segments = segmentTimeInfo.Select((segmentTime, index) => { + if (index < segmentUrlMap.Count){ + var segment = segmentUrlMap[index]; + segment.Timeline = segmentTime.Timeline; + segment.Duration = segmentTime.Duration; + segment.Number = segmentTime.Number; + segment.PresentationTime = attributes.periodStart + ((segmentTime.Time - (attributes.presentationTimeOffset ?? 0)) / (attributes.timescale ?? 1)); + + return segment; + } + + return null; + }).Where(segment => segment != null).ToList(); + + return segments; + } + + public static dynamic SegmentURLToSegmentObject(dynamic attributes, dynamic segmentUrl){ + var initSegment = UrlType.UrlTypeToSegment(new{ + baseUrl = attributes.baseUrl, + source = attributes.initialization?.sourceURL, + range = attributes.initialization?.range + }); + + var segment = UrlType.UrlTypeToSegment(new{ + baseUrl = attributes.baseUrl, + source = segmentUrl.media, + range = segmentUrl.mediaRange + }); + + segment.Map = initSegment; + return segment; + } +} \ No newline at end of file diff --git a/Utils/Parser/Segments/SegmentTemplate.cs b/Utils/Parser/Segments/SegmentTemplate.cs new file mode 100644 index 0000000..8b56c5f --- /dev/null +++ b/Utils/Parser/Segments/SegmentTemplate.cs @@ -0,0 +1,107 @@ +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; +using System.Text.RegularExpressions; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser.Segments; + +public class SegmentTemplate{ + public static List SegmentsFromTemplate(dynamic attributes, List segmentTimeline){ + dynamic templateValues = new ExpandoObject(); + templateValues.RepresentationID = ObjectUtilities.GetMemberValue(attributes,"id"); + templateValues.Bandwidth = ObjectUtilities.GetMemberValue(attributes,"bandwidth") ?? 0; + + dynamic initialization = attributes.initialization ?? new{ sourceURL = string.Empty, range = string.Empty }; + + dynamic mapSegment = UrlType.UrlTypeToSegment(new{ + baseUrl = ObjectUtilities.GetMemberValue(attributes,"baseUrl"), + source = ConstructTemplateUrl(initialization.sourceURL, templateValues), + range = ObjectUtilities.GetMemberValue(initialization,"range") + }); + + List segments = ParseTemplateInfo(attributes, segmentTimeline); + + return segments.Select(segment => { + templateValues.Number = ObjectUtilities.GetMemberValue(segment,"number"); + templateValues.Time = ObjectUtilities.GetMemberValue(segment,"time"); + + var uri = ConstructTemplateUrl(ObjectUtilities.GetMemberValue(attributes,"media") ?? "", templateValues); + var timescale = ObjectUtilities.GetMemberValue(attributes,"timescale") ?? 1; + var presentationTimeOffset = ObjectUtilities.GetMemberValue(attributes,"presentationTimeOffset") ?? 0; + double presentationTime = ObjectUtilities.GetMemberValue(attributes,"periodStart") + ((ObjectUtilities.GetMemberValue(segment,"time") - presentationTimeOffset) / (double) timescale); + + dynamic map = new ExpandoObject(); + map.uri = uri; + map.timeline = ObjectUtilities.GetMemberValue(segment,"timeline"); + map.duration = ObjectUtilities.GetMemberValue(segment,"duration"); + map.resolvedUri = UrlUtils.ResolveUrl(ObjectUtilities.GetMemberValue(attributes,"baseUrl") ?? "", uri); + map.map = mapSegment; + map.number = ObjectUtilities.GetMemberValue(segment,"number"); + map.presentationTime = presentationTime; + + return map; + }).ToList(); + } + + + private static readonly Regex IdentifierPattern = new Regex(@"\$([A-Za-z]*)(?:(%0)([0-9]+)d)?\$", RegexOptions.Compiled); + + public static string ConstructTemplateUrl(string url, dynamic values){ + // Convert dynamic to IDictionary for easier handling + var valuesDictionary = (IDictionary)values; + return IdentifierPattern.Replace(url, match => IdentifierReplacement(match, valuesDictionary)); + } + + private static string IdentifierReplacement(Match match, IDictionary values){ + if (match.Value == "$$"){ + // escape sequence + return "$"; + } + + var identifier = match.Groups[1].Value; + var format = match.Groups[2].Value; + var widthStr = match.Groups[3].Value; + + if (!values.ContainsKey(identifier)){ + return match.Value; + } + + var value = values[identifier]?.ToString() ?? ""; + + if (identifier == "RepresentationID"){ + // Format tag shall not be present with RepresentationID + return value; + } + + int width = string.IsNullOrEmpty(format) ? 1 : int.Parse(widthStr); + if (value.Length >= width){ + return value; + } + + return value.PadLeft(width, '0'); + } + + public static List ParseTemplateInfo(dynamic attributes, List segmentTimeline){ + // Check if duration and SegmentTimeline are not present + if (ObjectUtilities.GetMemberValue(attributes,"duration") == null && segmentTimeline == null){ + // Exactly one media segment expected + return new List{ + new{ + number = ObjectUtilities.GetMemberValue(attributes,"startNumber") ?? 1, + duration = ObjectUtilities.GetMemberValue(attributes,"sourceDuration"), + time = 0, + timeline = ObjectUtilities.GetMemberValue(attributes,"periodStart") + } + }; + } + + if (ObjectUtilities.GetMemberValue(attributes,"duration") != null){ + // Parse segments based on duration + return DurationTimeParser.ParseByDuration(attributes); + } + + // Parse segments based on SegmentTimeline + return TimelineTimeParser.ParseByTimeline(attributes, segmentTimeline); + } +} \ No newline at end of file diff --git a/Utils/Parser/Segments/TimelineTimeParser.cs b/Utils/Parser/Segments/TimelineTimeParser.cs new file mode 100644 index 0000000..78a7438 --- /dev/null +++ b/Utils/Parser/Segments/TimelineTimeParser.cs @@ -0,0 +1,65 @@ +using System; +using System.Collections.Generic; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser.Segments; + +public class TimelineTimeParser{ + public static int GetLiveRValue(dynamic attributes, long time, long duration){ + long now = (attributes.NOW + attributes.clientOffset) / 1000; + long periodStartWC = attributes.availabilityStartTime + (attributes.periodStart ?? 0); + long periodEndWC = now + (attributes.minimumUpdatePeriod ?? 0); + long periodDuration = periodEndWC - periodStartWC; + long timescale = attributes.timescale ?? 1; + + return (int)Math.Ceiling(((periodDuration * timescale) - time) / (double)duration); + } + + public static List ParseByTimeline(dynamic attributes, IEnumerable segmentTimeline){ + var segments = new List(); + long time = -1; + long timescale = attributes.timescale ?? 1; + int startNumber = attributes.startNumber ?? 1; + double timeline = attributes.periodStart; + + int sIndex = 0; + foreach (var S in segmentTimeline){ + long duration = ObjectUtilities.GetMemberValue(S,"d"); + int repeat = ObjectUtilities.GetMemberValue(S,"r") ?? 0; + long segmentTime = ObjectUtilities.GetMemberValue(S,"t") ?? 0; + + if (time < 0){ + // first segment + time = segmentTime; + } + + if (segmentTime > time){ + // discontinuity + time = segmentTime; + } + + int count; + if (repeat < 0){ + count = GetLiveRValue(attributes, time, duration); + } else{ + count = repeat + 1; + } + + int end = startNumber + segments.Count + count; + + for (int number = startNumber + segments.Count; number < end; number++){ + segments.Add(new { + number = number, + duration = duration / (double)timescale, + time = time, + timeline = timeline + }); + time += duration; + } + + sIndex++; + } + + return segments; + } +} diff --git a/Utils/Parser/Segments/UrlType.cs b/Utils/Parser/Segments/UrlType.cs new file mode 100644 index 0000000..f097222 --- /dev/null +++ b/Utils/Parser/Segments/UrlType.cs @@ -0,0 +1,34 @@ +using System; +using CRD.Utils.Parser.Utils; + +namespace CRD.Utils.Parser.Segments; + +public class UrlType{ + public static dynamic UrlTypeToSegment(dynamic input){ + dynamic segment = new { + uri = ObjectUtilities.GetMemberValue(input,"source"), + resolvedUri = new Uri(new Uri(input.baseUrl, UriKind.Absolute), input.source).ToString() + }; + + string rangeStr = !string.IsNullOrEmpty(input.range) ? ObjectUtilities.GetMemberValue(input,"range") : ObjectUtilities.GetMemberValue(input,"indexRange"); + if (!string.IsNullOrEmpty(rangeStr)){ + var ranges = rangeStr.Split('-'); + long startRange = long.Parse(ranges[0]); + long endRange = long.Parse(ranges[1]); + long length = endRange - startRange + 1; + + segment.ByteRange = new { + length = length, + offset = startRange + }; + } + + return segment; + } + + + public static string ByteRangeToString(dynamic byteRange){ + long endRange = byteRange.offset + byteRange.length - 1; + return $"{byteRange.offset}-{endRange}"; + } +} \ No newline at end of file diff --git a/Utils/Parser/Utils/DivisionValueParser.cs b/Utils/Parser/Utils/DivisionValueParser.cs new file mode 100644 index 0000000..7199e2d --- /dev/null +++ b/Utils/Parser/Utils/DivisionValueParser.cs @@ -0,0 +1,13 @@ +namespace CRD.Utils.Parser.Utils; + +public class DivisionValueParser{ + public static double ParseDivisionValue(string value){ + string[] parts = value.Split('/'); + double result = double.Parse(parts[0]); + for (int i = 1; i < parts.Length; i++){ + result /= double.Parse(parts[i]); + } + + return result; + } +} \ No newline at end of file diff --git a/Utils/Parser/Utils/DurationParser.cs b/Utils/Parser/Utils/DurationParser.cs new file mode 100644 index 0000000..564421f --- /dev/null +++ b/Utils/Parser/Utils/DurationParser.cs @@ -0,0 +1,66 @@ +using System; +using System.Globalization; +using System.Text.RegularExpressions; + +namespace CRD.Utils.Parser.Utils; + +public class DurationParser{ + private const int SECONDS_IN_YEAR = 365 * 24 * 60 * 60; + private const int SECONDS_IN_MONTH = 30 * 24 * 60 * 60; + private const int SECONDS_IN_DAY = 24 * 60 * 60; + private const int SECONDS_IN_HOUR = 60 * 60; + private const int SECONDS_IN_MIN = 60; + + public static double ParseDuration(string str){ + // P10Y10M10DT10H10M10.1S + Regex durationRegex = new Regex(@"P(?:(\d*)Y)?(?:(\d*)M)?(?:(\d*)D)?(?:T(?:(\d*)H)?(?:(\d*)M)?(?:([\d.]*)S)?)?"); + Match match = durationRegex.Match(str); + + if (!match.Success){ + return 0; + } + + double year = string.IsNullOrEmpty(match.Groups[1].Value) ? 0 : GetDouble(match.Groups[1].Value,0); + double month = string.IsNullOrEmpty(match.Groups[2].Value) ? 0 : GetDouble(match.Groups[2].Value,0); + double day = string.IsNullOrEmpty(match.Groups[3].Value) ? 0 : GetDouble(match.Groups[3].Value,0); + double hour = string.IsNullOrEmpty(match.Groups[4].Value) ? 0 : GetDouble(match.Groups[4].Value,0); + double minute = string.IsNullOrEmpty(match.Groups[5].Value) ? 0 : GetDouble(match.Groups[5].Value,0); + double second = string.IsNullOrEmpty(match.Groups[6].Value) ? 0 : GetDouble(match.Groups[6].Value,0); + + return (year * SECONDS_IN_YEAR + + month * SECONDS_IN_MONTH + + day * SECONDS_IN_DAY + + hour * SECONDS_IN_HOUR + + minute * SECONDS_IN_MIN + + second); + } + + public static double GetDouble(string value, double defaultValue){ + double result; + + // // Try parsing in the current culture + // if (!double.TryParse(value, System.Globalization.NumberStyles.Any, CultureInfo.CurrentCulture, out result) && + // // Then try in US english + // !double.TryParse(value, System.Globalization.NumberStyles.Any, CultureInfo.GetCultureInfo("en-US"), out result) && + // // Then in neutral language + // !double.TryParse(value, System.Globalization.NumberStyles.Any, CultureInfo.InvariantCulture, out result)) + // { + // result = defaultValue; + // } + return double.Parse(value, CultureInfo.InvariantCulture); + } + + public static long ParseDate(string str){ + // Date format without timezone according to ISO 8601 + // YYY-MM-DDThh:mm:ss.ssssss + string dateRegexPattern = @"^\d+-\d+-\d+T\d+:\d+:\d+(\.\d+)?$"; + + // If the date string does not specify a timezone, we must specify UTC. This is + // expressed by ending with 'Z' + if (Regex.IsMatch(str, dateRegexPattern)){ + str += 'Z'; + } + + return DateTimeOffset.Parse(str).ToUnixTimeMilliseconds(); + } +} \ No newline at end of file diff --git a/Utils/Parser/Utils/ManifestInfo.cs b/Utils/Parser/Utils/ManifestInfo.cs new file mode 100644 index 0000000..f068344 --- /dev/null +++ b/Utils/Parser/Utils/ManifestInfo.cs @@ -0,0 +1,10 @@ +using System.Collections.Generic; + +namespace CRD.Utils.Parser.Utils; + +public class ManifestInfo{ + public dynamic locations{ get; set; } + public dynamic contentSteeringInfo{ get; set; } + public dynamic representationInfo{ get; set; } + public dynamic eventStream{ get; set; } +} \ No newline at end of file diff --git a/Utils/Parser/Utils/ObjectUtilities.cs b/Utils/Parser/Utils/ObjectUtilities.cs new file mode 100644 index 0000000..6f2db91 --- /dev/null +++ b/Utils/Parser/Utils/ObjectUtilities.cs @@ -0,0 +1,110 @@ +using System; +using System.Collections.Generic; +using System.Dynamic; +using System.Linq; + +namespace CRD.Utils.Parser.Utils; + +public class ObjectUtilities{ + public static ExpandoObject MergeExpandoObjects(dynamic target, dynamic source){ + var result = new ExpandoObject(); + var resultDict = result as IDictionary; + + // Cast source and target to dictionaries if they are not null + var targetDict = target as IDictionary; + var sourceDict = source as IDictionary; + + // If both are null, return an empty ExpandoObject + if (targetDict == null && sourceDict == null){ + Console.WriteLine("Nothing Merged; both are empty"); + return result; // result is already a new ExpandoObject + } + + // Copy targetDict into resultDict + if (targetDict != null){ + foreach (var kvp in targetDict){ + resultDict[kvp.Key] = kvp.Value; // Add or overwrite key-value pairs + } + } + + // Copy sourceDict into resultDict, potentially overwriting values from targetDict + if (sourceDict != null){ + foreach (var kvp in sourceDict){ + resultDict[kvp.Key] = kvp.Value; // Overwrites if key exists + } + } + + return result; + } + + public static void SetAttributeWithDefault(dynamic ob, string attributeName, string defaultValue){ + var obDict = ob as IDictionary; + + if (obDict == null){ + throw new ArgumentException("Provided object must be an ExpandoObject."); + } + + // Check if the attribute exists and is not null or empty + if (obDict.TryGetValue(attributeName, out object value) && value != null && !string.IsNullOrEmpty(value.ToString())){ + obDict[attributeName] = value; + } else{ + obDict[attributeName] = defaultValue; + } + } + + public static object GetAttributeWithDefault(dynamic ob, string attributeName, string defaultValue){ + var obDict = ob as IDictionary; + + if (obDict == null){ + throw new ArgumentException("Provided object must be an ExpandoObject."); + } + + // Check if the attribute exists and is not null or empty + if (obDict.TryGetValue(attributeName, out object value) && value != null && !string.IsNullOrEmpty(value.ToString())){ + return value; + } else{ + return defaultValue; + } + } + + public static void SetFieldFromOrToDefault(dynamic targetObject, string fieldToSet, string fieldToGetValueFrom, object defaultValue){ + var targetDict = targetObject as IDictionary; + + if (targetDict == null){ + throw new ArgumentException("Provided targetObject must be an ExpandoObject."); + } + + // Attempt to get the value from the specified field + object valueToSet = defaultValue; + if (targetDict.TryGetValue(fieldToGetValueFrom, out object valueFromField) && valueFromField != null){ + valueToSet = valueFromField; + } + + // Set the specified field to the retrieved value or the default value + targetDict[fieldToSet] = valueToSet; + } + + public static object GetMemberValue(dynamic obj, string memberName){ + // First, check if the object is indeed an ExpandoObject + if (obj is ExpandoObject expando){ + // Try to get the value from the ExpandoObject + var dictionary = (IDictionary)expando; + if (dictionary.TryGetValue(memberName, out object value)){ + // Return the found value, which could be null + return value; + } + } else if (obj != null){ + // For non-ExpandoObject dynamics, attempt to access the member directly + // This part might throw exceptions if the member does not exist + try{ + return obj.GetType().GetProperty(memberName)?.GetValue(obj, null) ?? + obj.GetType().GetField(memberName)?.GetValue(obj); + } catch{ + // Member access failed, handle accordingly (e.g., log the issue) + } + } + + // Member doesn't exist or obj is null, return null or a default value + return null; + } +} \ No newline at end of file diff --git a/Utils/Parser/Utils/UrlResolver.cs b/Utils/Parser/Utils/UrlResolver.cs new file mode 100644 index 0000000..38149c4 --- /dev/null +++ b/Utils/Parser/Utils/UrlResolver.cs @@ -0,0 +1,8 @@ +using System.Text.RegularExpressions; +using System; + +namespace CRD.Utils.Parser.Utils; + +public class UrlResolver{ + +} \ No newline at end of file diff --git a/Utils/Parser/Utils/UrlUtils.cs b/Utils/Parser/Utils/UrlUtils.cs new file mode 100644 index 0000000..ad5f632 --- /dev/null +++ b/Utils/Parser/Utils/UrlUtils.cs @@ -0,0 +1,23 @@ +using System; + +namespace CRD.Utils.Parser.Utils; + +public class UrlUtils{ + public static string ResolveUrl(string baseUrl, string relativeUrl){ + // Return early if the relative URL is actually an absolute URL + if (Uri.IsWellFormedUriString(relativeUrl, UriKind.Absolute)) + return relativeUrl; + + // Handle the case where baseUrl is not specified or invalid + Uri baseUri; + if (string.IsNullOrEmpty(baseUrl) || !Uri.TryCreate(baseUrl, UriKind.Absolute, out baseUri)){ + // Assuming you want to use a default base if none is provided + // For example, you could default to "http://example.com" + // This part is up to how you want to handle such cases + baseUri = new Uri("http://example.com"); + } + + Uri resolvedUri = new Uri(baseUri, relativeUrl); + return resolvedUri.ToString(); + } +} \ No newline at end of file diff --git a/Utils/Parser/Utils/XMLUtils.cs b/Utils/Parser/Utils/XMLUtils.cs new file mode 100644 index 0000000..52ec6f9 --- /dev/null +++ b/Utils/Parser/Utils/XMLUtils.cs @@ -0,0 +1,29 @@ +using System.Collections.Generic; +using System.Linq; +using System.Xml; + +namespace CRD.Utils.Parser.Utils; + +public class XMLUtils{ + public static List FindChildren(XmlElement element, string name){ + return From(element.ChildNodes).OfType().Where(child => child.Name == name).ToList(); + } + + public static string GetContent(XmlElement element){ + return element.InnerText.Trim(); + } + + private static List From(XmlNodeList list){ + if (list.Count == 0){ + return new List(); + } + + List result = new List(list.Count); + + for (int i = 0; i < list.Count; i++){ + result.Add(list[i]); + } + + return result; + } +} \ No newline at end of file diff --git a/Utils/Structs/CalendarStructs.cs b/Utils/Structs/CalendarStructs.cs new file mode 100644 index 0000000..6b520ed --- /dev/null +++ b/Utils/Structs/CalendarStructs.cs @@ -0,0 +1,70 @@ +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Views; +using ReactiveUI; + +namespace CRD.Utils.Structs; + +public class CalendarWeek{ + public DateTime? FirstDayOfWeek{ get; set; } + public string? FirstDayOfWeekString{ get; set; } + public List? CalendarDays{ get; set; } +} + +public class CalendarDay{ + public DateTime? DateTime{ get; set; } + public string? DayName{ get; set; } + public List? CalendarEpisodes{ get; set; } +} + +public partial class CalendarEpisode : INotifyPropertyChanged{ + public DateTime? DateTime{ get; set; } + public bool? HasPassed{ get; set; } + public string? EpisodeName{ get; set; } + public string? SeasonUrl{ get; set; } + public string? EpisodeUrl{ get; set; } + public string? ThumbnailUrl{ get; set; } + public Bitmap? ImageBitmap{ get; set; } + + public string? EpisodeNumber{ get; set; } + + public bool IsPremiumOnly{ get; set; } + + public string? SeasonName{ get; set; } + + public event PropertyChangedEventHandler? PropertyChanged; + + [RelayCommand] + public void AddEpisodeToQue(string episodeUrl){ + var match = Regex.Match(episodeUrl, "/([^/]+)/watch/([^/]+)"); + + if (match.Success){ + var locale = match.Groups[1].Value; // Capture the locale part + var id = match.Groups[2].Value; // Capture the ID part + Crunchyroll.Instance.AddEpisodeToQue(id, locale, Crunchyroll.Instance.CrunOptions.DubLang); + } + } + + public async Task LoadImage(){ + try{ + using (var client = new HttpClient()){ + var response = await client.GetAsync(ThumbnailUrl); + response.EnsureSuccessStatusCode(); + using (var stream = await response.Content.ReadAsStreamAsync()){ + ImageBitmap = new Bitmap(stream); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImageBitmap))); + } + } + } catch (Exception ex){ + // Handle exceptions + Console.WriteLine("Failed to load image: " + ex.Message); + } + } +} \ No newline at end of file diff --git a/Utils/Structs/Chapters.cs b/Utils/Structs/Chapters.cs new file mode 100644 index 0000000..2804922 --- /dev/null +++ b/Utils/Structs/Chapters.cs @@ -0,0 +1,33 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public struct CrunchyChapters{ + public List Chapters { get; set; } + public DateTime? lastUpdate { get; set; } + public string? mediaId { get; set; } +} + +public struct CrunchyChapter{ + public string approverId { get; set; } + public string distributionNumber { get; set; } + public int? end { get; set; } + public int? start { get; set; } + public string title { get; set; } + public string seriesId { get; set; } + [JsonProperty("new")] + public bool New { get; set; } + public string type { get; set; } +} + +public struct CrunchyOldChapter{ + public string media_id { get; set; } + public double startTime { get; set; } + public double endTime { get; set; } + public double duration { get; set; } + public string comparedWith { get; set; } + public string ordering { get; set; } + public DateTime last_updated { get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrCmsToken.cs b/Utils/Structs/CrCmsToken.cs new file mode 100644 index 0000000..6ec205b --- /dev/null +++ b/Utils/Structs/CrCmsToken.cs @@ -0,0 +1,24 @@ +using System; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public class CrCmsToken{ + [JsonProperty("cms")] public CmsTokenB Cms{ get; set; } + [JsonProperty("cms_beta")] public CmsTokenB CmsBeta{ get; set; } + [JsonProperty("cms_web")] public CmsTokenB CmsWeb{ get; set; } + + [JsonProperty("service_available")] public bool ServiceAvailable{ get; set; } + + [JsonProperty("default_marketing_opt_in")] + public bool DefaultMarketingOptIn{ get; set; } +} + +public struct CmsTokenB{ + public string Bucket{ get; set; } + public string Policy{ get; set; } + public string Signature{ get; set; } + [JsonProperty("key_pair_id")] public string KeyPairId{ get; set; } + + public DateTime Expires{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrDownloadOptions.cs b/Utils/Structs/CrDownloadOptions.cs new file mode 100644 index 0000000..7308702 --- /dev/null +++ b/Utils/Structs/CrDownloadOptions.cs @@ -0,0 +1,123 @@ +using System.Collections.Generic; +using YamlDotNet.Serialization; + +namespace CRD.Utils.Structs; + +public class CrDownloadOptions{ + [YamlMember(Alias = "hard_sub_lang", ApplyNamingConventions = false)] + public string Hslang{ get; set; } //locale string none or locale + + [YamlIgnore] + public int Kstream{ get; set; } + + [YamlMember(Alias = "no_video", ApplyNamingConventions = false)] + public bool Novids{ get; set; } //dont download videos + + [YamlMember(Alias = "no_audio", ApplyNamingConventions = false)] + public bool Noaudio{ get; set; } //dont download audio + + [YamlIgnore] + public int X{ get; set; } // selected server + + [YamlMember(Alias = "quality_video", ApplyNamingConventions = false)] + public string QualityVideo{ get; set; } //quality 0 is best + + [YamlMember(Alias = "quality_audio", ApplyNamingConventions = false)] + public string QualityAudio{ get; set; } //quality 0 is best + + [YamlMember(Alias = "file_name", ApplyNamingConventions = false)] + public string FileName{ get; set; } // + + [YamlMember(Alias = "leading_numbers", ApplyNamingConventions = false)] + public int Numbers{ get; set; } //leading 0 probably + + [YamlIgnore] + public int Partsize{ get; set; } // download parts at same time? + + [YamlIgnore] + public int Timeout{ get; set; } + + [YamlIgnore] + public int Waittime{ get; set; } + + [YamlIgnore] + public int FsRetryTime{ get; set; } + + [YamlMember(Alias = "soft_subs", ApplyNamingConventions = false)] + public List DlSubs{ get; set; } //all or local for subs to download + + [YamlIgnore] + public bool SkipSubs{ get; set; } // don't download subs + + [YamlIgnore] + public bool NoSubs{ get; set; } // don't download subs + + [YamlMember(Alias = "mux_mp4", ApplyNamingConventions = false)] + public bool Mp4{ get; set; } // mp4 output else mkv + + [YamlIgnore] + public List Override{ get; set; } + + [YamlIgnore] + public string VideoTitle{ get; set; } // ??? + + [YamlIgnore] + public string Force{ get; set; } // always Y + + [YamlMember(Alias = "mux_ffmpeg", ApplyNamingConventions = false)] + public List FfmpegOptions{ get; set; } //additional ffmpeg options + + [YamlMember(Alias = "mux_mkvmerge", ApplyNamingConventions = false)] + public List MkvmergeOptions{ get; set; } //additional mkvmerge + + [YamlIgnore] + public LanguageItem DefaultSub{ get; set; } //default sub + + [YamlIgnore] + public LanguageItem DefaultAudio{ get; set; } //default audio + + [YamlIgnore] + public string CcTag{ get; set; } //cc tag ?? + + [YamlIgnore] + public bool DlVideoOnce{ get; set; } // don't download same video multiple times + + [YamlIgnore] + public bool? Skipmux{ get; set; } //mux in the end or not + + [YamlIgnore] + public bool SyncTiming{ get; set; } // sync timing in muxing + + [YamlIgnore] + public bool Nocleanup{ get; set; } // cleanup files after muxing + + [YamlMember(Alias = "chapters", ApplyNamingConventions = false)] + public bool Chapters{ get; set; } // download chaperts + + [YamlIgnore] + public string? FontName{ get; set; } //font sutff + + [YamlIgnore] + public bool OriginalFontSize{ get; set; } //font sutff + + [YamlIgnore] + public int FontSize{ get; set; } //font sutff + + [YamlMember(Alias = "dub_lang", ApplyNamingConventions = false)] + public List DubLang{ get; set; } //dub lang download + + [YamlMember(Alias = "simultaneous_downloads", ApplyNamingConventions = false)] + public int SimultaneousDownloads{ get; set; } + + [YamlMember(Alias = "theme", ApplyNamingConventions = false)] + public string Theme{ get; set; } + + [YamlMember(Alias = "accent_color", ApplyNamingConventions = false)] + public string? AccentColor{ get; set; } + + [YamlIgnore] + public string? SelectedCalendarLanguage{ get; set; } + + [YamlMember(Alias = "history", ApplyNamingConventions = false)] + public bool History{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrProfile.cs b/Utils/Structs/CrProfile.cs new file mode 100644 index 0000000..17f1cb3 --- /dev/null +++ b/Utils/Structs/CrProfile.cs @@ -0,0 +1,15 @@ +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public class CrProfile{ + public string? Avatar{ get; set; } + public string? Email{ get; set; } + public string? Username{ get; set; } + + [JsonProperty("preferred_content_audio_language")] + public string? PreferredContentAudioLanguage{ get; set; } + + [JsonProperty("preferred_content_subtitle_language")] + public string? PreferredContentSubtitleLanguage{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrSeriesBase.cs b/Utils/Structs/CrSeriesBase.cs new file mode 100644 index 0000000..f397a82 --- /dev/null +++ b/Utils/Structs/CrSeriesBase.cs @@ -0,0 +1,96 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public class CrSeriesBase{ + public int Total{ get; set; } + public SeriesBaseItem[]? Data{ get; set; } + public Meta Meta{ get; set; } +} + +public struct SeriesBaseItem{ + [JsonProperty("extended_maturity_rating")] + public Dictionary + ExtendedMaturityRating{ get; set; } + + [JsonProperty("extended_description")] + public string ExtendedDescription{ get; set; } + + [JsonProperty("episode_count")] + public int EpisodeCount{ get; set; } + + [JsonProperty("is_mature")] + public bool IsMature{ get; set; } + + + public Images Images{ get; set; } + + + [JsonProperty("season_count")] + public int SeasonCount{ get; set; } + + [JsonProperty("content_descriptors")] + public List ContentDescriptors{ get; set; } + + + public string Id{ get; set; } + + + [JsonProperty("media_count")] + public int MediaCount{ get; set; } + + + [JsonProperty("is_simulcast")] + public bool IsSimulcast{ get; set; } + + [JsonProperty("seo_description")] + public string SeoDescription{ get; set; } + + [JsonProperty("availability_notes")] + public string AvailabilityNotes{ get; set; } + + [JsonProperty("season_tags")] + public List SeasonTags{ get; set; } + + [JsonProperty("maturity_ratings")] + public List MaturityRatings{ get; set; } + + [JsonProperty("mature_blocked")] + public bool MatureBlocked{ get; set; } + + [JsonProperty("is_dubbed")] + public bool IsDubbed{ get; set; } + + [JsonProperty("series_launch_year")] + public int SeriesLaunchYear{ get; set; } + + public string Slug{ get; set; } + + [JsonProperty("content_provider")] + public string ContentProvider{ get; set; } + + [JsonProperty("subtitle_locales")] + public List SubtitleLocales{ get; set; } + + public string Title{ get; set; } + + [JsonProperty("is_subbed")] + public bool IsSubbed{ get; set; } + + [JsonProperty("seo_title")] + public string SeoTitle{ get; set; } + + [JsonProperty("channel_id")] + public string ChannelId{ get; set; } + + [JsonProperty("slug_title")] + public string SlugTitle{ get; set; } + + public string Description{ get; set; } + + public List Keywords{ get; set; } + + [JsonProperty("audio_locales")] + public List AudioLocales{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrSeriesSearch.cs b/Utils/Structs/CrSeriesSearch.cs new file mode 100644 index 0000000..131166a --- /dev/null +++ b/Utils/Structs/CrSeriesSearch.cs @@ -0,0 +1,59 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public class CrSeriesSearch{ + public int Total{ get; set; } + public SeriesSearchItem[]? Data{ get; set; } + public Meta Meta{ get; set; } +} + +public struct SeriesSearchItem{ + public string Description{ get; set; } + [JsonProperty("seo_description")] public string SeoDescription{ get; set; } + [JsonProperty("number_of_episodes")] public int NumberOfEpisodes{ get; set; } + [JsonProperty("is_dubbed")] public bool IsDubbed{ get; set; } + public string Identifier{ get; set; } + [JsonProperty("channel_id")] public string ChannelId{ get; set; } + [JsonProperty("slug_title")] public string SlugTitle{ get; set; } + + [JsonProperty("season_sequence_number")] + public int SeasonSequenceNumber{ get; set; } + + [JsonProperty("season_tags")] public List SeasonTags{ get; set; } + + [JsonProperty("extended_maturity_rating")] + public Dictionary + ExtendedMaturityRating{ get; set; } + + [JsonProperty("is_mature")] public bool IsMature{ get; set; } + [JsonProperty("audio_locale")] public string AudioLocale{ get; set; } + [JsonProperty("season_number")] public int SeasonNumber{ get; set; } + public Dictionary Images{ get; set; } + [JsonProperty("mature_blocked")] public bool MatureBlocked{ get; set; } + public List Versions{ get; set; } + public string Title{ get; set; } + [JsonProperty("is_subbed")] public bool IsSubbed{ get; set; } + public string Id{ get; set; } + [JsonProperty("audio_locales")] public List AudioLocales{ get; set; } + [JsonProperty("subtitle_locales")] public List SubtitleLocales{ get; set; } + [JsonProperty("availability_notes")] public string AvailabilityNotes{ get; set; } + [JsonProperty("series_id")] public string SeriesId{ get; set; } + + [JsonProperty("season_display_number")] + public string SeasonDisplayNumber{ get; set; } + + [JsonProperty("is_complete")] public bool IsComplete{ get; set; } + public List Keywords{ get; set; } + [JsonProperty("maturity_ratings")] public List MaturityRatings{ get; set; } + [JsonProperty("is_simulcast")] public bool IsSimulcast{ get; set; } + [JsonProperty("seo_title")] public string SeoTitle{ get; set; } +} + +public struct Version{ + [JsonProperty("audio_locale")] public string? AudioLocale{ get; set; } + public string? Guid{ get; set; } + public bool? Original{ get; set; } + public string? Variant{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrToken.cs b/Utils/Structs/CrToken.cs new file mode 100644 index 0000000..1749018 --- /dev/null +++ b/Utils/Structs/CrToken.cs @@ -0,0 +1,15 @@ +using System; + +namespace CRD.Utils.Structs; + +public class CrToken{ + public string? access_token { get; set; } + public string? refresh_token { get; set; } + public int? expires_in { get; set; } + public string? token_type { get; set; } + public string? scope { get; set; } + public string? country { get; set; } + public string? account_id { get; set; } + public string? profile_id { get; set; } + public DateTime? expires { get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/CrunchyNoDRMStream.cs b/Utils/Structs/CrunchyNoDRMStream.cs new file mode 100644 index 0000000..cf4fe93 --- /dev/null +++ b/Utils/Structs/CrunchyNoDRMStream.cs @@ -0,0 +1,46 @@ +using System.Collections.Generic; + +namespace CRD.Utils.Structs; + +public class CrunchyNoDrmStream{ + public string? AssetId{ get; set; } + public string? AudioLocale{ get; set; } + public string? Bifs{ get; set; } + public string? BurnedInLocale{ get; set; } + public Dictionary? Captions{ get; set; } + public Dictionary? HardSubs{ get; set; } + public string? PlaybackType{ get; set; } + public Session? Session{ get; set; } + public Dictionary? Subtitles{ get; set; } + public string? Token{ get; set; } + public string? Url{ get; set; } + public List? Versions{ get; set; } // Use a more specific type if known +} + +public class Caption{ + public string? Format{ get; set; } + public string? Language{ get; set; } + public string? Url{ get; set; } +} + +public class HardSub{ + public string? Hlang{ get; set; } + public string? Url{ get; set; } + public string? Quality{ get; set; } +} + +public class Session{ + public int? RenewSeconds{ get; set; } + public int? NoNetworkRetryIntervalSeconds{ get; set; } + public int? NoNetworkTimeoutSeconds{ get; set; } + public int? MaximumPauseSeconds{ get; set; } + public int? EndOfVideoUnloadSeconds{ get; set; } + public int? SessionExpirationSeconds{ get; set; } + public bool? UsesStreamLimits{ get; set; } +} + +public class Subtitle{ + public string? Format{ get; set; } + public string? Language{ get; set; } + public string? Url{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/EpisodeStructs.cs b/Utils/Structs/EpisodeStructs.cs new file mode 100644 index 0000000..2b6232f --- /dev/null +++ b/Utils/Structs/EpisodeStructs.cs @@ -0,0 +1,211 @@ +using System; +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public struct CrunchyEpisodeList{ + public int Total{ get; set; } + public List? Data{ get; set; } + public Meta Meta{ get; set; } +} + +public struct CrunchyEpisode{ + [JsonProperty("next_episode_id")] public string NextEpisodeId{ get; set; } + [JsonProperty("series_id")] public string SeriesId{ get; set; } + [JsonProperty("season_number")] public int SeasonNumber{ get; set; } + [JsonProperty("next_episode_title")] public string NextEpisodeTitle{ get; set; } + [JsonProperty("availability_notes")] public string AvailabilityNotes{ get; set; } + [JsonProperty("duration_ms")] public int DurationMs{ get; set; } + [JsonProperty("series_slug_title")] public string SeriesSlugTitle{ get; set; } + [JsonProperty("series_title")] public string SeriesTitle{ get; set; } + [JsonProperty("is_dubbed")] public bool IsDubbed{ get; set; } + public List? Versions{ get; set; } // Assume Version is defined elsewhere. + public string Identifier{ get; set; } + [JsonProperty("sequence_number")] public float SequenceNumber{ get; set; } + [JsonProperty("eligible_region")] public string EligibleRegion{ get; set; } + [JsonProperty("availability_starts")] public DateTime? AvailabilityStarts{ get; set; } + public Images? Images{ get; set; } // Assume Images is a struct or class you've defined elsewhere. + [JsonProperty("season_id")] public string SeasonId{ get; set; } + [JsonProperty("seo_title")] public string SeoTitle{ get; set; } + [JsonProperty("is_premium_only")] public bool IsPremiumOnly{ get; set; } + + [JsonProperty("extended_maturity_rating")] + public Dictionary ExtendedMaturityRating{ get; set; } + + public string Title{ get; set; } + + [JsonProperty("production_episode_id")] + public string ProductionEpisodeId{ get; set; } + + [JsonProperty("premium_available_date")] + public DateTime? PremiumAvailableDate{ get; set; } + + [JsonProperty("season_title")] public string SeasonTitle{ get; set; } + [JsonProperty("seo_description")] public string SeoDescription{ get; set; } + + [JsonProperty("audio_locale")] public string AudioLocale{ get; set; } + public string Id{ get; set; } + [JsonProperty("media_type")] public MediaType? MediaType{ get; set; } // MediaType should be an enum you define based on possible values. + [JsonProperty("availability_ends")] public DateTime? AvailabilityEnds{ get; set; } + [JsonProperty("free_available_date")] public DateTime? FreeAvailableDate{ get; set; } + public string Playback{ get; set; } + [JsonProperty("channel_id")] public ChannelId? ChannelId{ get; set; } // ChannelID should be an enum or struct. + public string? Episode{ get; set; } + [JsonProperty("is_mature")] public bool IsMature{ get; set; } + [JsonProperty("listing_id")] public string ListingId{ get; set; } + [JsonProperty("episode_air_date")] public DateTime? EpisodeAirDate{ get; set; } + public string Slug{ get; set; } + [JsonProperty("available_date")] public DateTime? AvailableDate{ get; set; } + [JsonProperty("subtitle_locales")] public List SubtitleLocales{ get; set; } + [JsonProperty("slug_title")] public string SlugTitle{ get; set; } + [JsonProperty("available_offline")] public bool AvailableOffline{ get; set; } + public string Description{ get; set; } + [JsonProperty("is_subbed")] public bool IsSubbed{ get; set; } + [JsonProperty("premium_date")] public DateTime? PremiumDate{ get; set; } + [JsonProperty("upload_date")] public DateTime? UploadDate{ get; set; } + [JsonProperty("season_slug_title")] public string SeasonSlugTitle{ get; set; } + + [JsonProperty("closed_captions_available")] + public bool ClosedCaptionsAvailable{ get; set; } + + [JsonProperty("episode_number")] public int? EpisodeNumber{ get; set; } + [JsonProperty("season_tags")] public List SeasonTags{ get; set; } // More specific type could be used if known. + [JsonProperty("maturity_ratings")] public List MaturityRatings{ get; set; } // MaturityRating should be defined based on possible values. + [JsonProperty("streams_link")] public string? StreamsLink{ get; set; } + [JsonProperty("mature_blocked")] public bool? MatureBlocked{ get; set; } + [JsonProperty("is_clip")] public bool IsClip{ get; set; } + [JsonProperty("hd_flag")] public bool HdFlag{ get; set; } + [JsonProperty("hide_season_title")] public bool? HideSeasonTitle{ get; set; } + [JsonProperty("hide_season_number")] public bool? HideSeasonNumber{ get; set; } + public bool? IsSelected{ get; set; } + [JsonProperty("seq_id")] public string SeqId{ get; set; } + [JsonProperty("__links__")] public Links? Links{ get; set; } +} + +// public struct CrunchyEpisode{ +// +// public string channel_id{ get; set; } +// public bool is_mature{ get; set; } +// public string upload_date{ get; set; } +// public string free_available_date{ get; set; } +// public List content_descriptors{ get; set; } +// public Dictionary images{ get; set; } // Consider specifying actual key and value types if known +// public int season_sequence_number{ get; set; } +// public string audio_locale{ get; set; } +// public string title{ get; set; } +// public Dictionary +// extended_maturity_rating{ get; set; } // Consider specifying actual key and value types if known +// public bool available_offline{ get; set; } +// public string identifier{ get; set; } +// public string listing_id{ get; set; } +// public List season_tags{ get; set; } +// public string next_episode_id{ get; set; } +// public string next_episode_title{ get; set; } +// public bool is_subbed{ get; set; } +// public string slug{ get; set; } +// public List versions{ get; set; } +// public int season_number{ get; set; } +// public string availability_ends{ get; set; } +// public string eligible_region{ get; set; } +// public bool is_clip{ get; set; } +// public string description{ get; set; } +// public string seo_description{ get; set; } +// public bool is_premium_only{ get; set; } +// public string streams_link{ get; set; } +// public int episode_number{ get; set; } +// public bool closed_captions_available{ get; set; } +// +// public bool is_dubbed{ get; set; } +// public string seo_title{ get; set; } +// public long duration_ms{ get; set; } +// public string id{ get; set; } +// public string series_id{ get; set; } +// public string series_slug_title{ get; set; } +// public string episode_air_date{ get; set; } +// public bool hd_flag{ get; set; } +// public bool mature_blocked{ get; set; } +// +// public string availability_notes{ get; set; } +// +// public List maturity_ratings{ get; set; } +// public string episode{ get; set; } +// public int sequence_number{ get; set; } +// public List subtitle_locales{ get; set; } +// +// } + +public struct Images{ + [JsonProperty("poster_tall")] public List>? PosterTall{ get; set; } + [JsonProperty("poster_wide")] public List>? PosterWide{ get; set; } + [JsonProperty("promo_image")] public List>? PromoImage{ get; set; } + public List> Thumbnail{ get; set; } +} + +public struct Image{ + public int Height{ get; set; } + public string Source{ get; set; } + public ImageType Type{ get; set; } + public int Width{ get; set; } +} + +public struct EpisodeVersion{ + [JsonProperty("audio_locale")] public string AudioLocale{ get; set; } + public string Guid{ get; set; } + [JsonProperty("is_premium_only")] public bool IsPremiumOnly{ get; set; } + [JsonProperty("media_guid")] public string? MediaGuid{ get; set; } + public bool Original{ get; set; } + [JsonProperty("season_guid")] public string SeasonGuid{ get; set; } + public string Variant{ get; set; } +} + +public struct Link{ + public string Href{ get; set; } +} + +public struct Links(){ + public Dictionary LinkMappings{ get; set; } = new(){ + { "episode/channel", default }, + { "episode/next_episode", default }, + { "episode/season", default }, + { "episode/series", default }, + { "streams", default } + }; +} + +public class CrunchyEpMeta{ + public List? Data{ get; set; } + + public string? SeriesTitle{ get; set; } + public string? SeasonTitle{ get; set; } + public string? EpisodeNumber{ get; set; } + public string? EpisodeTitle{ get; set; } + public string? SeasonId{ get; set; } + public int? Season{ get; set; } + public string? ShowId{ get; set; } + public string? AbsolutEpisodeNumberE{ get; set; } + public string? Image{ get; set; } + public bool Paused{ get; set; } + public DownloadProgress? DownloadProgress{ get; set; } +} + +public class DownloadProgress{ + + public bool IsDownloading = false; + public bool Done = false; + public bool Error = false; + public string Doing = string.Empty; + + public int Percent{ get; set; } + public double Time{ get; set; } + public double DownloadSpeed{ get; set; } +} + +public struct CrunchyEpMetaData{ + public string MediaId{ get; set; } + public LanguageItem? Lang{ get; set; } + public string? Playback{ get; set; } + public List? Versions{ get; set; } + public bool IsSubbed{ get; set; } + public bool IsDubbed{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/Languages.cs b/Utils/Structs/Languages.cs new file mode 100644 index 0000000..a505cc9 --- /dev/null +++ b/Utils/Structs/Languages.cs @@ -0,0 +1,142 @@ +using System; +using System.Collections.Generic; +using System.Linq; +using System.Reflection; +using System.Text.RegularExpressions; + +namespace CRD.Utils.Structs; + +public class Languages{ + public static readonly LanguageItem[] languages ={ + new(){ CrLocale = "en-US", Locale = "en", Code = "eng", Name = "English" }, + new(){ CrLocale = "en-IN", Locale = "en-IN", Code = "eng", Name = "English (India)" }, + new(){ CrLocale = "es-LA", Locale = "es-419", Code = "spa", Name = "Spanish", Language = "Latin American Spanish" }, + new(){ CrLocale = "es-419", Locale = "es-419", Code = "spa-419", Name = "Spanish", Language = "Latin American Spanish" }, + new(){ CrLocale = "es-ES", Locale = "es-ES", Code = "spa-ES", Name = "Castilian", Language = "European Spanish" }, + new(){ CrLocale = "pt-BR", Locale = "pt-BR", Code = "por", Name = "Portuguese", Language = "Brazilian Portuguese" }, + new(){ CrLocale = "pt-PT", Locale = "pt-PT", Code = "por", Name = "Portuguese (Portugal)", Language = "Portugues (Portugal)" }, + new(){ CrLocale = "fr-FR", Locale = "fr", Code = "fra", Name = "French" }, + new(){ CrLocale = "de-DE", Locale = "de", Code = "deu", Name = "German" }, + new(){ CrLocale = "ar-ME", Locale = "ar", Code = "ara-ME", Name = "Arabic" }, + new(){ CrLocale = "ar-SA", Locale = "ar", Code = "ara", Name = "Arabic (Saudi Arabia)" }, + new(){ CrLocale = "it-IT", Locale = "it", Code = "ita", Name = "Italian" }, + new(){ CrLocale = "ru-RU", Locale = "ru", Code = "rus", Name = "Russian" }, + new(){ CrLocale = "tr-TR", Locale = "tr", Code = "tur", Name = "Turkish" }, + new(){ CrLocale = "hi-IN", Locale = "hi", Code = "hin", Name = "Hindi" }, + // new(){ locale = "zh", code = "cmn", name = "Chinese (Mandarin, PRC)" }, + new(){ CrLocale = "zh-CN", Locale = "zh-CN", Code = "zho", Name = "Chinese (Mainland China)" }, + new(){ CrLocale = "zh-TW", Locale = "zh-TW", Code = "chi", Name = "Chinese (Taiwan)" }, + new(){ CrLocale = "ko-KR", Locale = "ko", Code = "kor", Name = "Korean" }, + new(){ CrLocale = "ca-ES", Locale = "ca-ES", Code = "cat", Name = "Catalan" }, + new(){ CrLocale = "pl-PL", Locale = "pl-PL", Code = "pol", Name = "Polish" }, + new(){ CrLocale = "th-TH", Locale = "th-TH", Code = "tha", Name = "Thai", Language = "ไทย" }, + new(){ CrLocale = "ta-IN", Locale = "ta-IN", Code = "tam", Name = "Tamil (India)", Language = "தமிழ்" }, + new(){ CrLocale = "ms-MY", Locale = "ms-MY", Code = "may", Name = "Malay (Malaysia)", Language = "Bahasa Melayu" }, + new(){ CrLocale = "vi-VN", Locale = "vi-VN", Code = "vie", Name = "Vietnamese", Language = "Tiếng Việt" }, + new(){ CrLocale = "id-ID", Locale = "id-ID", Code = "ind", Name = "Indonesian", Language = "Bahasa Indonesia" }, + new(){ CrLocale = "te-IN", Locale = "te-IN", Code = "tel", Name = "Telugu (India)", Language = "తెలుగు" }, + new(){ CrLocale = "ja-JP", Locale = "ja", Code = "jpn", Name = "Japanese" }, + new(){ CrLocale = "id-ID", Locale = "id", Code = "in", Name = "Indonesian " }, + }; + + public static LanguageItem FixAndFindCrLc(string cr_locale){ + if (string.IsNullOrEmpty(cr_locale)){ + return new LanguageItem(); + } + string str = FixLanguageTag(cr_locale); + return FindLang(str); + } + + public static string SubsFile(string fnOutput, string subsIndex, LanguageItem langItem, bool isCC, string ccTag, bool? isSigns = false, string? format = "ass"){ + subsIndex = (int.Parse(subsIndex) + 1).ToString().PadLeft(2, '0'); + string fileName = $"{fnOutput}.{subsIndex}.{langItem.Code}"; + + //removed .{langItem.language} from file name at end + + if (isCC){ + fileName += $".{ccTag}"; + } + + if (isSigns == true){ + fileName += ".signs"; + } + + fileName += $".{format}"; + return fileName; + } + + public static string FixLanguageTag(string tag){ + tag = tag ?? "und"; + + var match = Regex.Match(tag, @"^(\w{2})-?(\w{2})$"); + if (match.Success){ + + string tagLang = $"{match.Groups[1].Value}-{match.Groups[2].Value.ToUpper()}"; + + var langObj = FindLang(tagLang); + if (langObj.CrLocale != "und"){ + return langObj.CrLocale; + } + + return tagLang; + } + + return tag; + } + + public static List SortTags(List data){ + var retData = data.Select(e => new LanguageItem{ Locale = e }).ToList(); + var sorted = SortSubtitles(retData); + return sorted.Select(e => e.Locale).ToList(); + } + + public static LanguageItem FindLang(string crLocale){ + LanguageItem lang = languages.FirstOrDefault(l => l.CrLocale == crLocale); + if (lang.CrLocale != null){ + return lang; + } else{ + return new LanguageItem{ + CrLocale = "und", + Locale = "un", + Code = "und", + Name = string.Empty, + Language = string.Empty + }; + } + } + + + public static LanguageItem Locale2language(string locale){ + LanguageItem? filteredLocale = languages.FirstOrDefault(l => { return l.Locale == locale; }); + if (filteredLocale != null){ + return (LanguageItem)filteredLocale; + } else{ + return new LanguageItem{ + CrLocale = "und", + Locale = "un", + Code = "und", + Name = string.Empty, + Language = string.Empty + }; + } + } + + public static List SortSubtitles(List data, string sortKey = "locale"){ + var idx = new Dictionary(); + var tags = new HashSet(languages.Select(e => e.Locale)); + + int order = 1; + foreach (var l in tags){ + idx[l] = order++; + } + + return data.OrderBy(item => { + var property = typeof(T).GetProperty(sortKey, BindingFlags.Public | BindingFlags.Instance | BindingFlags.IgnoreCase); + if (property == null) throw new ArgumentException($"Property '{sortKey}' not found on type '{typeof(T).Name}'."); + + var value = property.GetValue(item) as string; + int index = idx.ContainsKey(value) ? idx[value] : 50; + return index; + }).ToList(); + } +} \ No newline at end of file diff --git a/Utils/Structs/Playback.cs b/Utils/Structs/Playback.cs new file mode 100644 index 0000000..dd74263 --- /dev/null +++ b/Utils/Structs/Playback.cs @@ -0,0 +1,57 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public class PlaybackData{ + public int Total{ get; set; } + public List>>? Data{ get; set; } + public PlaybackMeta? Meta{ get; set; } +} + +public class StreamDetails{ + [JsonProperty("hardsub_locale")] public Locale? HardsubLocale{ get; set; } + public string? Url{ get; set; } + [JsonProperty("hardsub_lang")] public string? HardsubLang{ get; set; } + [JsonProperty("audio_lang")] public string? AudioLang{ get; set; } + public string? Type{ get; set; } +} + +public class PlaybackMeta{ + [JsonProperty("media_id")] public string? MediaId{ get; set; } + public Subtitles? Subtitles{ get; set; } + public List? Bifs{ get; set; } + public List? Versions{ get; set; } + [JsonProperty("audio_locale")] public Locale? AudioLocale{ get; set; } + [JsonProperty("closed_captions")] public Subtitles? ClosedCaptions{ get; set; } + public Dictionary? Captions{ get; set; } +} + +public class SubtitleInfo{ + public string? Format{ get; set; } + public Locale? Locale{ get; set; } + public string? Url{ get; set; } +} + +public class CrunchyStreams : Dictionary; + +public class Subtitles : Dictionary; + +public class PlaybackVersion{ + [JsonProperty("audio_locale")] public Locale AudioLocale{ get; set; } // Assuming Locale is defined elsewhere + public string? Guid{ get; set; } + [JsonProperty("is_premium_only")] public bool IsPremiumOnly{ get; set; } + [JsonProperty("media_guid")] public string? MediaGuid{ get; set; } + public bool Original{ get; set; } + [JsonProperty("season_guid")] public string? SeasonGuid{ get; set; } + public string? Variant{ get; set; } +} + +public class StreamDetailsPop{ + public Locale? HardsubLocale{ get; set; } + public string? Url{ get; set; } + public string? HardsubLang{ get; set; } + public string? AudioLang{ get; set; } + public string? Type{ get; set; } + public string? Format{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/PlaybackDataAndroid.cs b/Utils/Structs/PlaybackDataAndroid.cs new file mode 100644 index 0000000..43e8447 --- /dev/null +++ b/Utils/Structs/PlaybackDataAndroid.cs @@ -0,0 +1,19 @@ +using System.Collections.Generic; + +namespace CRD.Utils.Structs; + +public class PlaybackDataAndroid{ + public string __class__{ get; set; } + public string __href__{ get; set; } + public string __resource_key__{ get; set; } + public Links __links__{ get; set; } + public Dictionary __actions__{ get; set; } + public string media_id{ get; set; } + public Locale audio_locale{ get; set; } + public Subtitles subtitles{ get; set; } + public Subtitles closed_captions{ get; set; } + public List>> streams{ get; set; } + public List bifs{ get; set; } + public List versions{ get; set; } + public Dictionary captions{ get; set; } +} \ No newline at end of file diff --git a/Utils/Structs/Structs.cs b/Utils/Structs/Structs.cs new file mode 100644 index 0000000..85c6dc9 --- /dev/null +++ b/Utils/Structs/Structs.cs @@ -0,0 +1,84 @@ +using System.Collections.Generic; +using Newtonsoft.Json; + +namespace CRD.Utils.Structs; + +public struct AuthData{ + public string Username{ get; set; } + public string Password{ get; set; } +} + +public class DrmAuthData{ + [JsonProperty("custom_data")] public string? CustomData{ get; set; } + public string? Token{ get; set; } +} + +public struct Meta{ + [JsonProperty("versions_considered")] + public bool? VersionsConsidered{ get; set; } +} + +public struct LanguageItem{ + [JsonProperty("cr_locale")] + public string CrLocale{ get; set; } + public string Locale{ get; set; } + public string Code{ get; set; } + public string Name{ get; set; } + public string Language{ get; set; } +} + +public struct EpisodeAndLanguage{ + public List Items{ get; set; } + public List Langs{ get; set; } +} + +public struct CrunchyMultiDownload(List dubLang, bool? all = null, bool? but = null, List? e = null, string? s = null){ + public List DubLang{ get; set; } = dubLang; //lang code + public bool? AllEpisodes{ get; set; } = all; // download all episodes + public bool? But{ get; set; } = but; //download all except selected episodes + public List? E{ get; set; } = e; //episode numbers + public string? S{ get; set; } = s; //season id +} + +public struct CrunchySeriesList{ + public List List{ get; set; } + public Dictionary Data{ get; set; } +} + +public struct Episode{ + public string E{ get; set; } + public List Lang{ get; set; } + public string Name{ get; set; } + public string Season{ get; set; } + public string SeasonTitle{ get; set; } + public string SeriesTitle{ get; set; } + public string EpisodeNum{ get; set; } + public string Id{ get; set; } + public string Img{ get; set; } + public string Description{ get; set; } + public string Time{ get; set; } +} + +public struct DownloadResponse{ + public List Data{ get; set; } + public string FileName{ get; set; } + public bool Error{ get; set; } +} + +public class DownloadedMedia : SxItem{ + public DownloadMediaType Type{ get; set; } + public LanguageItem Lang{ get; set; } + public bool IsPrimary{ get; set; } + + public bool? Cc{ get; set; } + public bool? Signs{ get; set; } +} + +public class SxItem{ + public LanguageItem Language{ get; set; } + public string? Path{ get; set; } + public string? File{ get; set; } + public string? Title{ get; set; } + public Dictionary>? Fonts{ get; set; } +} + diff --git a/Utils/Structs/Variable.cs b/Utils/Structs/Variable.cs new file mode 100644 index 0000000..ffe9adb --- /dev/null +++ b/Utils/Structs/Variable.cs @@ -0,0 +1,18 @@ +namespace CRD.Utils.Structs; + +public class Variable{ + public string Name{ get; set; } + public object ReplaceWith{ get; set; } + public string Type{ get; set; } + public bool Sanitize{ get; set; } + + public Variable(string name, object replaceWith, bool sanitize){ + Name = name; + ReplaceWith = replaceWith; + Type = replaceWith.GetType().Name.ToLower(); + Sanitize = sanitize; + } + + public Variable(){ + } +} \ No newline at end of file diff --git a/Utils/UI/UiIntToVisibilityConverter.cs b/Utils/UI/UiIntToVisibilityConverter.cs new file mode 100644 index 0000000..8acef1c --- /dev/null +++ b/Utils/UI/UiIntToVisibilityConverter.cs @@ -0,0 +1,21 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using FluentAvalonia.UI.Controls; + +namespace CRD.Utils.UI; + +public class UiIntToVisibilityConverter : IValueConverter{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture){ + if (value is int intValue){ + // Return Visible if intValue is greater than or equal to 1, otherwise Collapsed + return intValue >= 1 ? true : false; + } + + return false; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture){ + throw new NotImplementedException("This converter only works for one-way binding"); + } +} \ No newline at end of file diff --git a/Utils/UI/UiSeasonValueConverter.cs b/Utils/UI/UiSeasonValueConverter.cs new file mode 100644 index 0000000..cc8ebc2 --- /dev/null +++ b/Utils/UI/UiSeasonValueConverter.cs @@ -0,0 +1,23 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using FluentAvalonia.UI.Controls; + +namespace CRD.Utils.UI; + +public class UiSeasonValueConverter : IValueConverter{ + public object Convert(object value, Type targetType, object parameter, CultureInfo culture){ + + if (value is string stringValue){ + var parsed = int.TryParse(stringValue, out int seasonNum); + if (parsed) + return $"Season {seasonNum}"; + } + + return "Specials"; + } + + public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture){ + throw new NotImplementedException(); + } +} \ No newline at end of file diff --git a/Utils/UI/UiValueConverter.cs b/Utils/UI/UiValueConverter.cs new file mode 100644 index 0000000..f0a797e --- /dev/null +++ b/Utils/UI/UiValueConverter.cs @@ -0,0 +1,24 @@ +using System; +using System.Globalization; +using Avalonia.Data.Converters; +using FluentAvalonia.UI.Controls; + +namespace CRD.Utils.UI; + +public class UiValueConverter : IValueConverter{ + public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture){ + if (value is bool boolValue){ + return boolValue ? Symbol.Pause : Symbol.Play; + } + + return null; // Or return a default value + } + + public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture){ + if (value is Symbol sym) + { + return sym == Symbol.Pause; + } + return false; + } +} \ No newline at end of file diff --git a/ViewLocator.cs b/ViewLocator.cs new file mode 100644 index 0000000..debb159 --- /dev/null +++ b/ViewLocator.cs @@ -0,0 +1,28 @@ +using System; +using Avalonia.Controls; +using Avalonia.Controls.Templates; +using CRD.ViewModels; + +namespace CRD; + +public class ViewLocator : IDataTemplate{ + public Control? Build(object? data){ + if (data is null) + return null; + + var name = data.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); + var type = Type.GetType(name); + + if (type != null){ + var control = (Control)Activator.CreateInstance(type)!; + control.DataContext = data; + return control; + } + + return new TextBlock{ Text = "Not Found: " + name }; + } + + public bool Match(object? data){ + return data is ViewModelBase; + } +} \ No newline at end of file diff --git a/ViewModels/AccountPageViewModel.cs b/ViewModels/AccountPageViewModel.cs new file mode 100644 index 0000000..c69b3f5 --- /dev/null +++ b/ViewModels/AccountPageViewModel.cs @@ -0,0 +1,66 @@ +using System; +using System.Net.Http; +using System.Threading.Tasks; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Views.Utils; +using FluentAvalonia.UI.Controls; + +namespace CRD.ViewModels; + +public partial class AccountPageViewModel : ViewModelBase{ + [ObservableProperty] private Bitmap? _profileImage; + + [ObservableProperty] private string _profileName = ""; + + [ObservableProperty] private string _loginLogoutText = ""; + + + public AccountPageViewModel(){ + UpdatetProfile(); + } + + public void UpdatetProfile(){ + ProfileName = Crunchyroll.Instance.Profile.Username; // Default or fetched user name + LoginLogoutText = Crunchyroll.Instance.Profile.Username == "???" ? "Login" : "Logout"; // Default state + LoadProfileImage("https://static.crunchyroll.com/assets/avatar/170x170/" + Crunchyroll.Instance.Profile.Avatar); + } + + [RelayCommand] + public async Task Button_Press(){ + if (LoginLogoutText == "Login"){ + var dialog = new ContentDialog(){ + Title = "Login", + PrimaryButtonText = "Login", + CloseButtonText = "Close" + }; + + var viewModel = new ContentDialogInputLoginViewModel(dialog, this); + dialog.Content = new ContentDialogInputLoginView(){ + DataContext = viewModel + }; + + _ = await dialog.ShowAsync(); + } else{ + await Crunchyroll.Instance.CrAuth.AuthAnonymous(); + UpdatetProfile(); + } + } + + public async void LoadProfileImage(string imageUrl){ + try{ + using (var client = new HttpClient()){ + var response = await client.GetAsync(imageUrl); + response.EnsureSuccessStatusCode(); + using (var stream = await response.Content.ReadAsStreamAsync()){ + ProfileImage = new Bitmap(stream); + } + } + } catch (Exception ex){ + // Handle exceptions + Console.WriteLine("Failed to load image: " + ex.Message); + } + } +} \ No newline at end of file diff --git a/ViewModels/AddDownloadPageViewModel.cs b/ViewModels/AddDownloadPageViewModel.cs new file mode 100644 index 0000000..9962c69 --- /dev/null +++ b/ViewModels/AddDownloadPageViewModel.cs @@ -0,0 +1,255 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Net.Http; +using System.Text.RegularExpressions; +using System.Threading.Tasks; +using Avalonia.Controls; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Utils; +using CRD.Utils.Structs; +using CRD.Views; +using ReactiveUI; + + +namespace CRD.ViewModels; + +public partial class AddDownloadPageViewModel : ViewModelBase{ + [ObservableProperty] public string _urlInput = ""; + [ObservableProperty] public string _buttonText = "Enter Url"; + [ObservableProperty] public bool _addAllEpisodes = false; + + [ObservableProperty] public bool _buttonEnabled = false; + [ObservableProperty] public bool _allButtonEnabled = false; + [ObservableProperty] public bool _showLoading = false; + public ObservableCollection Items{ get; } = new(); + public ObservableCollection SelectedItems{ get; } = new(); + + [ObservableProperty] public ComboBoxItem _currentSelectedSeason; + public ObservableCollection SeasonList{ get; } = new(); + + private Dictionary> episodesBySeason = new(); + + private List selectedEpisodes = new(); + + private CrunchySeriesList? currentSeriesList; + + public AddDownloadPageViewModel(){ + // Items.Add(new ItemModel("", "Test", "22:33", "Test", "S1", "E1", 1, new List())); + SelectedItems.CollectionChanged += OnSelectedItemsChanged; + } + + + partial void OnUrlInputChanged(string value){ + if (UrlInput.Length > 9){ + if (UrlInput.Contains("/watch/concert/") || UrlInput.Contains("/artist/")){ + MessageBus.Current.SendMessage(new ToastMessage("Concerts / Artists not implemented yet", ToastType.Error, 1)); + } else if (UrlInput.Contains("/watch/")){ + //Episode + ButtonText = "Add Episode to Queue"; + ButtonEnabled = true; + } else if (UrlInput.Contains("/series/")){ + //Series + ButtonText = "List Episodes"; + ButtonEnabled = true; + } else{ + ButtonEnabled = false; + } + } else{ + ButtonText = "Enter Url"; + ButtonEnabled = false; + } + } + + [RelayCommand] + public async void OnButtonPress(){ + if ((selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes)){ + Console.WriteLine("Added to Queue"); + + if (SelectedItems.Count > 0){ + foreach (var selectedItem in SelectedItems){ + if (!selectedEpisodes.Contains(selectedItem.AbsolutNum)){ + selectedEpisodes.Add(selectedItem.AbsolutNum); + } + } + } + + if (currentSeriesList != null){ + Crunchyroll.Instance.AddSeriesToQueue(currentSeriesList.Value, new CrunchyMultiDownload(Crunchyroll.Instance.CrunOptions.DubLang, AddAllEpisodes, false, selectedEpisodes)); + MessageBus.Current.SendMessage(new ToastMessage($"Added episodes to the queue", ToastType.Information, 1)); + } + + + UrlInput = ""; + selectedEpisodes.Clear(); + SelectedItems.Clear(); + Items.Clear(); + currentSeriesList = null; + SeasonList.Clear(); + episodesBySeason.Clear(); + AllButtonEnabled = false; + AddAllEpisodes = false; + ButtonText = "Enter Url"; + ButtonEnabled = false; + } else if (UrlInput.Length > 9){ + episodesBySeason.Clear(); + SeasonList.Clear(); + if (UrlInput.Contains("/watch/concert/") || UrlInput.Contains("/artist/")){ + MessageBus.Current.SendMessage(new ToastMessage("Concerts / Artists not implemented yet", ToastType.Error, 1)); + } else if (UrlInput.Contains("/watch/")){ + //Episode + + var match = Regex.Match(UrlInput, "/([^/]+)/watch/([^/]+)"); + + if (match.Success){ + var locale = match.Groups[1].Value; // Capture the locale part + var id = match.Groups[2].Value; // Capture the ID part + Crunchyroll.Instance.AddEpisodeToQue(id, locale, Crunchyroll.Instance.CrunOptions.DubLang); + UrlInput = ""; + selectedEpisodes.Clear(); + SelectedItems.Clear(); + Items.Clear(); + currentSeriesList = null; + SeasonList.Clear(); + episodesBySeason.Clear(); + } + } else if (UrlInput.Contains("/series/")){ + //Series + var match = Regex.Match(UrlInput, "/([^/]+)/series/([^/]+)"); + + if (match.Success){ + var locale = match.Groups[1].Value; // Capture the locale part + var id = match.Groups[2].Value; // Capture the ID part + + if (id.Length != 9){ + return; + } + + ButtonEnabled = false; + ShowLoading = true; + var list = await Crunchyroll.Instance.CrSeries.ListSeriesId(id,"", new CrunchyMultiDownload(Crunchyroll.Instance.CrunOptions.DubLang, true)); + ShowLoading = false; + if (list != null){ + currentSeriesList = list; + foreach (var episode in currentSeriesList.Value.List){ + if (episodesBySeason.ContainsKey("S" + episode.Season)){ + episodesBySeason["S" + episode.Season].Add(new ItemModel(episode.Img, episode.Description, episode.Time, episode.Name, "S" + episode.Season, "E" + episode.EpisodeNum, episode.E, + episode.Lang)); + } else{ + episodesBySeason.Add("S" + episode.Season, new List{ + new ItemModel(episode.Img, episode.Description, episode.Time, episode.Name, "S" + episode.Season, "E" + episode.EpisodeNum, episode.E, episode.Lang) + }); + SeasonList.Add(new ComboBoxItem{ Content = "S" + episode.Season }); + } + } + + CurrentSelectedSeason = SeasonList[0]; + ButtonEnabled = false; + AllButtonEnabled = true; + ButtonText = "Select Episodes"; + } else{ + ButtonEnabled = true; + } + } + } + } else{ + Console.WriteLine("Probably not a url"); + } + } + + partial void OnCurrentSelectedSeasonChanging(ComboBoxItem? oldValue, ComboBoxItem newValue){ + foreach (var selectedItem in SelectedItems){ + if (!selectedEpisodes.Contains(selectedItem.AbsolutNum)){ + selectedEpisodes.Add(selectedItem.AbsolutNum); + } + } + + if (selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes){ + ButtonText = "Add Episodes to Queue"; + ButtonEnabled = true; + } else{ + ButtonEnabled = false; + ButtonText = "Select Episodes"; + } + } + + private void OnSelectedItemsChanged(object? sender, NotifyCollectionChangedEventArgs e){ + if (selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes){ + ButtonText = "Add Episodes to Queue"; + ButtonEnabled = true; + } else{ + ButtonEnabled = false; + ButtonText = "Select Episodes"; + } + } + + partial void OnAddAllEpisodesChanged(bool value){ + if ((selectedEpisodes.Count > 0 || SelectedItems.Count > 0 || AddAllEpisodes)){ + ButtonText = "Add Episodes to Queue"; + ButtonEnabled = true; + } else{ + ButtonEnabled = false; + ButtonText = "Select Episodes"; + } + } + + async partial void OnCurrentSelectedSeasonChanged(ComboBoxItem? value){ + if (value == null){ + return; + } + + string key = value.Content + ""; + Items.Clear(); + if (episodesBySeason.TryGetValue(key, out var season)){ + foreach (var episode in season){ + if (episode.ImageBitmap == null){ + await episode.LoadImage(); + Items.Add(episode); + if (selectedEpisodes.Contains(episode.AbsolutNum)){ + SelectedItems.Add(episode); + } + } else{ + Items.Add(episode); + if (selectedEpisodes.Contains(episode.AbsolutNum)){ + SelectedItems.Add(episode); + } + } + } + } + } +} + +public class ItemModel(string imageUrl, string description, string time, string title, string season, string episode, string absolutNum, List availableAudios){ + public string ImageUrl{ get; set; } = imageUrl; + public Bitmap? ImageBitmap{ get; set; } + public string Title{ get; set; } = title; + public string Description{ get; set; } = description; + public string Time{ get; set; } = time; + public string Season{ get; set; } = season; + public string Episode{ get; set; } = episode; + + public string AbsolutNum{ get; set; } = absolutNum; + + public string TitleFull{ get; set; } = season + episode + " - " + title; + + public List AvailableAudios{ get; set; } = availableAudios; + + public async Task LoadImage(){ + try{ + using (var client = new HttpClient()){ + var response = await client.GetAsync(ImageUrl); + response.EnsureSuccessStatusCode(); + using (var stream = await response.Content.ReadAsStreamAsync()){ + ImageBitmap = new Bitmap(stream); + } + } + } catch (Exception ex){ + // Handle exceptions + Console.WriteLine("Failed to load image: " + ex.Message); + } + } +} \ No newline at end of file diff --git a/ViewModels/CalendarPageViewModel.cs b/ViewModels/CalendarPageViewModel.cs new file mode 100644 index 0000000..58e4970 --- /dev/null +++ b/ViewModels/CalendarPageViewModel.cs @@ -0,0 +1,141 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using Avalonia.Controls; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Utils.Structs; +using DynamicData; + +namespace CRD.ViewModels; + +public partial class CalendarPageViewModel : ViewModelBase{ + public ObservableCollection CalendarDays{ get; set; } + + [ObservableProperty] private ComboBoxItem? _currentCalendarLanguage; + [ObservableProperty] private bool? _showLoading = false; + + public ObservableCollection CalendarLanguage{ get; } = new(){ + new ComboBoxItem(){ Content = "en-us" }, + new ComboBoxItem(){ Content = "es" }, + new ComboBoxItem(){ Content = "es-es" }, + new ComboBoxItem(){ Content = "pt-br" }, + new ComboBoxItem(){ Content = "pt-pt" }, + new ComboBoxItem(){ Content = "fr" }, + new ComboBoxItem(){ Content = "de" }, + new ComboBoxItem(){ Content = "ar" }, + new ComboBoxItem(){ Content = "it" }, + new ComboBoxItem(){ Content = "ru" }, + new ComboBoxItem(){ Content = "hi" }, + }; + + private CalendarWeek? currentWeek; + + public CalendarPageViewModel(){ + CalendarDays = new ObservableCollection(); + CurrentCalendarLanguage = CalendarLanguage.FirstOrDefault(a => a.Content != null && (string)a.Content == Crunchyroll.Instance.CrunOptions.SelectedCalendarLanguage) ?? CalendarLanguage[0]; + // LoadCalendar(GetThisWeeksMondayDate(), false); + } + + private string GetThisWeeksMondayDate(){ + // Get today's date + DateTime today = DateTime.Today; + + // Calculate the number of days to subtract to get to Monday + // DayOfWeek.Monday is 1, so if today is Monday, subtract 0 days, if it's Tuesday subtract 1 day, etc. + int daysToSubtract = (int)today.DayOfWeek - (int)DayOfWeek.Monday; + + // If today is Sunday (0), it will subtract -1, which we need to adjust to 6 to go back to the previous Monday + if (daysToSubtract < 0){ + daysToSubtract += 7; + } + + // Get the date of the most recent Monday + DateTime monday = today.AddDays(-daysToSubtract); + + // Format and print the date + string formattedDate = monday.ToString("yyyy-MM-dd"); + + return formattedDate; + } + + public async void LoadCalendar(string mondayDate, bool forceUpdate){ + ShowLoading = true; + CalendarWeek week = await Crunchyroll.Instance.GetCalendarForDate(mondayDate, forceUpdate); + if (currentWeek != null && currentWeek == week){ + ShowLoading = false; + return; + } + currentWeek = week; + CalendarDays.Clear(); + CalendarDays.AddRange(week.CalendarDays); + RaisePropertyChanged(nameof(CalendarDays)); + ShowLoading = false; + foreach (var calendarDay in CalendarDays){ + foreach (var calendarDayCalendarEpisode in calendarDay.CalendarEpisodes){ + if (calendarDayCalendarEpisode.ImageBitmap == null){ + calendarDayCalendarEpisode.LoadImage(); + } + } + } + } + + private string NextMonday(DateTime currentMonday){ + DateTime nextMonday = currentMonday.AddDays(7); + return nextMonday.ToString("yyyy-MM-dd"); + } + + private string PreviousMonday(DateTime currentMonday){ + DateTime nextMonday = currentMonday.AddDays(-7); + return nextMonday.ToString("yyyy-MM-dd"); + } + + + [RelayCommand] + public void Refresh(){ + string mondayDate; + + if (currentWeek is{ FirstDayOfWeekString: not null }){ + mondayDate = currentWeek.FirstDayOfWeekString; + } else{ + mondayDate = GetThisWeeksMondayDate(); + } + + LoadCalendar(mondayDate, true); + } + + [RelayCommand] + public void PrevWeek(){ + string mondayDate; + + if (currentWeek is{ FirstDayOfWeek: not null }){ + mondayDate = PreviousMonday((DateTime)currentWeek.FirstDayOfWeek); + } else{ + mondayDate = GetThisWeeksMondayDate(); + } + + LoadCalendar(mondayDate, false); + } + + [RelayCommand] + public void NextWeek(){ + string mondayDate; + + if (currentWeek is{ FirstDayOfWeek: not null }){ + mondayDate = NextMonday((DateTime)currentWeek.FirstDayOfWeek); + } else{ + mondayDate = GetThisWeeksMondayDate(); + } + + LoadCalendar(mondayDate, false); + } + + + partial void OnCurrentCalendarLanguageChanged(ComboBoxItem? value){ + if (value?.Content != null){ + Crunchyroll.Instance.CrunOptions.SelectedCalendarLanguage = value.Content.ToString(); + Refresh(); + } + } +} \ No newline at end of file diff --git a/ViewModels/ContentDialogInputLoginViewModel.cs b/ViewModels/ContentDialogInputLoginViewModel.cs new file mode 100644 index 0000000..9ccabd7 --- /dev/null +++ b/ViewModels/ContentDialogInputLoginViewModel.cs @@ -0,0 +1,41 @@ +using System; +using CommunityToolkit.Mvvm.ComponentModel; +using CRD.Downloader; +using CRD.Utils; +using CRD.Utils.Structs; +using FluentAvalonia.UI.Controls; + +namespace CRD.ViewModels; + +public partial class ContentDialogInputLoginViewModel : ViewModelBase{ + private readonly ContentDialog dialog; + + [ObservableProperty] + private string _email; + + [ObservableProperty] + private string _password; + + private AccountPageViewModel accountPageViewModel; + + public ContentDialogInputLoginViewModel(ContentDialog dialog, AccountPageViewModel accountPageViewModel){ + if (dialog is null){ + throw new ArgumentNullException(nameof(dialog)); + } + + this.dialog = dialog; + dialog.Closed += DialogOnClosed; + dialog.PrimaryButtonClick += LoginButton; + this.accountPageViewModel = accountPageViewModel; + } + + private async void LoginButton(ContentDialog sender, ContentDialogButtonClickEventArgs args){ + dialog.PrimaryButtonClick -= LoginButton; + await Crunchyroll.Instance.CrAuth.Auth(new AuthData{Password = Password,Username = Email}); + accountPageViewModel.UpdatetProfile(); + } + + private void DialogOnClosed(ContentDialog sender, ContentDialogClosedEventArgs args){ + dialog.Closed -= DialogOnClosed; + } +} \ No newline at end of file diff --git a/ViewModels/DownloadsPageViewModel.cs b/ViewModels/DownloadsPageViewModel.cs new file mode 100644 index 0000000..e19660c --- /dev/null +++ b/ViewModels/DownloadsPageViewModel.cs @@ -0,0 +1,227 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.ComponentModel; +using System.Linq; +using System.Net.Http; +using System.Threading; +using System.Threading.Tasks; +using Avalonia; +using Avalonia.Media.Imaging; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Utils; +using CRD.Utils.Structs; + +namespace CRD.ViewModels; + +public partial class DownloadsPageViewModel : ViewModelBase{ + + + public ObservableCollection Items{ get; } + + [ObservableProperty] public bool _autoDownload; + + private SemaphoreSlim semaphore = new SemaphoreSlim(1, 1); + + public DownloadsPageViewModel(){ + UpdateListItems(); + Items = Crunchyroll.Instance.DownloadItemModels; + AutoDownload = Crunchyroll.Instance.AutoDownload; + Crunchyroll.Instance.Queue.CollectionChanged += UpdateItemListOnRemove; + // Items.Add(new DownloadItemModel{Title = "Test - S1E1"}); + } + + private void UpdateItemListOnRemove(object? sender, NotifyCollectionChangedEventArgs e){ + if (e.Action == NotifyCollectionChangedAction.Remove){ + if (e.OldItems != null) + foreach (var eOldItem in e.OldItems){ + var downloadItem = Crunchyroll.Instance.DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(eOldItem)); + if (downloadItem != null){ + Crunchyroll.Instance.DownloadItemModels.Remove(downloadItem); + } else{ + Console.WriteLine("Failed to Remove From Preview"); + } + } + } + + UpdateListItems(); + } + + + public void UpdateListItems(){ + var list = Crunchyroll.Instance.Queue; + + foreach (CrunchyEpMeta crunchyEpMeta in list){ + var downloadItem = Crunchyroll.Instance.DownloadItemModels.FirstOrDefault(e => e.epMeta.Equals(crunchyEpMeta)); + if (downloadItem != null){ + downloadItem.Refresh(); + } else{ + downloadItem = new DownloadItemModel(crunchyEpMeta); + downloadItem.LoadImage(); + Crunchyroll.Instance.DownloadItemModels.Add(downloadItem); + } + + if (downloadItem is{ isDownloading: false, Error: false } && Crunchyroll.Instance.AutoDownload && Crunchyroll.Instance.ActiveDownloads < Crunchyroll.Instance.CrunOptions.SimultaneousDownloads){ + downloadItem.StartDownload(); + } + } + } + + partial void OnAutoDownloadChanged(bool value){ + Crunchyroll.Instance.AutoDownload = value; + if (value){ + UpdateListItems(); + } + } + + public void Cleanup(){ + Crunchyroll.Instance.Queue.CollectionChanged -= UpdateItemListOnRemove; + } +} + +public partial class DownloadItemModel : INotifyPropertyChanged{ + public string ImageUrl{ get; set; } + public Bitmap? ImageBitmap{ get; set; } + public string Title{ get; set; } + + public bool isDownloading{ get; set; } + public bool Done{ get; set; } + public bool Paused{ get; set; } + + public double Percent{ get; set; } + public string Time{ get; set; } + public string DoingWhat{ get; set; } + public string DownloadSpeed{ get; set; } + public string InfoText{ get; set; } + + public CrunchyEpMeta epMeta{ get; set; } + + + public bool Error{ get; set; } + + public DownloadItemModel(CrunchyEpMeta epMetaF){ + epMeta = epMetaF; + + ImageUrl = epMeta.Image; + Title = epMeta.SeriesTitle + " - S" + epMeta.Season + "E" + (epMeta.EpisodeNumber != string.Empty ? epMeta.EpisodeNumber : epMeta.AbsolutEpisodeNumberE) + " - " + epMeta.EpisodeTitle; + isDownloading = epMeta.DownloadProgress.IsDownloading || Done; + + Done = epMeta.DownloadProgress.Done; + Percent = epMeta.DownloadProgress.Percent; + Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time / 1000).ToString(@"hh\:mm\:ss"); + DownloadSpeed = $"{epMeta.DownloadProgress.DownloadSpeed / 1000000.0:F2}Mb/s"; + Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; + DoingWhat = epMeta.Paused ? "Paused" : Done ? "Done" : epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting"; + + if (epMeta.Data != null) InfoText = "Dub: " + epMeta.Data.First().Lang?.CrLocale + " - " + GetSubtitleString(); + + Error = epMeta.DownloadProgress.Error; + } + + private string GetSubtitleString(){ + var hardSubs = Crunchyroll.Instance.CrunOptions.Hslang != "none" ? "Hardsub: " + Crunchyroll.Instance.CrunOptions.Hslang : ""; + if (hardSubs != string.Empty){ + return hardSubs; + } + + var softSubs = "Softsub: "; + + foreach (var crunOptionsDlSub in Crunchyroll.Instance.CrunOptions.DlSubs){ + softSubs += crunOptionsDlSub + " "; + } + + return softSubs; + } + + public void Refresh(){ + isDownloading = epMeta.DownloadProgress.IsDownloading || Done; + Done = epMeta.DownloadProgress.Done; + Percent = epMeta.DownloadProgress.Percent; + Time = "Estimated Time: " + TimeSpan.FromSeconds(epMeta.DownloadProgress.Time / 1000).ToString(@"hh\:mm\:ss"); + DownloadSpeed = $"{epMeta.DownloadProgress.DownloadSpeed / 1000000.0:F2}Mb/s"; + + Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; + DoingWhat = epMeta.Paused ? "Paused" : Done ? "Done" : epMeta.DownloadProgress.Doing != string.Empty ? epMeta.DownloadProgress.Doing : "Waiting"; + + Error = epMeta.DownloadProgress.Error; + + if (PropertyChanged != null){ + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(isDownloading))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Percent))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Time))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DownloadSpeed))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(DoingWhat))); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Error))); + } + } + + + + + public event PropertyChangedEventHandler? PropertyChanged; + + [RelayCommand] + public void ToggleIsDownloading(){ + + if (isDownloading){ + //StopDownload(); + epMeta.Paused = !epMeta.Paused; + + Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused))); + + } else{ + if (epMeta.Paused){ + epMeta.Paused = false; + Paused = epMeta.Paused || !isDownloading && !epMeta.Paused; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused))); + } else{ + StartDownload(); + } + } + + + if (PropertyChanged != null){ + PropertyChanged.Invoke(this, new PropertyChangedEventArgs("isDownloading")); + } + + } + + public async void StartDownload(){ + if (!isDownloading){ + isDownloading = true; + epMeta.DownloadProgress.IsDownloading = true; + Paused = !epMeta.Paused && !isDownloading || epMeta.Paused; + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(Paused))); + await Crunchyroll.Instance.DownloadEpisode(epMeta, Crunchyroll.Instance.CrunOptions, false); + } + + } + + [RelayCommand] + public void RemoveFromQueue(){ + CrunchyEpMeta? downloadItem = Crunchyroll.Instance.Queue.FirstOrDefault(e => e.Equals(epMeta)) ?? null; + if (downloadItem != null){ + Crunchyroll.Instance.Queue.Remove(downloadItem); + } + } + + public async Task LoadImage(){ + try{ + using (var client = new HttpClient()){ + var response = await client.GetAsync(ImageUrl); + response.EnsureSuccessStatusCode(); + using (var stream = await response.Content.ReadAsStreamAsync()){ + ImageBitmap = new Bitmap(stream); + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(nameof(ImageBitmap))); + } + } + } catch (Exception ex){ + // Handle exceptions + Console.WriteLine("Failed to load image: " + ex.Message); + } + } +} \ No newline at end of file diff --git a/ViewModels/HistoryPageViewModel.cs b/ViewModels/HistoryPageViewModel.cs new file mode 100644 index 0000000..2900b18 --- /dev/null +++ b/ViewModels/HistoryPageViewModel.cs @@ -0,0 +1,54 @@ +using System; +using System.Collections.ObjectModel; +using System.Linq; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Views; +using ReactiveUI; + +namespace CRD.ViewModels; + +public partial class HistoryPageViewModel : ViewModelBase{ + + public ObservableCollection Items{ get; } + [ObservableProperty] private bool? _showLoading = false; + [ObservableProperty] + public HistorySeries _selectedSeries; + + public HistoryPageViewModel(){ + Items = Crunchyroll.Instance.HistoryList; + + foreach (var historySeries in Items){ + if (historySeries.ThumbnailImage == null){ + historySeries.LoadImage(); + } + historySeries.UpdateNewEpisodes(); + } + + } + + + partial void OnSelectedSeriesChanged(HistorySeries value){ + Crunchyroll.Instance.SelectedSeries = value; + MessageBus.Current.SendMessage(new NavigationMessage(typeof(SeriesPageViewModel),false,false)); + _selectedSeries = null; + } + + [RelayCommand] + public void NavToSeries(){ + MessageBus.Current.SendMessage(new NavigationMessage(typeof(SeriesPageViewModel),false,false)); + } + + [RelayCommand] + public async void RefreshAll(){ + foreach (var historySeries in Items){ + ShowLoading = true; + await historySeries.FetchData(""); + historySeries.UpdateNewEpisodes(); + } + ShowLoading = false; + } + + +} \ No newline at end of file diff --git a/ViewModels/MainWindowViewModel.cs b/ViewModels/MainWindowViewModel.cs new file mode 100644 index 0000000..d574454 --- /dev/null +++ b/ViewModels/MainWindowViewModel.cs @@ -0,0 +1,38 @@ +using Avalonia; +using Avalonia.Media; +using Avalonia.Styling; +using CRD.Downloader; +using FluentAvalonia.Styling; + +namespace CRD.ViewModels; + +public partial class MainWindowViewModel : ViewModelBase{ + private readonly FluentAvaloniaTheme _faTheme; + + public MainWindowViewModel(){ + + _faTheme = App.Current.Styles[0] as FluentAvaloniaTheme; + + Init(); + + } + + public async void Init(){ + await Crunchyroll.Instance.Init(); + + if (Crunchyroll.Instance.CrunOptions.AccentColor != null){ + _faTheme.CustomAccentColor = Color.Parse(Crunchyroll.Instance.CrunOptions.AccentColor); + } + + if (Crunchyroll.Instance.CrunOptions.Theme == "System"){ + _faTheme.PreferSystemTheme = true; + } else if (Crunchyroll.Instance.CrunOptions.Theme == "Dark"){ + _faTheme.PreferSystemTheme = false; + Application.Current.RequestedThemeVariant = ThemeVariant.Dark; + } else{ + _faTheme.PreferSystemTheme = false; + Application.Current.RequestedThemeVariant = ThemeVariant.Light; + } + } + +} \ No newline at end of file diff --git a/ViewModels/SeriesPageViewModel.cs b/ViewModels/SeriesPageViewModel.cs new file mode 100644 index 0000000..20b8c9b --- /dev/null +++ b/ViewModels/SeriesPageViewModel.cs @@ -0,0 +1,38 @@ +using System; +using System.Threading.Tasks; +using CommunityToolkit.Mvvm.ComponentModel; +using CommunityToolkit.Mvvm.Input; +using CRD.Downloader; +using CRD.Views; +using ReactiveUI; + +namespace CRD.ViewModels; + +public partial class SeriesPageViewModel : ViewModelBase{ + + + [ObservableProperty] + public HistorySeries _selectedSeries; + + public SeriesPageViewModel(){ + _selectedSeries = Crunchyroll.Instance.SelectedSeries; + + if (_selectedSeries.ThumbnailImage == null){ + _selectedSeries.LoadImage(); + } + } + + [RelayCommand] + public async Task UpdateData(string? season){ + await SelectedSeries.FetchData(season); + + MessageBus.Current.SendMessage(new NavigationMessage(typeof(SeriesPageViewModel),false,true)); + } + + [RelayCommand] + public void NavBack(){ + SelectedSeries.UpdateNewEpisodes(); + MessageBus.Current.SendMessage(new NavigationMessage(null,true,false)); + } + +} \ No newline at end of file diff --git a/ViewModels/SettingsPageViewModel.cs b/ViewModels/SettingsPageViewModel.cs new file mode 100644 index 0000000..7feada1 --- /dev/null +++ b/ViewModels/SettingsPageViewModel.cs @@ -0,0 +1,383 @@ +using System; +using System.Collections.Generic; +using System.Collections.ObjectModel; +using System.Collections.Specialized; +using System.Linq; +using System.Net.Mime; +using System.Reflection; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Media; +using Avalonia.Styling; +using CommunityToolkit.Mvvm.ComponentModel; +using CRD.Downloader; +using CRD.Utils; +using CRD.Utils.Structs; +using FluentAvalonia.Styling; + +namespace CRD.ViewModels; + +public partial class SettingsPageViewModel : ViewModelBase{ + [ObservableProperty] private string _currentVersion = "v1.1"; + + [ObservableProperty] private bool _downloadVideo = true; + + [ObservableProperty] private bool _downloadAudio = true; + + [ObservableProperty] private bool _downloadChapters = true; + + [ObservableProperty] private bool _muxToMp4 = false; + + [ObservableProperty] private bool _history = false; + + [ObservableProperty] private int _leadingNumbers = 0; + + [ObservableProperty] private int _simultaneousDownloads = 0; + + [ObservableProperty] private string _fileName = ""; + + [ObservableProperty] private string _mkvMergeOptions = ""; + + [ObservableProperty] private string _ffmpegOptions = ""; + + [ObservableProperty] private string _selectedSubs = "all"; + + [ObservableProperty] private ComboBoxItem _selectedHSLang; + + [ObservableProperty] private ComboBoxItem? _selectedDubLang; + + [ObservableProperty] private ComboBoxItem? _selectedVideoQuality; + + [ObservableProperty] private ComboBoxItem? _selectedAudioQuality; + + [ObservableProperty] private ComboBoxItem? _currentAppTheme; + + [ObservableProperty] private ObservableCollection _selectedSubLang = new(); + + [ObservableProperty] private bool _useCustomAccent = false; + + [ObservableProperty] private Color _listBoxColor ; + [ObservableProperty] private Color _customAccentColor = Colors.SlateBlue; + + public ObservableCollection PredefinedColors{ get; } = new(){ + + Color.FromRgb(255, 185, 0), + Color.FromRgb(255, 140, 0), + Color.FromRgb(247, 99, 12), + Color.FromRgb(202, 80, 16), + Color.FromRgb(218, 59, 1), + Color.FromRgb(239, 105, 80), + Color.FromRgb(209, 52, 56), + Color.FromRgb(255, 67, 67), + Color.FromRgb(231, 72, 86), + Color.FromRgb(232, 17, 35), + Color.FromRgb(234, 0, 94), + Color.FromRgb(195, 0, 82), + Color.FromRgb(227, 0, 140), + Color.FromRgb(191, 0, 119), + Color.FromRgb(194, 57, 179), + Color.FromRgb(154, 0, 137), + Color.FromRgb(0, 120, 212), + Color.FromRgb(0, 99, 177), + Color.FromRgb(142, 140, 216), + Color.FromRgb(107, 105, 214), + Colors.SlateBlue, + Color.FromRgb(135, 100, 184), + Color.FromRgb(116, 77, 169), + Color.FromRgb(177, 70, 194), + Color.FromRgb(136, 23, 152), + Color.FromRgb(0, 153, 188), + Color.FromRgb(45, 125, 154), + Color.FromRgb(0, 183, 195), + Color.FromRgb(3, 131, 135), + Color.FromRgb(0, 178, 148), + Color.FromRgb(1, 133, 116), + Color.FromRgb(0, 204, 106), + Color.FromRgb(16, 137, 62), + Color.FromRgb(122, 117, 116), + Color.FromRgb(93, 90, 88), + Color.FromRgb(104, 118, 138), + Color.FromRgb(81, 92, 107), + Color.FromRgb(86, 124, 115), + Color.FromRgb(72, 104, 96), + Color.FromRgb(73, 130, 5), + Color.FromRgb(16, 124, 16), + Color.FromRgb(118, 118, 118), + Color.FromRgb(76, 74, 72), + Color.FromRgb(105, 121, 126), + Color.FromRgb(74, 84, 89), + Color.FromRgb(100, 124, 100), + Color.FromRgb(82, 94, 84), + Color.FromRgb(132, 117, 69), + Color.FromRgb(126, 115, 95) + }; + + public ObservableCollection AppThemes{ get; } = new(){ + new ComboBoxItem(){ Content = "System" }, + new ComboBoxItem(){ Content = "Light" }, + new ComboBoxItem(){ Content = "Dark" }, + }; + + public ObservableCollection VideoQualityList{ get; } = new(){ + new ComboBoxItem(){ Content = "best" }, + new ComboBoxItem(){ Content = "1080" }, + new ComboBoxItem(){ Content = "720" }, + new ComboBoxItem(){ Content = "480" }, + new ComboBoxItem(){ Content = "360" }, + new ComboBoxItem(){ Content = "240" }, + new ComboBoxItem(){ Content = "worst" }, + }; + + public ObservableCollection AudioQualityList{ get; } = new(){ + new ComboBoxItem(){ Content = "best" }, + new ComboBoxItem(){ Content = "128kB/s" }, + new ComboBoxItem(){ Content = "96kB/s" }, + new ComboBoxItem(){ Content = "64kB/s" }, + new ComboBoxItem(){ Content = "worst" }, + }; + + public ObservableCollection HardSubLangList{ get; } = new(){ + new ComboBoxItem(){ Content = "none" }, + }; + + public ObservableCollection DubLangList{ get; } = new(){ + }; + + public ObservableCollection SubLangList{ get; } = new(){ + new ListBoxItem(){ Content = "all" }, + new ListBoxItem(){ Content = "none" }, + }; + + private readonly FluentAvaloniaTheme _faTheme; + + private bool settingsLoaded = false; + + public SettingsPageViewModel(){ + + var version = Assembly.GetExecutingAssembly().GetName().Version; + _currentVersion = $"{version?.Major}.{version?.Minor}.{version?.Build}"; + + + _faTheme = App.Current.Styles[0] as FluentAvaloniaTheme; + + foreach (var languageItem in Languages.languages){ + HardSubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale }); + SubLangList.Add(new ListBoxItem{ Content = languageItem.CrLocale }); + DubLangList.Add(new ComboBoxItem{ Content = languageItem.CrLocale }); + } + + CrDownloadOptions options = Crunchyroll.Instance.CrunOptions; + + var softSubLang = SubLangList.Where(a => options.DlSubs.Contains(a.Content)).ToList(); + + SelectedSubLang.Clear(); + foreach (var listBoxItem in softSubLang){ + SelectedSubLang.Add(listBoxItem); + } + + if (SelectedSubLang.Count == 0){ + SelectedSubs = "none"; + } else{ + SelectedSubs = SelectedSubLang[0].Content.ToString(); + for (var i = 1; i < SelectedSubLang.Count; i++){ + SelectedSubs += "," + SelectedSubLang[i].Content; + } + } + + ComboBoxItem? hsLang = HardSubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Hslang) ?? null; + SelectedHSLang = hsLang ?? HardSubLangList[0]; + + ComboBoxItem? dubLang = DubLangList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.DubLang[0]) ?? null; + SelectedDubLang = dubLang ?? DubLangList[0]; + + DownloadVideo = !options.Novids; + DownloadAudio = !options.Noaudio; + DownloadChapters = options.Chapters; + MuxToMp4 = options.Mp4; + LeadingNumbers = options.Numbers; + FileName = options.FileName; + SimultaneousDownloads = options.SimultaneousDownloads; + + ComboBoxItem? qualityAudio = AudioQualityList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.QualityAudio) ?? null; + SelectedAudioQuality = qualityAudio ?? AudioQualityList[0]; + + ComboBoxItem? qualityVideo = VideoQualityList.FirstOrDefault(a => a.Content != null && (string)a.Content == options.QualityVideo) ?? null; + SelectedVideoQuality = qualityVideo ?? VideoQualityList[0]; + + ComboBoxItem? theme = AppThemes.FirstOrDefault(a => a.Content != null && (string)a.Content == options.Theme) ?? null; + CurrentAppTheme = theme ?? AppThemes[0]; + + if (options.AccentColor != CustomAccentColor.ToString()){ + UseCustomAccent = true; + } + + History = options.History; + + //TODO - Mux Options + + SelectedSubLang.CollectionChanged += Changes; + + settingsLoaded = true; + } + + private void UpdateSettings(){ + + if (!settingsLoaded){ + return; + } + + if (SelectedSubLang.Count == 0){ + SelectedSubs = "none"; + } else{ + SelectedSubs = SelectedSubLang[0].Content.ToString(); + for (var i = 1; i < SelectedSubLang.Count; i++){ + SelectedSubs += "," + SelectedSubLang[i].Content; + } + } + + Crunchyroll.Instance.CrunOptions.Novids = !DownloadVideo; + Crunchyroll.Instance.CrunOptions.Noaudio = !DownloadAudio; + Crunchyroll.Instance.CrunOptions.Chapters = DownloadChapters; + Crunchyroll.Instance.CrunOptions.Mp4 = MuxToMp4; + Crunchyroll.Instance.CrunOptions.Numbers = LeadingNumbers; + Crunchyroll.Instance.CrunOptions.FileName = FileName; + + + List softSubs = new List(); + foreach (var listBoxItem in SelectedSubLang){ + softSubs.Add(listBoxItem.Content + ""); + } + + Crunchyroll.Instance.CrunOptions.DlSubs = softSubs; + + string hslang = SelectedHSLang.Content + ""; + + Crunchyroll.Instance.CrunOptions.Hslang = hslang != "none" ? Languages.FindLang(hslang).Locale : hslang; + + if (SelectedDubLang != null){ + string dublang = SelectedDubLang.Content + ""; + + Crunchyroll.Instance.CrunOptions.DubLang = new List{ dublang }; + } + + Crunchyroll.Instance.CrunOptions.SimultaneousDownloads = SimultaneousDownloads; + + + Crunchyroll.Instance.CrunOptions.QualityAudio = SelectedAudioQuality?.Content + ""; + Crunchyroll.Instance.CrunOptions.QualityVideo = SelectedVideoQuality?.Content + ""; + Crunchyroll.Instance.CrunOptions.Theme = CurrentAppTheme?.Content + ""; + + Crunchyroll.Instance.CrunOptions.AccentColor = _faTheme.CustomAccentColor.ToString(); + + Crunchyroll.Instance.CrunOptions.History = History; + + //TODO - Mux Options + + CfgManager.WriteSettingsToFile(); + + // Console.WriteLine("Updated Settings"); + } + + partial void OnCurrentAppThemeChanged(ComboBoxItem? value){ + if (value?.Content?.ToString() == "System"){ + _faTheme.PreferSystemTheme = true; + } else if (value?.Content?.ToString() == "Dark"){ + _faTheme.PreferSystemTheme = false; + Application.Current.RequestedThemeVariant = ThemeVariant.Dark; + } else{ + _faTheme.PreferSystemTheme = false; + Application.Current.RequestedThemeVariant = ThemeVariant.Light; + } + + UpdateSettings(); + } + + partial void OnUseCustomAccentChanged(bool value){ + if (value){ + if (_faTheme.TryGetResource("SystemAccentColor", null, out var curColor)){ + CustomAccentColor = (Color)curColor; + ListBoxColor = CustomAccentColor; + + RaisePropertyChanged(nameof(CustomAccentColor)); + RaisePropertyChanged(nameof(ListBoxColor)); + } + } else{ + CustomAccentColor = default; + ListBoxColor = default; + UpdateAppAccentColor(Colors.SlateBlue); + } + } + + partial void OnListBoxColorChanged(Color value){ + if (value != null){ + CustomAccentColor = value; + RaisePropertyChanged(nameof(CustomAccentColor)); + + UpdateAppAccentColor(value); + } + } + + partial void OnCustomAccentColorChanged(Color value){ + ListBoxColor = value; + RaisePropertyChanged(nameof(ListBoxColor)); + UpdateAppAccentColor(value); + } + + private void UpdateAppAccentColor(Color? color){ + _faTheme.CustomAccentColor = color; + UpdateSettings(); + } + + + partial void OnSelectedDubLangChanged(ComboBoxItem? value){ + UpdateSettings(); + } + + private void Changes(object? sender, NotifyCollectionChangedEventArgs e){ + UpdateSettings(); + } + + partial void OnDownloadAudioChanged(bool value){ + UpdateSettings(); + } + + partial void OnDownloadChaptersChanged(bool value){ + UpdateSettings(); + } + + partial void OnDownloadVideoChanged(bool value){ + UpdateSettings(); + } + + partial void OnFileNameChanged(string value){ + UpdateSettings(); + } + + partial void OnLeadingNumbersChanged(int value){ + UpdateSettings(); + } + + partial void OnMuxToMp4Changed(bool value){ + UpdateSettings(); + } + + partial void OnSelectedHSLangChanged(ComboBoxItem value){ + UpdateSettings(); + } + + partial void OnSimultaneousDownloadsChanged(int value){ + UpdateSettings(); + } + + partial void OnSelectedAudioQualityChanged(ComboBoxItem? value){ + UpdateSettings(); + } + + partial void OnSelectedVideoQualityChanged(ComboBoxItem? value){ + UpdateSettings(); + } + + partial void OnHistoryChanged(bool value){ + UpdateSettings(); + } +} \ No newline at end of file diff --git a/ViewModels/ViewModelBase.cs b/ViewModels/ViewModelBase.cs new file mode 100644 index 0000000..0d62935 --- /dev/null +++ b/ViewModels/ViewModelBase.cs @@ -0,0 +1,12 @@ +using System.ComponentModel; +using CommunityToolkit.Mvvm.ComponentModel; + +namespace CRD.ViewModels; + +public class ViewModelBase : ObservableObject{ + public event PropertyChangedEventHandler PropertyChanged; + + protected void RaisePropertyChanged(string propName){ + PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propName)); + } +} \ No newline at end of file diff --git a/Views/AccountPageView.axaml b/Views/AccountPageView.axaml new file mode 100644 index 0000000..26e1ead --- /dev/null +++ b/Views/AccountPageView.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/AddDownloadPageView.axaml.cs b/Views/AddDownloadPageView.axaml.cs new file mode 100644 index 0000000..7dfd72a --- /dev/null +++ b/Views/AddDownloadPageView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace CRD.Views; + +public partial class AddDownloadPageView : UserControl{ + public AddDownloadPageView(){ + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Views/CalendarPageView.axaml b/Views/CalendarPageView.axaml new file mode 100644 index 0000000..8ccd3e1 --- /dev/null +++ b/Views/CalendarPageView.axaml @@ -0,0 +1,143 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/DownloadsPageView.axaml.cs b/Views/DownloadsPageView.axaml.cs new file mode 100644 index 0000000..54c9d6f --- /dev/null +++ b/Views/DownloadsPageView.axaml.cs @@ -0,0 +1,24 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Interactivity; +using CRD.Downloader; +using CRD.ViewModels; + +namespace CRD.Views; + +public partial class DownloadsPageView : UserControl{ + public DownloadsPageView(){ + InitializeComponent(); + } + + protected override void OnDetachedFromVisualTree(VisualTreeAttachmentEventArgs e){ + base.OnDetachedFromVisualTree(e); + if (DataContext is DownloadsPageViewModel vm){ + vm.Cleanup(); + } + } + + private void Button_OnClick(object? sender, RoutedEventArgs e){ + // Crunchy.Instance.TestMethode(); + } +} \ No newline at end of file diff --git a/Views/HistoryPageView.axaml b/Views/HistoryPageView.axaml new file mode 100644 index 0000000..b402ef7 --- /dev/null +++ b/Views/HistoryPageView.axaml @@ -0,0 +1,63 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/HistoryPageView.axaml.cs b/Views/HistoryPageView.axaml.cs new file mode 100644 index 0000000..98a6f5f --- /dev/null +++ b/Views/HistoryPageView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace CRD.Views; + +public partial class HistoryPageView : UserControl{ + public HistoryPageView(){ + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Views/MainWindow.axaml b/Views/MainWindow.axaml new file mode 100644 index 0000000..75ee72e --- /dev/null +++ b/Views/MainWindow.axaml @@ -0,0 +1,83 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/MainWindow.axaml.cs b/Views/MainWindow.axaml.cs new file mode 100644 index 0000000..68806c9 --- /dev/null +++ b/Views/MainWindow.axaml.cs @@ -0,0 +1,117 @@ +using System; +using System.Collections; +using System.Collections.Generic; +using System.Reactive.Disposables; +using Avalonia.Controls; +using CRD.Downloader; +using CRD.ViewModels; +using CRD.Views.Utils; +using FluentAvalonia.Core; +using FluentAvalonia.UI.Controls; +using FluentAvalonia.UI.Navigation; +using FluentAvalonia.UI.Windowing; +using ReactiveUI; + +namespace CRD.Views; + +public partial class MainWindow : AppWindow{ + private Stack navigationStack = new Stack(); + + public MainWindow(){ + InitializeComponent(); + + TitleBar.ExtendsContentIntoTitleBar = true; + TitleBar.TitleBarHitTestType = TitleBarHitTestType.Complex; + + + //select first element as default + var nv = this.FindControl("NavView"); + nv.SelectedItem = nv.MenuItems.ElementAt(0); + + MessageBus.Current.Listen() + .Subscribe(message => { + if (message.Refresh){ + navigationStack.Pop(); + var viewModel = Activator.CreateInstance(message.ViewModelType); + navigationStack.Push(viewModel); + nv.Content = viewModel; + } else if (!message.Back && message.ViewModelType != null){ + var viewModel = Activator.CreateInstance(message.ViewModelType); + navigationStack.Push(viewModel); + nv.Content = viewModel; + } else{ + navigationStack.Pop(); + var viewModel = navigationStack.Peek(); + nv.Content = viewModel; + } + }); + + MessageBus.Current.Listen() + .Subscribe(message => ShowToast(message.Message, message.Type, message.Seconds)); + + + + } + + public static void ShowError(string message){ + var window = new ErrorWindow(); + window.SetErrorMessage(message); + window.Show(); // 'this' is a reference to the parent window, if applicable + } + + public void ShowToast(string message, ToastType type, int durationInSeconds = 5){ + this.FindControl("Toast").Show(message, type, durationInSeconds); + } + + + private void NavView_SelectionChanged(object? sender, NavigationViewSelectionChangedEventArgs e){ + if (sender is NavigationView navView){ + var selectedItem = navView.SelectedItem as NavigationViewItem; + if (selectedItem != null){ + switch (selectedItem.Tag){ + case "DownloadQueue": + (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel)); + break; + case "AddDownload": + (sender as NavigationView).Content = Activator.CreateInstance(typeof(AddDownloadPageViewModel)); + break; + case "Calendar": + (sender as NavigationView).Content = Activator.CreateInstance(typeof(CalendarPageViewModel)); + break; + case "History": + (sender as NavigationView).Content = Activator.CreateInstance(typeof(HistoryPageViewModel)); + navigationStack.Clear(); + navigationStack.Push((sender as NavigationView).Content); + break; + case "Account": + (sender as NavigationView).Content = Activator.CreateInstance(typeof(AccountPageViewModel)); + break; + case "Settings": + (sender as NavigationView).Content = Activator.CreateInstance(typeof(SettingsPageViewModel)); + break; + default: + (sender as NavigationView).Content = Activator.CreateInstance(typeof(DownloadsPageViewModel)); + break; + } + } + } + } +} + +public class ToastMessage(string message, ToastType type, int i){ + public string? Message{ get; set; } = message; + public int Seconds{ get; set; } = i; + public ToastType Type{ get; set; } = type; +} + +public class NavigationMessage{ + public Type? ViewModelType{ get; } + public bool Back{ get; } + public bool Refresh{ get; } + + public NavigationMessage(Type? viewModelType, bool back, bool refresh){ + ViewModelType = viewModelType; + Back = back; + Refresh = refresh; + } +} \ No newline at end of file diff --git a/Views/SeriesPageView.axaml b/Views/SeriesPageView.axaml new file mode 100644 index 0000000..0a1fc7b --- /dev/null +++ b/Views/SeriesPageView.axaml @@ -0,0 +1,139 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/SeriesPageView.axaml.cs b/Views/SeriesPageView.axaml.cs new file mode 100644 index 0000000..8863ad2 --- /dev/null +++ b/Views/SeriesPageView.axaml.cs @@ -0,0 +1,11 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; + +namespace CRD.Views; + +public partial class SeriesPageView : UserControl{ + public SeriesPageView(){ + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Views/SettingsPageView.axaml b/Views/SettingsPageView.axaml new file mode 100644 index 0000000..9f7c20c --- /dev/null +++ b/Views/SettingsPageView.axaml @@ -0,0 +1,399 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/SettingsPageView.axaml.cs b/Views/SettingsPageView.axaml.cs new file mode 100644 index 0000000..63bcd0c --- /dev/null +++ b/Views/SettingsPageView.axaml.cs @@ -0,0 +1,12 @@ +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using CRD.ViewModels; + +namespace CRD.Views; + +public partial class SettingsPageView : UserControl{ + public SettingsPageView(){ + InitializeComponent(); + } +} \ No newline at end of file diff --git a/Views/ToastNotification.axaml b/Views/ToastNotification.axaml new file mode 100644 index 0000000..8a0d48e --- /dev/null +++ b/Views/ToastNotification.axaml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + \ No newline at end of file diff --git a/Views/ToastNotification.axaml.cs b/Views/ToastNotification.axaml.cs new file mode 100644 index 0000000..0745fda --- /dev/null +++ b/Views/ToastNotification.axaml.cs @@ -0,0 +1,51 @@ +using System; +using Avalonia; +using Avalonia.Controls; +using Avalonia.Markup.Xaml; +using Avalonia.Threading; + +namespace CRD.Views; + +public partial class ToastNotification : UserControl{ + public ToastNotification(){ + InitializeComponent(); + } + + private void InitializeComponent(){ + AvaloniaXamlLoader.Load(this); + } + + public void Show(string message, ToastType type, int durationInSeconds){ + this.FindControl("MessageText").Text = message; + SetStyle(type); + DispatcherTimer timer = new DispatcherTimer{ Interval = TimeSpan.FromSeconds(durationInSeconds) }; + timer.Tick += (sender, args) => { + timer.Stop(); + this.IsVisible = false; + }; + timer.Start(); + this.IsVisible = true; + } + + private void SetStyle(ToastType type){ + var border = this.FindControl("MessageBorder"); + border.Classes.Clear(); // Clear previous styles + switch (type){ + case ToastType.Information: + border.Classes.Add("info"); + break; + case ToastType.Error: + border.Classes.Add("error"); + break; + case ToastType.Warning: + border.Classes.Add("warning"); + break; + } + } +} + +public enum ToastType{ + Information, + Error, + Warning +} \ No newline at end of file diff --git a/Views/Utils/ErrorWindow.axaml b/Views/Utils/ErrorWindow.axaml new file mode 100644 index 0000000..c1a8e96 --- /dev/null +++ b/Views/Utils/ErrorWindow.axaml @@ -0,0 +1,22 @@ + + + + + + + + + + +