using AudibleApi; using System; using System.Collections.Generic; using System.Linq; using System.Threading.Tasks; using System.Net.Http; using AudibleApi.Cryptography; using Newtonsoft.Json.Linq; using Dinah.Core.Net.Http; using System.Text.Json.Nodes; #nullable enable namespace AudibleUtilities.Widevine; public partial class Cdm { /// /// Get a from or from the API. /// /// A if successful, otherwise public static async Task GetCdmAsync() { using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); //Check if there are any Android accounts. If not, we can't use Widevine. if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType)) return null; if (!string.IsNullOrEmpty(persister.Target.Cdm)) { try { var cdm = Convert.FromBase64String(persister.Target.Cdm); return new Cdm(new Device(cdm)); } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings."); persister.Target.Cdm = string.Empty; //Clear the stored Cdm and try getting a fresh one from the server. } } if (string.IsNullOrEmpty(persister.Target.Cdm)) { using var client = new HttpClient(); if (await GetCdmUris(client) is not Uri[] uris) return null; //try to get a CDM file for any account that's registered as an android device. //CDMs are not account-specific, so it doesn't matter which account we're successful with. foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType)) { try { var requestMessage = CreateApiRequest(account); await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } }); //Try all CDM URIs until a CDM has been retrieved successfully foreach (var uri in uris) { try { var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content); if (!resp.IsSuccessStatusCode) { var message = await resp.Content.ReadAsStringAsync(); throw new ApiErrorException(uri, null, message); } var cdmBts = await resp.Content.ReadAsByteArrayAsync(); var device = new Device(cdmBts); persister.Target.Cdm = Convert.ToBase64String(cdmBts); return new Cdm(device); } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri); //try the next URI } } } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry); //try the next Account } } } return null; } /// /// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file. /// /// If successful, an array of URIs to try. Otherwise null private static async Task GetCdmUris(HttpClient httpClient) { const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json"; try { var fileContents = await httpClient.GetStringAsync(CdmUrlListFile); var releaseIndex = JObject.Parse(fileContents); var urlArray = releaseIndex["CdmUrls"] as JArray; if (urlArray is null) throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents); var uris = urlArray.Select(u => u.Value()).OfType().Select(u => new Uri(u)).ToArray(); if (uris.Length == 0) throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents); return uris; } catch (Exception ex) { Serilog.Log.Logger.Error(ex, "Error getting CDM URLs"); return null; } } static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"]; //Ensure that the request can be made successfully before sending it to the API //The API uses System.Text.Json, so perform test with same. private static async Task TestApiRequest(HttpClient client, JsonObject input) { if (input["body"]?.GetValue() is not string body || JsonNode.Parse(body) is not JsonNode bodyJson) throw new Exception("Api request doesn't contain a body"); if (bodyJson?["Url"]?.GetValue() is not string url || !Uri.TryCreate(url, UriKind.Absolute, out var uri)) throw new Exception("Api request doesn't contain a url"); if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower())) throw new Exception($"Unknown Audible Api domain: {uri.Host}"); if (bodyJson?["Headers"] is not JsonObject headers) throw new Exception($"Api request doesn't contain any headers"); using var request = new HttpRequestMessage(HttpMethod.Get, uri); Dictionary? headersDict = null; try { headersDict = System.Text.Json.JsonSerializer.Deserialize>(headers); } catch (Exception ex) { throw new Exception("Failed to read Audible Api headers.", ex); } if (headersDict is null) throw new Exception("Failed to read Audible Api headers."); foreach (var kvp in headersDict) request.Headers.Add(kvp.Key, kvp.Value); using var resp = await client.SendAsync(request); resp.EnsureSuccessStatusCode(); } /// /// Create a request body to send to the API /// /// An authenticated account private static JObject CreateApiRequest(Account account) { const string ACCOUNT_INFO_PATH = "/1.0/account/information"; var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH); message.SignRequest( DateTime.UtcNow, account.IdentityTokens.AdpToken, account.IdentityTokens.PrivateKey); return new JObject { { "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) }, { "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) } }; } }