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 0000000..66ef3af Binary files /dev/null and b/Assets/app_icon.ico differ 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 @@ + + + + + + + + + + +