diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..f135689 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +# Build output +[Bb]in/ +[Oo]bj/ + +# VS Code workspace settings: keep shareable debug/task configs +.vscode/* +!.vscode/launch.json +!.vscode/tasks.json +*.cache diff --git a/.vscode/launch.json b/.vscode/launch.json new file mode 100644 index 0000000..83abd10 --- /dev/null +++ b/.vscode/launch.json @@ -0,0 +1,27 @@ +{ + "version": "0.2.0", + "configurations": [ + { + "name": "BookLibConnect (Development)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-booklibconnect-debug", + "program": "${workspaceFolder}/src/Connect.app.gui.core/bin/Debug/net6.0-windows/BookLibConnect.exe", + "cwd": "${workspaceFolder}/src/Connect.app.gui.core", + "console": "internalConsole", + "stopAtEntry": false, + "justMyCode": true + }, + { + "name": "BookLibConnect (Production)", + "type": "coreclr", + "request": "launch", + "preLaunchTask": "build-booklibconnect-release", + "program": "${workspaceFolder}/src/Connect.app.gui.core/bin/Release/net6.0-windows/BookLibConnect.exe", + "cwd": "${workspaceFolder}/src/Connect.app.gui.core", + "console": "internalConsole", + "stopAtEntry": false, + "justMyCode": false + } + ] +} diff --git a/.vscode/tasks.json b/.vscode/tasks.json new file mode 100644 index 0000000..cbaf1e6 --- /dev/null +++ b/.vscode/tasks.json @@ -0,0 +1,37 @@ +{ + "version": "2.0.0", + "tasks": [ + { + "label": "build-booklibconnect-debug", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/src/Connect.app.gui.core/Connect.app.gui.core.csproj", + "-c", + "Debug", + "-p:Platform=AnyCPU" + ], + "group": "build", + "problemMatcher": [ + "$msCompile" + ] + }, + { + "label": "build-booklibconnect-release", + "type": "shell", + "command": "dotnet", + "args": [ + "build", + "${workspaceFolder}/src/Connect.app.gui.core/Connect.app.gui.core.csproj", + "-c", + "Release", + "-p:Platform=AnyCPU" + ], + "group": "build", + "problemMatcher": [ + "$msCompile" + ] + } + ] +} diff --git a/src/Audible.json.core/LicenseResponse.cs b/src/Audible.json.core/LicenseResponse.cs index 4889a4b..31ecdf5 100644 --- a/src/Audible.json.core/LicenseResponse.cs +++ b/src/Audible.json.core/LicenseResponse.cs @@ -14,6 +14,7 @@ public partial class ContentLicense { public string asin { get; set; } public ContentMetadata content_metadata { get; set; } public string drm_type { get; set; } + public LicenseDenialReason[] license_denial_reasons { get; set; } public string license_id { get; set; } public string license_response { get; set; } public string message { get; set; } @@ -25,6 +26,13 @@ public partial class ContentLicense { public Voucher voucher { get; set; } } + public class LicenseDenialReason { + public string message { get; set; } + public string rejectionReason { get; set; } + public string rejectionReasonCode { get; set; } + public string validationType { get; set; } + } + public class ContentMetadata { public ChapterInfo chapter_info { get; set; } public ContentReference content_reference { get; set; } diff --git a/src/BooksDatabase.core/Extensions.cs b/src/BooksDatabase.core/Extensions.cs index 2a99101..0c2e588 100644 --- a/src/BooksDatabase.core/Extensions.cs +++ b/src/BooksDatabase.core/Extensions.cs @@ -3,7 +3,7 @@ namespace core.audiamus.booksdb.ex { public static class EntityExtensions { public static EConversionState ApplicableState (this Book book, bool multipart) { - if (multipart && book.Components.Count > 0) { + if (useComponentDownloads (book, multipart)) { var state = book.Components .Select (c => c.Conversion.ApplicableState()) .Distinct () @@ -23,7 +23,7 @@ public static EConversionState ApplicableState (this Conversion conv) { } public static EDownloadQuality ApplicableDownloadQuality (this Book book, bool multipart) { - if (multipart && book.Components.Count > 0) { + if (useComponentDownloads (book, multipart)) { var dnldqual = book.Components .Select (c => c.ApplicableDownloadQuality()) .Distinct () @@ -44,5 +44,15 @@ public static Book GetBook (this IBookCommon common) { _ => null, }; } + + private static bool useComponentDownloads (Book book, bool multipart) { + if (book is null || book.Components.Count == 0) + return false; + + if (multipart) + return true; + + return book.DeliveryType == EDeliveryType.Periodical; + } } } diff --git a/src/Connect.lib.core/AaxExporter.cs b/src/Connect.lib.core/AaxExporter.cs index cfc1032..d6a372f 100644 --- a/src/Connect.lib.core/AaxExporter.cs +++ b/src/Connect.lib.core/AaxExporter.cs @@ -21,7 +21,6 @@ public class AaxExporter { private IExportSettings ExportSettings { get; } private IMultiPartSettings MultipartSettings { get; } - private List> AccuChapters { get; } = new List> (); public IBookLibrary BookLibrary { private get; set; } @@ -31,18 +30,22 @@ public AaxExporter (IExportSettings exportSettings, IMultiPartSettings multipart } public void Export (Book book, SimpleConversionContext context, Action onNewStateCallback) { - AccuChapters.Clear (); using var _ = new LogGuard (3, this, () => book.ToString ()); - if (book.Components.Count == 0 || !MultipartSettings.MultiPartDownload) - exportSinglePart (book, context, onNewStateCallback); + var accuChapters = new List> (); + bool useComponentExport = book.Components.Count > 0 + && (MultipartSettings.MultiPartDownload || book.DeliveryType == EDeliveryType.Periodical); + + if (!useComponentExport) + exportSinglePart (book, context, onNewStateCallback, accuChapters); else - exportMultiPart (book, context, onNewStateCallback); + exportMultiPart (book, context, onNewStateCallback, accuChapters); } private void exportSinglePart ( IBookCommon book, SimpleConversionContext context, Action onNewStateCallback, + List> accuChapters, bool skipSeries = false ) { Log (3, this, () => book.ToString ()); @@ -56,7 +59,7 @@ private void exportSinglePart ( onNewStateCallback?.Invoke (book.Conversion); return; } - exportChapters (book); + exportChapters (book, accuChapters); exportProduct (book); @@ -71,13 +74,14 @@ private void exportSinglePart ( private void exportMultiPart ( Book book, SimpleConversionContext context, - Action onNewStateCallback + Action onNewStateCallback, + List> accuChapters ) { Log (3, this, () => book.ToString ()); bool skipSeries = false; foreach (var comp in book.Components) { - exportSinglePart (comp, context, onNewStateCallback, skipSeries); + exportSinglePart (comp, context, onNewStateCallback, accuChapters, skipSeries); skipSeries = true; } } @@ -107,7 +111,7 @@ private bool copyFile (IBookCommon book, SimpleConversionContext context) { } // internal instead of private for testing only - internal string exportChapters (IBookCommon book) { + internal string exportChapters (IBookCommon book, List> accuChapters) { if (book.ChapterInfo is null) BookLibrary?.GetChapters (book); @@ -140,14 +144,14 @@ internal string exportChapters (IBookCommon book) { ci.runtime_length_ms = chapterInfo.RuntimeLengthMs; ci.runtime_length_sec = chapterInfo.RuntimeLengthMs / 1000; - var accuChapters = new List> (); - var flattenedChapters = BookLibrary?.GetChaptersFlattened (book, accuChapters); + var partAccuChapters = new List> (); + var flattenedChapters = BookLibrary?.GetChaptersFlattened (book, partAccuChapters); if (!flattenedChapters.IsNullOrEmpty()) { var chapters = new List (); foreach (var chapter in flattenedChapters) { - if (chapters.Count == 0 && skipChapter (chapter)) + if (chapters.Count == 0 && skipChapter (chapter, accuChapters)) continue; var ch = new adb.json.Chapter { @@ -169,17 +173,17 @@ internal string exportChapters (IBookCommon book) { File.WriteAllText (outpath, json); - updateAccuChapters (accuChapters); + updateAccuChapters (accuChapters, partAccuChapters); return outpath; } - private bool skipChapter (Chapter ch) { - if (AccuChapters.Count < 2) + private bool skipChapter (Chapter ch, List> accuChapters) { + if (accuChapters.Count < 2) return false; - for (int i = 0; i < AccuChapters.Count - 1; i++) { - var chextr = AccuChapters[i].FirstOrDefault (ce => + for (int i = 0; i < accuChapters.Count - 1; i++) { + var chextr = accuChapters[i].FirstOrDefault (ce => string.Equals (ce.Title, ch.Title) && Math.Abs (ce.Length - ch.LengthMs) < 1500 && ch.LengthMs < 25000); if (chextr is not null) @@ -189,11 +193,16 @@ private bool skipChapter (Chapter ch) { return false; } - private void updateAccuChapters (List> accuPart) { + private void updateAccuChapters (List> accuChapters, List> accuPart) { + if (accuPart.IsNullOrEmpty ()) + return; + for (int i = 0; i < accuPart.Count; i++) { - if (AccuChapters.Count < i + 1) - AccuChapters.Add (new List ()); - AccuChapters[i].AddRange (accuPart[i]); + if (accuChapters.Count < i + 1) + accuChapters.Add (new List ()); + + if (!accuPart[i].IsNullOrEmpty ()) + accuChapters[i].AddRange (accuPart[i]); } } @@ -301,6 +310,15 @@ private adb.json.Product makeProduct (IBookCommon prod) { series.Add (s); } product.series = series.ToArray (); + } else if (prod is Component comp && !book.Title.IsNullOrWhiteSpace ()) { + // Some podcast episodes arrive without explicit series links. Keep season grouping stable. + product.series = new[] { + new adb.json.Series { + asin = book.Asin, + title = book.Title, + sequence = comp.PartNumber > 0 ? comp.PartNumber.ToString () : null + } + }; } return product; diff --git a/src/Connect.lib.core/AudibleApi.cs b/src/Connect.lib.core/AudibleApi.cs index 6a6a2bc..08241fd 100644 --- a/src/Connect.lib.core/AudibleApi.cs +++ b/src/Connect.lib.core/AudibleApi.cs @@ -23,6 +23,13 @@ namespace core.audiamus.connect { class AudibleApi : IAudibleApi { const string USER_AGENT = "Audible/671 CFNetwork/1240.0.4 Darwin/20.6.0"; + const int LICENSE_MAX_THROTTLE_RETRIES = 4; + static readonly TimeSpan __minLicenseRequestSpacing = TimeSpan.FromSeconds (3.0); + static readonly TimeSpan __mediumLicenseRequestSpacing = TimeSpan.FromSeconds (3.75); + static readonly TimeSpan __highLicenseRequestSpacing = TimeSpan.FromSeconds (5.0); + static readonly SemaphoreSlim __licenseThrottleGate = new (1, 1); + static DateTime __nextLicenseRequestUtc = DateTime.MinValue; + static int __licenseRequestCount; //const string HTTP_AUTHORITY_AUDIBLE = @"https://api.audible."; const string CONTENT_PATH = "/1.0/content"; @@ -108,22 +115,64 @@ public void Dispose () { using var _ = new LogGuard (3, this, () => $"resync={resync}"); const int PAGE_SIZE = 100; - int page = 0; + const string GROUPS + = "response_groups=badge_types,category_ladders,claim_code_url,contributors,is_downloaded,is_returnable,media," + + "origin_asin,pdf_url,percent_complete,price,product_attrs,product_desc,product_extended_attrs,product_plan_details," + + "product_plans,provided_review,rating,relationships,review_attrs,reviews,sample,series,sku"; var libProducts = new List (); if (json is null) { + DateTime dt = await BookLibrary.SinceLatestPurchaseDateAsync (new ProfileId (AccountId, Region), resync); - const string GROUPS - = "response_groups=badge_types,category_ladders,claim_code_url,contributors,is_downloaded,is_returnable,media," - + "origin_asin,pdf_url,percent_complete,price,product_attrs,product_desc,product_extended_attrs,product_plan_details," - + "product_plans,provided_review,rating,relationships,review_attrs,reviews,sample,series,sku"; + string purchasedAfterParam = $"purchased_after={dt.ToXmlTime ()}"; + var deltaProducts = await getLibraryProductsAsync (purchasedAfterParam, "library-delta"); + if (deltaProducts is null) + return null; + libProducts.AddRange (deltaProducts); + + // Podcast back catalogs often include older seasons/episodes with historical + // purchase dates that can be earlier than the latest audiobook watermark. + // Only perform a full podcast scan on resync to avoid extra API calls on + // every incremental sync. + if (resync) { + var podcastProducts = await getLibraryProductsAsync ("content_type=Podcast", "podcast-full"); + if (podcastProducts is null) + return null; + libProducts.AddRange (podcastProducts); + } + } else { + adb.json.LibraryResponse libraryResponse = adb.json.LibraryResponse.Deserialize (json); + libProducts.AddRange (libraryResponse.items); + } - DateTime dt = await BookLibrary.SinceLatestPurchaseDateAsync (new ProfileId (AccountId, Region), resync); + libProducts = libProducts.DistinctBy (p => p.asin).ToList (); + + libProducts.Sort ((x, y) => DateTime.Compare (x.purchase_date, y.purchase_date)); + +#if TEST_INVAL_CHAR + libProducts = libProducts + .Select (p => { + p.title = p.title.Replace (ORIG, SUBS); + return p; + }) + .ToList (); +#endif + + await BookLibrary.AddRemBooksAsync (libProducts, new ProfileId (AccountId, Region), resync); + + var allPagesResponse = new adb.json.LibraryResponse (); + allPagesResponse.items = libProducts.ToArray (); + return allPagesResponse; + + + async Task> getLibraryProductsAsync (string query, string label) { + int page = 0; + var products = new List (); while (true) { page++; string url = "/1.0/library" - + $"?purchased_after={dt.ToXmlTime ()}" + + $"?{query}" + $"&num_results={PAGE_SIZE}" + $"&page={page}" + "&" @@ -135,7 +184,7 @@ const string GROUPS if (Logging.Level >= 3) { string file = pageResult.WriteTempJsonFile ("LibraryResponse"); - Log (3, this, () => $"page={page}, file=\"{Path.GetFileName (file)}\""); + Log (3, this, () => $"{label}; page={page}, file=\"{Path.GetFileName (file)}\""); } adb.json.LibraryResponse libraryResponse = adb.json.LibraryResponse.Deserialize (pageResult); @@ -149,34 +198,12 @@ const string GROUPS #if TEST_UNAVAIL pageProducts = pageProducts.ToList().Take(pageProducts.Length - 1).ToArray(); #endif - Log (3, this, () => $"#items/page={pageProducts.Length}"); - libProducts.AddRange (pageProducts); - - + Log (3, this, () => $"{label}; #items/page={pageProducts.Length}"); + products.AddRange (pageProducts); } - } else { - adb.json.LibraryResponse libraryResponse = adb.json.LibraryResponse.Deserialize (json); - libProducts.AddRange (libraryResponse.items); - } - - libProducts = libProducts.DistinctBy (p => p.asin).ToList (); - libProducts.Sort ((x, y) => DateTime.Compare (x.purchase_date, y.purchase_date)); - -#if TEST_INVAL_CHAR - libProducts = libProducts - .Select (p => { - p.title = p.title.Replace (ORIG, SUBS); - return p; - }) - .ToList (); -#endif - - await BookLibrary.AddRemBooksAsync (libProducts, new ProfileId (AccountId, Region), resync); - - var allPagesResponse = new adb.json.LibraryResponse (); - allPagesResponse.items = libProducts.ToArray (); - return allPagesResponse; + return products; + } } private void ensureAccountId () { @@ -234,47 +261,147 @@ public async Task GetActivationBytesAsync () { adb.json.LicenseResponse license = adb.json.LicenseResponse.Deserialize (response); - decryptLicense (license?.content_license); + try { + decryptLicense (license?.content_license); + } catch (Exception exc) { + Log (3, this, () => $"asin={asin}; license decryption skipped: {exc.Summary ()}"); + } return license; } - public async Task GetDownloadLicenseAndSaveAsync (Conversion conversion, EDownloadQuality quality) { + public async Task GetDownloadLicenseAndSaveAsync (Conversion conversion, EDownloadQuality quality, CancellationToken cancToken = default) { using var _ = new LogGuard (3, this, () => $"{conversion}"); Log (3, this, () => $"{conversion}; desired quality: {quality}"); - adb.json.LicenseResponse licresp; - // get license + adb.json.ContentLicense lic = null; + bool granted = false; + try { - licresp = await GetDownloadLicenseAsync (conversion.Asin, quality); - } catch (Exception exc) { - conversion.State = EConversionState.license_denied; - Log (3, this, () => $"{conversion}; {exc.Summary ()}"); + for (int attempt = 0; attempt <= LICENSE_MAX_THROTTLE_RETRIES; attempt++) { + await waitForLicenseRequestWindowAsync (cancToken); + + adb.json.LicenseResponse licresp; + try { + licresp = await GetDownloadLicenseAsync (conversion.Asin, quality); + } catch (Exception exc) { + conversion.State = EConversionState.license_denied; + Log (3, this, () => $"{conversion}; {exc.Summary ()}"); + return false; + } + + lic = licresp?.content_license; + bool succ = Enum.TryParse (lic?.status_code, out var status); + granted = succ && status == ELicenseStatusCode.Granted; + if (granted) + break; + + string reasons = lic?.license_denial_reasons? + .Select (r => $"{r.validationType}:{r.rejectionReason}:{r.message}") + .Combine (" | "); + if (reasons.IsNullOrWhiteSpace ()) + reasons = "none"; + + bool throttled = isThrottledDenial (lic, reasons); + if (throttled && attempt < LICENSE_MAX_THROTTLE_RETRIES) { + var delay = getThrottleBackoff (attempt); + Log (2, this, () => $"{conversion}; license throttled. retry {attempt + 1}/{LICENSE_MAX_THROTTLE_RETRIES} in {delay.TotalSeconds:N1}s. reasons={reasons}"); + await applyThrottleCooldownAsync (delay, cancToken); + continue; + } + + conversion.State = EConversionState.license_denied; + Log (3, this, () => $"{conversion}; license not granted. status={lic?.status_code ?? "null"}; reasons={reasons}"); + return false; + } + } catch (OperationCanceledException) when (cancToken.IsCancellationRequested) { + Log (3, this, () => $"{conversion}; license request canceled by user."); return false; } - var lic = licresp?.content_license; - if (lic?.voucher is null) { + if (!granted) { conversion.State = EConversionState.license_denied; - Log (3, this, () => $"{conversion}; license decryption failed."); + Log (3, this, () => $"{conversion}; license not granted after retry budget."); return false; } - bool succ = Enum.TryParse (lic.status_code, out var status); - if (!succ || status != ELicenseStatusCode.Granted) { + if (lic?.voucher is null) { conversion.State = EConversionState.license_denied; - Log (3, this, () => $"{conversion}; license not granted."); + Log (3, this, () => $"{conversion}; license payload missing voucher for granted status."); return false; } // save license to DB, including chapters // update state - var aq = BookLibrary.UpdateLicenseAndChapters (lic, conversion, quality); - Log (3, this, () => $"{conversion}; done, {aq}"); + try { + var aq = BookLibrary.UpdateLicenseAndChapters (lic, conversion, quality); + Log (3, this, () => $"{conversion}; done, {aq}"); + } catch (Exception exc) { + conversion.State = EConversionState.license_denied; + Log (1, this, () => $"{conversion}; failed to persist license: {exc.Summary ()}"); + return false; + } return true; } + private static bool isThrottledDenial (adb.json.ContentLicense lic, string reasons) { + if (lic?.license_denial_reasons?.Any (r => + r?.rejectionReason?.IndexOf ("throttl", StringComparison.OrdinalIgnoreCase) >= 0 + || r?.message?.IndexOf ("throttl", StringComparison.OrdinalIgnoreCase) >= 0) == true) + return true; + + return reasons?.IndexOf ("throttl", StringComparison.OrdinalIgnoreCase) >= 0; + } + + private static TimeSpan getThrottleBackoff (int attempt) { + double[] baseSecs = new[] { 10d, 20d, 40d, 80d, 120d }; + int idx = Math.Min (attempt, baseSecs.Length - 1); + int jitterMs = Random.Shared.Next (250, 1250); + return TimeSpan.FromSeconds (baseSecs[idx]) + TimeSpan.FromMilliseconds (jitterMs); + } + + private static async Task waitForLicenseRequestWindowAsync (CancellationToken cancToken = default) { + await __licenseThrottleGate.WaitAsync (cancToken); + try { + var now = DateTime.UtcNow; + if (__nextLicenseRequestUtc < now - TimeSpan.FromMinutes (10)) + __licenseRequestCount = 0; + + if (now < __nextLicenseRequestUtc) { + var wait = __nextLicenseRequestUtc - now; + await Task.Delay (wait, cancToken); + } + + __licenseRequestCount++; + var spacing = getAdaptiveLicenseSpacing (__licenseRequestCount); + __nextLicenseRequestUtc = DateTime.UtcNow + spacing; + } finally { + __licenseThrottleGate.Release (); + } + } + + private static TimeSpan getAdaptiveLicenseSpacing (int requestCount) { + if (requestCount > 80) + return __highLicenseRequestSpacing; + if (requestCount > 40) + return __mediumLicenseRequestSpacing; + return __minLicenseRequestSpacing; + } + + private static async Task applyThrottleCooldownAsync (TimeSpan delay, CancellationToken cancToken = default) { + await __licenseThrottleGate.WaitAsync (cancToken); + try { + var target = DateTime.UtcNow + delay; + if (target > __nextLicenseRequestUtc) + __nextLicenseRequestUtc = target; + } finally { + __licenseThrottleGate.Release (); + } + + await Task.Delay (delay, cancToken); + } + public async Task DownloadAsync (Conversion conversion, Action progressAction, CancellationToken cancToken) { @@ -416,6 +543,7 @@ public async Task UpdateMetaInfo (IEnumerable components, Action $"component asin={comp.Asin}, delivery=\"{prod.content_delivery_type}\", title=\"{prod.title}\", has_children={prod.has_children}, is_listenable={prod.is_listenable}"); pairs.Add (new (prod, comp)); } BookLibrary.UpdateComponentProduct (pairs); @@ -518,6 +646,13 @@ private void decryptLicense (adb.json.ContentLicense license) { if (license is null) return; + // For denied/unlicensed items Audible may omit encrypted payload. + // In that case leave voucher null and let status_code handling decide. + if (license.voucher is not null) + return; + if (license.license_response.IsNullOrWhiteSpace ()) + return; + string hashable = Profile.DeviceInfo.Type + Profile.DeviceInfo.Serial + Profile.CustomerInfo.AccountId + license.asin; diff --git a/src/Connect.lib.core/BookLibrary.cs b/src/Connect.lib.core/BookLibrary.cs index cd00e93..28bd857 100644 --- a/src/Connect.lib.core/BookLibrary.cs +++ b/src/Connect.lib.core/BookLibrary.cs @@ -314,9 +314,18 @@ EDownloadQuality downloadQuality using var dbContext = new BookDbContext (_dbDir); dbContext.Conversions.Attach (conversion); + // Ensure conversion navigation properties are loaded when invoked with + // detached conversion entities from UI/download queues. + dbContext.Entry (conversion).Reference (c => c.Book).Load (); + dbContext.Entry (conversion).Reference (c => c.Component).Load (); + if (conversion.Component is not null) + dbContext.Entry (conversion.Component).Reference (c => c.Book).Load (); + conversion.DownloadUrl = license.content_metadata.content_url.offline_url; var product = conversion.BookCommon; + if (product is null) + throw new InvalidOperationException ($"missing book/component navigation for conversion {conversion.Id}"); if (product is Component comp) dbContext.Components.Attach (comp); @@ -585,20 +594,32 @@ private static AudioQuality setDownloadFilenameAndCodec ( EDownloadQuality downloadQuality ) { var product = conversion.BookCommon; + if (product is null) + throw new InvalidOperationException ($"missing book/component navigation for conversion {conversion?.Id}"); product.DownloadQuality = downloadQuality; // download destination string dir = conversion.DownloadFileName; + if (dir.IsNullOrWhiteSpace ()) + dir = ApplEnv.LocalApplDirectory; var sb = new StringBuilder (); // title plus asin plus codec.aaxc.m4b - string title = product.Title.Prune (); + string title = product.Title + ?? conversion.Title + ?? conversion.ParentBook?.Title + ?? product.Asin + ?? conversion.Asin + ?? "untitled"; + title = title.Prune (); + if (title.IsNullOrWhiteSpace ()) + title = "untitled"; title = title.Substring (0, Math.Min (20, title.Length)); sb.Append (title); - string asin = product.Asin; + string asin = product.Asin ?? conversion.Asin ?? "unknown"; sb.Append ($"_{asin}_LC"); AudioQuality aq = null; @@ -788,6 +809,8 @@ private void addPageBooks (BookDbContextLazyLoad dbContext, BookCompositeLists b addComponents (book, bcl.Components, product.relationships); + logInterestingProduct (product, book); + addConversions (book, bcl.Conversions, profileId); addSeries (book, bcl.Series, bcl.SeriesBooks, product.relationships); @@ -820,11 +843,27 @@ private void addPageBooks (BookDbContextLazyLoad dbContext, BookCompositeLists b private bool readd (BookCompositeLists bcl, adb.json.Product product, ProfileId profileId, bool resync) { if (bcl.BookAsins.Contains (product.asin)) { - if (!resync) - return true; - var bk = bcl.Conversions .FirstOrDefault (conv => string.Equals (conv.Book?.Asin, product.asin))?.Book; + + if (bk is not null) { + syncBookOwnership (bk, profileId); + + if (!bk.DeliveryType.HasValue + && tryMapDeliveryType (product.content_delivery_type, out var mappedType)) { + bk.DeliveryType = mappedType; + Log (3, this, () => $"updated delivery type: {bk.Asin} => {mappedType}"); + } + + // Keep existing rows in sync with newly returned child relations + // (e.g. podcast episodes), even when the parent book already exists. + syncExistingBookComponents (bk, bcl, product.relationships, profileId); + } + + if (!resync) { + return true; + } + if (!(bk?.Deleted ?? false)) return true; @@ -867,8 +906,7 @@ private static Book addBook (BookDbContextLazyLoad dbContext, adb.json.Product p SkuLite = product.sku_lite }; - bool succ = Enum.TryParse (product.content_delivery_type, out var deltype); - if (succ) + if (tryMapDeliveryType (product.content_delivery_type, out var deltype)) book.DeliveryType = deltype; if (!product.format_type.IsNullOrEmpty ()) @@ -880,20 +918,49 @@ private static Book addBook (BookDbContextLazyLoad dbContext, adb.json.Product p private static void addComponents (Book book, ICollection components, IEnumerable itmRelations) { var relations = itmRelations? - .Where (r => r.relationship_to_product == "child" && r.relationship_type == "component") + .Where (isChildComponentRelation) .ToList (); if (relations.IsNullOrEmpty ()) return; + int idx = 0; foreach (var rel in relations) { - int.TryParse (rel.sort, out int partNum); + idx++; + + var existingBookComponent = book.Components.FirstOrDefault (c => c.Asin == rel.asin); + if (existingBookComponent is not null) { + existingBookComponent.PartNumber = getRelationPartNumber (rel, idx); + if (existingBookComponent.Title.IsNullOrWhiteSpace ()) + existingBookComponent.Title = rel.title; + if (existingBookComponent.Sku.IsNullOrWhiteSpace ()) + existingBookComponent.Sku = rel.sku; + if (existingBookComponent.SkuLite.IsNullOrWhiteSpace ()) + existingBookComponent.SkuLite = rel.sku_lite; + continue; + } + + var existingComponent = components.FirstOrDefault (c => c.Asin == rel.asin); + if (existingComponent is not null) { + if (existingComponent.Book is null || existingComponent.Book == book) { + existingComponent.PartNumber = getRelationPartNumber (rel, idx); + if (existingComponent.Title.IsNullOrWhiteSpace ()) + existingComponent.Title = rel.title; + if (existingComponent.Sku.IsNullOrWhiteSpace ()) + existingComponent.Sku = rel.sku; + if (existingComponent.SkuLite.IsNullOrWhiteSpace ()) + existingComponent.SkuLite = rel.sku_lite; + book.Components.Add (existingComponent); + } + continue; + } + var component = new Component { Asin = rel.asin, Title = rel.title, Sku = rel.sku, SkuLite = rel.sku_lite, - PartNumber = partNum + PartNumber = getRelationPartNumber (rel, idx) }; components.Add (component); @@ -901,6 +968,125 @@ private static void addComponents (Book book, ICollection components, } } + private static void syncExistingBookComponents ( + Book book, + BookCompositeLists bcl, + IEnumerable relationships, + ProfileId profileId + ) { + if (book is null) + return; + + addComponents (book, bcl.Components, relationships); + + foreach (var component in book.Components) { + if (component.Conversion is not null) { + component.Conversion.AccountId = profileId.AccountId; + component.Conversion.Region = profileId.Region; + continue; + } + + var conversion = new Conversion { + State = EConversionState.remote, + AccountId = profileId.AccountId, + Region = profileId.Region + }; + component.Conversion = conversion; + bcl.Conversions.Add (conversion); + } + } + + private static void syncBookOwnership (Book book, ProfileId profileId) { + if (book?.Conversion is null) + return; + + book.Conversion.AccountId = profileId.AccountId; + book.Conversion.Region = profileId.Region; + + foreach (var component in book.Components) { + if (component.Conversion is null) + continue; + component.Conversion.AccountId = profileId.AccountId; + component.Conversion.Region = profileId.Region; + } + } + + private static bool tryMapDeliveryType (string contentDeliveryType, out EDeliveryType deliveryType) { + deliveryType = default; + if (contentDeliveryType.IsNullOrWhiteSpace ()) + return false; + + if (Enum.TryParse (contentDeliveryType, ignoreCase: true, out deliveryType)) + return true; + + if (string.Equals (contentDeliveryType, "PodcastParent", StringComparison.OrdinalIgnoreCase)) { + deliveryType = EDeliveryType.Periodical; + return true; + } + + if (string.Equals (contentDeliveryType, "PodcastEpisode", StringComparison.OrdinalIgnoreCase)) { + deliveryType = EDeliveryType.AudioPart; + return true; + } + + return false; + } + + private static bool isChildComponentRelation (adb.json.Relationship relation) { + if (relation is null) + return false; + if (!string.Equals (relation.relationship_to_product, "child", StringComparison.OrdinalIgnoreCase)) + return false; + if (relation.asin.IsNullOrWhiteSpace ()) + return false; + + return string.Equals (relation.relationship_type, "component", StringComparison.OrdinalIgnoreCase) + || string.Equals (relation.relationship_type, "episode", StringComparison.OrdinalIgnoreCase) + || string.Equals (relation.content_delivery_type, nameof (EDeliveryType.AudioPart), StringComparison.OrdinalIgnoreCase) + || string.Equals (relation.content_delivery_type, "PodcastEpisode", StringComparison.OrdinalIgnoreCase); + } + + private static int getRelationPartNumber (adb.json.Relationship relation, int fallbackPartNumber) { + if (relation is null) + return fallbackPartNumber; + + if (int.TryParse (relation.sort, out int partNum) && partNum > 0) + return partNum; + + if (int.TryParse (relation.sequence, out partNum) && partNum > 0) + return partNum; + + if (!relation.sequence.IsNullOrWhiteSpace ()) { + string firstSequencePart = relation.sequence.Split ('.').FirstOrDefault (); + if (int.TryParse (firstSequencePart, out partNum) && partNum > 0) + return partNum; + } + + return fallbackPartNumber; + } + + private void logInterestingProduct (adb.json.Product product, Book book) { + if (product is null) + return; + + bool isPodcastLike = book?.DeliveryType == EDeliveryType.Periodical + || book?.DeliveryType == EDeliveryType.AudioPart + || string.Equals (product.content_delivery_type, "PodcastParent", StringComparison.OrdinalIgnoreCase) + || string.Equals (product.content_delivery_type, "PodcastEpisode", StringComparison.OrdinalIgnoreCase); + bool hasChildren = product.has_children ?? false; + bool unmappedDeliveryType = !product.content_delivery_type.IsNullOrWhiteSpace () && book?.DeliveryType is null; + if (!isPodcastLike && !hasChildren && !unmappedDeliveryType) + return; + + Log (3, this, () => { + string childSummary = product.relationships? + .Where (r => string.Equals (r.relationship_to_product, "child", StringComparison.OrdinalIgnoreCase)) + .Select (r => $"{r.relationship_type}/{r.content_delivery_type}/{r.sort}/{r.sequence}") + .Combine () ?? "none"; + return $"library item asin={product.asin}, delivery=\"{product.content_delivery_type}\", mapped={book?.DeliveryType?.ToString () ?? "null"}, has_children={product.has_children}, child_relations={childSummary}"; + }); + } + const string REGEX_SERIES = @"(\d+)(\.(\d+))?"; static readonly Regex _regexSeries = new Regex (REGEX_SERIES, RegexOptions.Compiled); diff --git a/src/Connect.lib.core/DownloadDecryptJob.cs b/src/Connect.lib.core/DownloadDecryptJob.cs index 1999773..a231ea7 100644 --- a/src/Connect.lib.core/DownloadDecryptJob.cs +++ b/src/Connect.lib.core/DownloadDecryptJob.cs @@ -105,12 +105,14 @@ ConvertDelegate convertAction conversion.DownloadFileName = Settings.DownloadDirectory; - var licTask = AudibleApi.GetDownloadLicenseAndSaveAsync (conversion, quality); + var licTask = AudibleApi.GetDownloadLicenseAndSaveAsync (conversion, quality, context.CancellationToken); OnNewStateCallback (conversion); succ = await licTask; OnNewStateCallback (conversion); if (!succ) { + if (context.CancellationToken.IsCancellationRequested) + return; AudibleApi.SavePersistentState (conversion, EConversionState.license_denied); return; } @@ -190,9 +192,10 @@ ConvertDelegate convertAction if (succ && convertAction is not null) { Book book = conversion.ParentBook; - if (book.ApplicableState (Settings.MultiPartDownload) >= EConversionState.local_unlocked) { + bool useComponents = shouldUseComponentDownloads (book); + if (book.ApplicableState (Settings.MultiPartDownload || useComponents) >= EConversionState.local_unlocked) { bool filesExist = true; - if (Settings.MultiPartDownload && !book.Components.IsNullOrEmpty ()) { + if (useComponents && !book.Components.IsNullOrEmpty ()) { foreach (var comp in book.Components) { hasUnlockedFile = File.Exists ((comp.Conversion.DownloadFileName + R.DecryptedFileExt).AsUncIfLong()); filesExist &= hasUnlockedFile; @@ -209,6 +212,10 @@ ConvertDelegate convertAction } } + bool shouldUseComponentDownloads (Book bk) => + bk?.Components?.Any () == true + && (Settings.MultiPartDownload || bk.DeliveryType == EDeliveryType.Periodical); + void onProgressTime (Conversion conversion, TimeSpan progPos) { if (_threadProgress.TryGetValue ((conversion, TP_KEY), out var tp)) { double runLengthSecs = conversion.BookCommon.RunTimeLengthSeconds ?? 0; diff --git a/src/Connect.lib.core/Interfaces.cs b/src/Connect.lib.core/Interfaces.cs index 6232838..56c1515 100644 --- a/src/Connect.lib.core/Interfaces.cs +++ b/src/Connect.lib.core/Interfaces.cs @@ -91,7 +91,7 @@ Task DecryptAsync ( ); Task DownloadCoverImagesAsync (); Task UpdateMetaInfo (IEnumerable components, Action> onDone); - Task GetDownloadLicenseAndSaveAsync (Conversion conversion, EDownloadQuality quality); + Task GetDownloadLicenseAndSaveAsync (Conversion conversion, EDownloadQuality quality, CancellationToken cancToken = default); IEnumerable GetBooks (); void SavePersistentState (Conversion conversion, EConversionState state); void RestorePersistentState (Conversion conversion); diff --git a/src/Connect.ui.lib.core/BookDataSourceBase.cs b/src/Connect.ui.lib.core/BookDataSourceBase.cs index 204cefb..136e503 100644 --- a/src/Connect.ui.lib.core/BookDataSourceBase.cs +++ b/src/Connect.ui.lib.core/BookDataSourceBase.cs @@ -32,7 +32,7 @@ public BookDataSourceBase (Book book, IDownloadSettings settings) : base (book) protected bool MultiState { get { - if (!Settings.MultiPartDownload || Book.Components.Count == 0) + if (!useComponentDownloads ()) return false; return Book.Components .Select (c => c.Conversion.State) @@ -42,7 +42,7 @@ protected bool MultiState { } protected EConversionState getState () { - if (Settings.MultiPartDownload && Book.Components.Count > 0) { + if (useComponentDownloads ()) { var state = Book.Components .Select (c => c.Conversion.State) .Distinct () @@ -52,6 +52,14 @@ protected EConversionState getState () { return Book.Conversion.State; } + private bool useComponentDownloads () { + if (Book.Components.Count == 0) + return false; + if (Settings.MultiPartDownload) + return true; + return Book.DeliveryType == EDeliveryType.Periodical; + } + } } diff --git a/src/Connect.ui.lib.core/BookLibDGVControl.cs b/src/Connect.ui.lib.core/BookLibDGVControl.cs index 9de0066..831bea0 100644 --- a/src/Connect.ui.lib.core/BookLibDGVControl.cs +++ b/src/Connect.ui.lib.core/BookLibDGVControl.cs @@ -139,9 +139,8 @@ private void setDataSource (IEnumerable allBooks) { if (allBooks is null) return; - // HACK currently exclude podcasts allBooks = allBooks - .Where (b => b.DeliveryType == EDeliveryType.SinglePartBook || b.DeliveryType == EDeliveryType.MultiPartBook) + .Where (isSupportedLibraryBook) .ToList (); Log (3, this, () => $"#books={allBooks.Count ()} (deliv type filtered)"); @@ -157,6 +156,14 @@ private void setDataSource (IEnumerable allBooks) { } + private static bool isSupportedLibraryBook (Book book) => book?.DeliveryType switch { + EDeliveryType.SinglePartBook => true, + EDeliveryType.MultiPartBook => true, + EDeliveryType.AudioPart => true, + EDeliveryType.Periodical => true, + _ => false + }; + private void onUpdatedSettings () { var clm = dataGridView1.Columns[nameof (BookDataSource.Adult)]; if (clm is null) diff --git a/src/Connect.ui.lib.core/ConvertDGVControl.cs b/src/Connect.ui.lib.core/ConvertDGVControl.cs index 2e28995..90366ad 100644 --- a/src/Connect.ui.lib.core/ConvertDGVControl.cs +++ b/src/Connect.ui.lib.core/ConvertDGVControl.cs @@ -262,7 +262,9 @@ private void removeConversions (IEnumerable convDSRem) { private IEnumerable getDownloadConversions (IEnumerable books) { var convDS = new List (); foreach (var book in books) { - if (DownloadSettings.MultiPartDownload && book.DeliveryType == EDeliveryType.MultiPartBook && book.Components.Any ()) { + bool useComponents = book.Components.Any () + && (DownloadSettings.MultiPartDownload || book.DeliveryType == EDeliveryType.Periodical); + if (useComponents) { var convDSMultiPart = new List (); foreach (var comp in book.Components) convDSMultiPart.Add (new ConversionDataSource (comp.Conversion)); diff --git a/src/Connect.ui.lib.core/DataGridViewEx.cs b/src/Connect.ui.lib.core/DataGridViewEx.cs index d611cf0..69abb3b 100644 --- a/src/Connect.ui.lib.core/DataGridViewEx.cs +++ b/src/Connect.ui.lib.core/DataGridViewEx.cs @@ -41,6 +41,10 @@ public bool ClientAreaEnabled { } public DataGridViewEx () { + // Work around sporadic WinForms native tooltip crashes (ToolTip.Hide/DataGridViewToolTip). + // State hints remain available in-grid; disabling cell tooltips avoids the unsafe native path. + ShowCellToolTips = false; + _timer1.Interval = 100; _timer1.Tick += timer1_Tick; _timer2.Interval = 10; diff --git a/src/Connect.ui.lib.core/Properties/Resources.Designer.cs b/src/Connect.ui.lib.core/Properties/Resources.Designer.cs index 3e15961..e5ed9fb 100644 --- a/src/Connect.ui.lib.core/Properties/Resources.Designer.cs +++ b/src/Connect.ui.lib.core/Properties/Resources.Designer.cs @@ -750,6 +750,24 @@ internal static string multipartbook { } } + /// + /// Looks up a localized string similar to Podcast episode. + /// + internal static string audiopart { + get { + return ResourceManager.GetString("audiopart", resourceCulture); + } + } + + /// + /// Looks up a localized string similar to Podcast. + /// + internal static string periodical { + get { + return ResourceManager.GetString("periodical", resourceCulture); + } + } + /// /// Looks up a localized string similar to Part. /// diff --git a/src/Connect.ui.lib.core/Properties/Resources.resx b/src/Connect.ui.lib.core/Properties/Resources.resx index 2a4d06d..d9eb190 100644 --- a/src/Connect.ui.lib.core/Properties/Resources.resx +++ b/src/Connect.ui.lib.core/Properties/Resources.resx @@ -380,12 +380,18 @@ The device can also be deregistered via Amazon account management. Multi part book + + Podcast episode + Part Parts + + Podcast + Purchase date