diff --git a/Scripts/Bundle_Debian.sh b/Scripts/Bundle_Debian.sh index 3ca7ef13..b69eb76f 100644 --- a/Scripts/Bundle_Debian.sh +++ b/Scripts/Bundle_Debian.sh @@ -113,6 +113,7 @@ Essential: no Priority: optional Maintainer: github.com/rmcrackan Description: liberate your audiobooks +Recommends: libgtk-3-0, libwebkit2gtk-4.1-0 " >> $FOLDER_DEBIAN/control echo "Changing permissions for pre- and post-install files..." diff --git a/Scripts/Bundle_Redhat.sh b/Scripts/Bundle_Redhat.sh index 1b18f1d1..05d27ac6 100644 --- a/Scripts/Bundle_Redhat.sh +++ b/Scripts/Bundle_Redhat.sh @@ -62,7 +62,7 @@ License: GPLv3+ URL: https://github.com/rmcrackan/Libation Source0: https://github.com/rmcrackan/Libation -Requires: bash +Requires: bash gtk3 webkit2gtk4.1 %define __os_install_post %{nil} diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index 046a0e54..25cff2d7 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -8,7 +8,7 @@ - + diff --git a/Source/ApplicationServices/ApplicationServices.csproj b/Source/ApplicationServices/ApplicationServices.csproj index 8bd2a9c7..2105a0b0 100644 --- a/Source/ApplicationServices/ApplicationServices.csproj +++ b/Source/ApplicationServices/ApplicationServices.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index 1f16052a..7ec92f0b 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -1,11 +1,12 @@ -using System; -using System.Collections.Generic; -using System.Linq; +using ClosedXML.Excel; using CsvHelper; using CsvHelper.Configuration.Attributes; using DataLayer; using Newtonsoft.Json; -using NPOI.XSSF.UserModel; +using System; +using System.Collections.Generic; +using System.Globalization; +using System.Linq; namespace ApplicationServices { @@ -208,19 +209,11 @@ namespace ApplicationServices { var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); - var workbook = new XSSFWorkbook(); - var sheet = workbook.CreateSheet("Library"); + using var workbook = new XLWorkbook(); + var sheet = workbook.AddWorksheet("Library"); - var detailSubtotalFont = workbook.CreateFont(); - detailSubtotalFont.IsBold = true; - - var detailSubtotalCellStyle = workbook.CreateCellStyle(); - detailSubtotalCellStyle.SetFont(detailSubtotalFont); // headers - var rowIndex = 0; - var row = sheet.CreateRow(rowIndex); - var columns = new[] { nameof(ExportDto.Account), nameof(ExportDto.DateAdded), @@ -261,81 +254,71 @@ namespace ApplicationServices nameof(ExportDto.ChannelCount), nameof(ExportDto.BitRate) }; - var col = 0; + + int rowIndex = 1, col = 1; + var headerRow = sheet.Row(rowIndex++); foreach (var c in columns) { - var cell = row.CreateCell(col++); - var name = ExportDto.GetName(c); - cell.SetCellValue(name); - cell.CellStyle = detailSubtotalCellStyle; + var headerCell = headerRow.Cell(col++); + headerCell.Value = ExportDto.GetName(c); + headerCell.Style.Font.Bold = true; } - var dateFormat = workbook.CreateDataFormat(); - var dateStyle = workbook.CreateCellStyle(); - dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss"); - - rowIndex++; + var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss"; // Add data rows foreach (var dto in dtos) { - col = 0; - row = sheet.CreateRow(rowIndex++); + col = 1; + var row = sheet.Row(rowIndex++); - row.CreateCell(col++).SetCellValue(dto.Account); - row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle; - row.CreateCell(col++).SetCellValue(dto.AudibleProductId); - row.CreateCell(col++).SetCellValue(dto.Locale); - row.CreateCell(col++).SetCellValue(dto.Title); - row.CreateCell(col++).SetCellValue(dto.Subtitle); - row.CreateCell(col++).SetCellValue(dto.AuthorNames); - row.CreateCell(col++).SetCellValue(dto.NarratorNames); - row.CreateCell(col++).SetCellValue(dto.LengthInMinutes); - row.CreateCell(col++).SetCellValue(dto.Description); - row.CreateCell(col++).SetCellValue(dto.Publisher); - row.CreateCell(col++).SetCellValue(dto.HasPdf); - row.CreateCell(col++).SetCellValue(dto.SeriesNames); - row.CreateCell(col++).SetCellValue(dto.SeriesOrder); - row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall); - row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance); - row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory); - row.CreateCell(col++).SetCellValue(dto.PictureId); - row.CreateCell(col++).SetCellValue(dto.IsAbridged); - row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle; - row.CreateCell(col++).SetCellValue(dto.CategoriesNames); - row.CreateCell(col++).SetCellValue(dto.MyRatingOverall); - row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance); - row.CreateCell(col++).SetCellValue(dto.MyRatingStory); - row.CreateCell(col++).SetCellValue(dto.MyLibationTags); - row.CreateCell(col++).SetCellValue(dto.BookStatus); - row.CreateCell(col++).SetCellValue(dto.PdfStatus); - row.CreateCell(col++).SetCellValue(dto.ContentType); - row.CreateCell(col++).SetCellValue(dto.Language); - row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle; - row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion); - row.CreateCell(col++).SetCellValue(dto.IsFinished); - row.CreateCell(col++).SetCellValue(dto.IsSpatial); - row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion); - row.CreateCell(col++).SetCellValue(dto.CodecString); - row.CreateCell(col++).SetCellValue(dto.SampleRate); - row.CreateCell(col++).SetCellValue(dto.ChannelCount); - row.CreateCell(col++).SetCellValue(dto.BitRate); + row.Cell(col++).Value = dto.Account; + row.Cell(col++).SetDate(dto.DateAdded, dateFormat); + row.Cell(col++).Value = dto.AudibleProductId; + row.Cell(col++).Value = dto.Locale; + row.Cell(col++).Value = dto.Title; + row.Cell(col++).Value = dto.Subtitle; + row.Cell(col++).Value = dto.AuthorNames; + row.Cell(col++).Value = dto.NarratorNames; + row.Cell(col++).Value = dto.LengthInMinutes; + row.Cell(col++).Value = dto.Description; + row.Cell(col++).Value = dto.Publisher; + row.Cell(col++).Value = dto.HasPdf; + row.Cell(col++).Value = dto.SeriesNames; + row.Cell(col++).Value = dto.SeriesOrder; + row.Cell(col++).Value = dto.CommunityRatingOverall; + row.Cell(col++).Value = dto.CommunityRatingPerformance; + row.Cell(col++).Value = dto.CommunityRatingStory; + row.Cell(col++).Value = dto.PictureId; + row.Cell(col++).Value = dto.IsAbridged; + row.Cell(col++).SetDate(dto.DatePublished, dateFormat); + row.Cell(col++).Value = dto.CategoriesNames; + row.Cell(col++).Value = dto.MyRatingOverall; + row.Cell(col++).Value = dto.MyRatingPerformance; + row.Cell(col++).Value = dto.MyRatingStory; + row.Cell(col++).Value = dto.MyLibationTags; + row.Cell(col++).Value = dto.BookStatus; + row.Cell(col++).Value = dto.PdfStatus; + row.Cell(col++).Value = dto.ContentType; + row.Cell(col++).Value = dto.Language; + row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat); + row.Cell(col++).Value = dto.LastDownloadedVersion; + row.Cell(col++).Value = dto.IsFinished; + row.Cell(col++).Value = dto.IsSpatial; + row.Cell(col++).Value = dto.LastDownloadedFileVersion; + row.Cell(col++).Value = dto.CodecString; + row.Cell(col++).Value = dto.SampleRate; + row.Cell(col++).Value = dto.ChannelCount; + row.Cell(col++).Value = dto.BitRate; } - using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); - workbook.Write(fileData); + workbook.SaveAs(saveFilePath); } - private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate) - => nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value) - : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); - - private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt) - => nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value) - : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); - - private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat) - => nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value) - : cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric); + private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat) + { + cell.Value = value; + cell.Style.DateFormat.Format = dateFormat; + } } } diff --git a/Source/ApplicationServices/RecordExporter.cs b/Source/ApplicationServices/RecordExporter.cs index 52506383..b888222f 100644 --- a/Source/ApplicationServices/RecordExporter.cs +++ b/Source/ApplicationServices/RecordExporter.cs @@ -1,10 +1,11 @@ using AudibleApi.Common; +using ClosedXML.Excel; using CsvHelper; using DataLayer; using Newtonsoft.Json.Linq; -using NPOI.XSSF.UserModel; using System; using System.Collections.Generic; +using System.Globalization; using System.Linq; namespace ApplicationServices @@ -16,19 +17,10 @@ namespace ApplicationServices if (!records.Any()) return; - using var workbook = new XSSFWorkbook(); - var sheet = workbook.CreateSheet("Records"); - - var detailSubtotalFont = workbook.CreateFont(); - detailSubtotalFont.IsBold = true; - - var detailSubtotalCellStyle = workbook.CreateCellStyle(); - detailSubtotalCellStyle.SetFont(detailSubtotalFont); + using var workbook = new XLWorkbook(); + var worksheet = workbook.AddWorksheet("Records"); // headers - var rowIndex = 0; - var row = sheet.CreateRow(rowIndex); - var columns = new List { nameof(Type.Name), @@ -49,56 +41,52 @@ namespace ApplicationServices if (records.OfType().Any()) columns.Add(nameof(Clip.Title)); - var col = 0; + int rowIndex = 1, col = 1; + var headerRow = worksheet.Row(rowIndex++); foreach (var c in columns) { - var cell = row.CreateCell(col++); - cell.SetCellValue(c); - cell.CellStyle = detailSubtotalCellStyle; + var headerCell = headerRow.Cell(col++); + headerCell.Value = c; + headerCell.Style.Font.Bold = true; } - var dateFormat = workbook.CreateDataFormat(); - var dateStyle = workbook.CreateCellStyle(); - dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss"); + var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss"; // Add data rows foreach (var record in records) { - col = 0; + col = 1; + var row = worksheet.Row(rowIndex++); - row = sheet.CreateRow(++rowIndex); - - row.CreateCell(col++).SetCellValue(record.GetType().Name); - - var dateCreatedCell = row.CreateCell(col++); - dateCreatedCell.CellStyle = dateStyle; - dateCreatedCell.SetCellValue(record.Created.DateTime); - - row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds); + row.Cell(col++).Value = record.GetType().Name; + row.Cell(col++).SetDate(record.Created.DateTime, dateFormat); + row.Cell(col++).Value = record.Start.TotalMilliseconds; if (record is IAnnotation annotation) { - row.CreateCell(col++).SetCellValue(annotation.AnnotationId); - var lastModifiedCell = row.CreateCell(col++); - lastModifiedCell.CellStyle = dateStyle; - lastModifiedCell.SetCellValue(annotation.LastModified.DateTime); + row.Cell(col++).Value = annotation.AnnotationId; + row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat); if (annotation is IRangeAnnotation rangeAnnotation) { - row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds); - row.CreateCell(col++).SetCellValue(rangeAnnotation.Text); + row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds; + row.Cell(col++).Value = rangeAnnotation.Text; if (rangeAnnotation is Clip clip) - row.CreateCell(col++).SetCellValue(clip.Title); + row.Cell(col++).Value = clip.Title; } } } - using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); - workbook.Write(fileData); + workbook.SaveAs(saveFilePath); } + private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat) + { + cell.Value = value; + cell.Style.DateFormat.Format = dateFormat; + } public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable records) { if (!records.Any()) diff --git a/Source/AudibleUtilities/AccountsSettings.cs b/Source/AudibleUtilities/AccountsSettings.cs index 8f1bc538..bdef1580 100644 --- a/Source/AudibleUtilities/AccountsSettings.cs +++ b/Source/AudibleUtilities/AccountsSettings.cs @@ -6,6 +6,7 @@ using AudibleApi.Authorization; using Dinah.Core; using Newtonsoft.Json; +#nullable enable namespace AudibleUtilities { // 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended @@ -14,8 +15,8 @@ namespace AudibleUtilities // JSON : Array (properties on the collection will not be serialized) public class AccountsSettings : IUpdatable { - public event EventHandler Updated; - private void update(object sender = null, EventArgs e = null) + public event EventHandler? Updated; + private void update(object? sender = null, EventArgs? e = null) { foreach (var account in Accounts) validate(account); @@ -48,9 +49,9 @@ namespace AudibleUtilities } } - private string _cdm; + private string? _cdm; [JsonProperty] - public string Cdm + public string? Cdm { get => _cdm; set @@ -68,7 +69,7 @@ namespace AudibleUtilities #endregion #region de/serialize - public static AccountsSettings FromJson(string json) + public static AccountsSettings? FromJson(string json) => JsonConvert.DeserializeObject(json, Identity.GetJsonSerializerSettings()); public string ToJson(Formatting formatting = Formatting.Indented) @@ -107,7 +108,7 @@ namespace AudibleUtilities account.Updated += update; } - public Account GetAccount(string accountId, string locale) + public Account? GetAccount(string accountId, string? locale) { if (locale is null) return null; diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index 506a45d6..4b62e37d 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -5,8 +5,8 @@ - - + + diff --git a/Source/DataLayer.Postgres/DataLayer.Postgres.csproj b/Source/DataLayer.Postgres/DataLayer.Postgres.csproj index d23ec26b..4ee33922 100644 --- a/Source/DataLayer.Postgres/DataLayer.Postgres.csproj +++ b/Source/DataLayer.Postgres/DataLayer.Postgres.csproj @@ -12,12 +12,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj b/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj index f1546adc..e1128635 100644 --- a/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj +++ b/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj @@ -12,12 +12,12 @@ - + all runtime; build; native; contentfiles; analyzers; buildtransitive - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index 916c545e..e2c3699a 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -13,12 +13,12 @@ - - + + all runtime; build; native; contentfiles; analyzers; buildtransitive - + all runtime; build; native; contentfiles; analyzers; buildtransitive diff --git a/Source/DataLayer/MockLibraryBook.cs b/Source/DataLayer/MockLibraryBook.cs new file mode 100644 index 00000000..f3b5dc02 --- /dev/null +++ b/Source/DataLayer/MockLibraryBook.cs @@ -0,0 +1,116 @@ +using System; +using System.Linq; +using System.Reflection; +using System.Text; + +#nullable enable +namespace DataLayer; +public class MockLibraryBook : LibraryBook +{ + protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil) + : base(book, dateAdded, account) + { + SetIncludedUntil(includedUntil); + } + + public MockLibraryBook AddSeries(string seriesName, int order) + { + var series = new Series(new AudibleSeriesId(CalculateAsin(seriesName)), seriesName); + Book.UpsertSeries(series, order.ToString()); + return this; + } + + public MockLibraryBook AddCategoryLadder(params string[] ladder) + { + var newLadder = new CategoryLadder(ladder.Select(c => new Category(new AudibleCategoryId(CalculateAsin(c)), c)).ToList()); + Book.SetCategoryLadders(Book.Categories.Select(c => c.CategoryLadder).Append(newLadder)); + return this; + } + + public MockLibraryBook AddNarrator(string name) + { + var newNarrator = new Contributor(name, CalculateAsin(name)); + Book.ReplaceNarrators(Book.Narrators.Append(newNarrator)); + return this; + } + + public MockLibraryBook AddAuthor(string name) + { + var newAuthor = new Contributor(name, CalculateAsin(name)); + Book.ReplaceAuthors(Book.Authors.Append(newAuthor)); + return this; + } + + public MockLibraryBook WithBookStatus(LiberatedStatus liberatedStatus) + { + //Set the backing field directly to preserve LiberatedStatus.PartialDownload + typeof(UserDefinedItem) + .GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance) + ?.SetValue(Book.UserDefinedItem, liberatedStatus); + return this; + } + + public MockLibraryBook WithPdfStatus(LiberatedStatus liberatedStatus) + { + Book.UserDefinedItem.PdfStatus = liberatedStatus; + return this; + } + + public MockLibraryBook WithLastDownloaded(Version? lastVersion = null, AudioFormat? format = null, string audioVersion = "1") + { + lastVersion ??= new Version(10, 0, 0, 0); + format ??= AudioFormat.Default; + Book.UserDefinedItem.SetLastDownloaded(lastVersion, format, audioVersion); + return this; + } + + public MockLibraryBook WithMyRating(float overallRating = 4, float performanceRating = 4.5f, float storyRating = 5) + { + Book.UserDefinedItem.UpdateRating(overallRating, performanceRating, storyRating); + return this; + } + + public static MockLibraryBook CreateBook( + string account = "someone@email.co", + bool absetFromLastScan = false, + DateTime? dateAdded = null, + DateTime? datePublished = null, + DateTime? includedUntil = null, + string title = "Mock Book Title", + string subtitle = "Mock Book Subtitle", + string description = "This is a mock book description.", + int lengthInMinutes = 1400, + ContentType contentType = ContentType.Product, + string firstAuthor = "Author One", + string firstNarrator = "Narrator One", + string localeName = "us", + bool isAbridged = false, + bool isSpatial = false, + string language = "English") + { + var book = new Book( + new AudibleProductId(CalculateAsin(title + subtitle)), + title, + subtitle, + description, + lengthInMinutes, + contentType, + [new Contributor(firstAuthor, CalculateAsin(firstAuthor))], + [new Contributor(firstNarrator, CalculateAsin(firstNarrator))], + localeName); + + book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language); + + return new MockLibraryBook( + book, + dateAdded ?? DateTime.Now, + account, + includedUntil) + { + AbsentFromLastScan = absetFromLastScan + }; + } + + private static string CalculateAsin(string name) + => Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(name))).Substring(0, 10); +} diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index d560886c..f6485c3f 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -3,6 +3,7 @@ using AudibleApi; using AudibleApi.Common; using AudibleUtilities.Widevine; using DataLayer; +using Dinah.Core; using LibationFileManager; using NAudio.Lame; using System; @@ -24,14 +25,29 @@ public partial class DownloadOptions public static async Task InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token) { var license = await ChooseContent(api, libraryBook, config, token); + Serilog.Log.Logger.Debug("Content License {@License}", new + { + license.DrmType, + license.ContentMetadata.ContentReference.Codec, + license.ContentMetadata.ContentReference.Marketplace, + license.ContentMetadata.ContentReference.ContentSizeInBytes, + license.ContentMetadata.ContentReference.Version, + license.ContentMetadata.ContentReference.FileVersion + }); + token.ThrowIfCancellationRequested(); //Some audiobooks will have incorrect chapters in the metadata returned from the license request, //but the metadata returned by the content metadata endpoint will be correct. Call the content - //metadata endpoint and use its chapters. Only replace the license request chapters if the total - //lengths match (defensive against different audio formats having slightly different lengths). - var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId); - if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs) + //metadata endpoint and use its chapters. Only replace the license request chapters if the content + //references match (defensive against different audio formats having slightly different lengths). + var metadata = await api.GetContentMetadataAsync( + libraryBook.Book.AudibleProductId, + license.DrmType, + license.ContentMetadata.ContentReference.Acr, + license.ContentMetadata.ContentReference.FileVersion); + + if (metadata is not null && metadata.ContentReference == license.ContentMetadata.ContentReference) license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; token.ThrowIfCancellationRequested(); @@ -41,7 +57,7 @@ public partial class DownloadOptions private class LicenseInfo { public DrmType DrmType { get; } - public ContentMetadata ContentMetadata { get; set; } + public ContentMetadata ContentMetadata { get; } public KeyData[]? DecryptionKeys { get; } public LicenseInfo(ContentLicense license, IEnumerable? keys = null) { @@ -56,10 +72,28 @@ public partial class DownloadOptions private static async Task ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) { + Serilog.Log.Logger.Debug("Download Settings {@Settings}", new + { + config.FileDownloadQuality, + config.UseWidevine, + config.Request_xHE_AAC, + config.RequestSpatial, + config.SpatialAudioCodec + }); + var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; - if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) + bool canUseWidevine = api.SupportsWidevine(); + if (!config.UseWidevine || !canUseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) { + if (config.UseWidevine) + { + if (canUseWidevine) + Serilog.Log.Logger.Information("Unable to get a Widevine CDM. Falling back to ADRM."); + else + Serilog.Log.Logger.Information("Account {@account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask()); + } + token.ThrowIfCancellationRequested(); var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality); return new LicenseInfo(license); @@ -254,8 +288,11 @@ public partial class DownloadOptions */ - public static List flattenChapters(IList chapters, string? titleConcat = ": ") + public static List flattenChapters(IList? chapters, string? titleConcat = ": ") { + if (chapters is null) + return []; + List chaps = new(); foreach (var c in chapters) diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 73ac5bbc..d563924c 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -7,6 +7,7 @@ using DataLayer; using Dinah.Core; using LibationFileManager; using LibationFileManager.Templates; +using System.Security.Authentication; #nullable enable namespace FileLiberator @@ -25,14 +26,22 @@ namespace FileLiberator public static async Task GetApiAsync(this LibraryBook libraryBook) { - Account account; - using (var accounts = AudibleApiStorage.GetAccountsSettingsPersister()) - account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale); + using var accounts = AudibleApiStorage.GetAccountsSettingsPersister(); + var account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale) + ?? throw new InvalidCredentialException($"No account found for '{libraryBook.Account}' and locale '{libraryBook.Book.Locale}'"); var apiExtended = await ApiExtended.CreateAsync(account); return apiExtended.Api; } + public static bool SupportsWidevine(this AudibleApi.Api api) + { + //TODO: Expose Api's identity maintainer directly instead of using reflection. + var identityProperty = api.GetType().GetProperty("_identityMaintainer", System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance); + return identityProperty?.GetValue(api) is AudibleApi.Authorization.IIdentityMaintainer identityMaintainer + && identityMaintainer.DeviceType == AudibleApi.Resources.DeviceType; + } + public static LibraryBookDto ToDto(this LibraryBook libraryBook) { using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); diff --git a/Source/FileManager/BackgroundFileSystem.cs b/Source/FileManager/BackgroundFileSystem.cs index e4ec7db2..4dec3b8a 100644 --- a/Source/FileManager/BackgroundFileSystem.cs +++ b/Source/FileManager/BackgroundFileSystem.cs @@ -3,15 +3,16 @@ using System.Collections.Concurrent; using System.Collections.Generic; using System.IO; using System.Linq; +using System.Threading; using System.Threading.Tasks; #nullable enable namespace FileManager { - /// - /// Tracks actual locations of files. - /// - public class BackgroundFileSystem + /// + /// Tracks actual locations of files. + /// + public class BackgroundFileSystem : IDisposable { public LongPath RootDirectory { get; private set; } public string SearchPattern { get; private set; } @@ -21,7 +22,7 @@ namespace FileManager private BlockingCollection? directoryChangesEvents { get; set; } private Task? backgroundScanner { get; set; } - private object fsCacheLocker { get; } = new(); + private Lock fsCacheLocker { get; } = new(); private List fsCache { get; } = new(); public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions) @@ -100,7 +101,6 @@ namespace FileManager private void FileSystemWatcher_Error(object sender, ErrorEventArgs e) { - Stop(); Init(); } @@ -181,8 +181,12 @@ namespace FileManager fsCache.Add(newFile); } - #endregion + #endregion - ~BackgroundFileSystem() => Stop(); - } + public void Dispose() + { + Stop(); + GC.SuppressFinalize(this); + } + } } diff --git a/Source/FileManager/FileManager.csproj b/Source/FileManager/FileManager.csproj index 8077a239..03d96351 100644 --- a/Source/FileManager/FileManager.csproj +++ b/Source/FileManager/FileManager.csproj @@ -6,7 +6,7 @@ - + diff --git a/Source/FileManager/IPersistentDictionary.cs b/Source/FileManager/IPersistentDictionary.cs new file mode 100644 index 00000000..865733d1 --- /dev/null +++ b/Source/FileManager/IPersistentDictionary.cs @@ -0,0 +1,40 @@ +using Newtonsoft.Json.Linq; +using System; +using System.Linq; + +#nullable enable +namespace FileManager; + +public interface IPersistentDictionary +{ + bool Exists(string propertyName); + string? GetString(string propertyName, string? defaultValue = null); + T? GetNonString(string propertyName, T? defaultValue = default); + object? GetObject(string propertyName); + void SetString(string propertyName, string? newValue); + void SetNonString(string propertyName, object? newValue); + bool RemoveProperty(string propertyName); + bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false); + string? GetStringFromJsonPath(string jsonPath); + + string? GetStringFromJsonPath(string jsonPath, string propertyName) + => GetStringFromJsonPath($"{jsonPath}.{propertyName}"); + + static T? UpCast(object obj) + { + if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj; + if (obj is JObject jObject) return jObject.ToObject(); + if (obj is JValue jValue) + { + if (typeof(T).IsAssignableTo(typeof(Enum))) + { + return + Enum.TryParse(typeof(T), jValue.Value(), out var enumVal) + ? (T)enumVal + : Enum.GetValues(typeof(T)).Cast().First(); + } + return jValue.Value(); + } + throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}"); + } +} diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index 3ebbd1cc..9bc86746 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -2,14 +2,13 @@ using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; -using System.Linq; using Newtonsoft.Json; using Newtonsoft.Json.Linq; #nullable enable namespace FileManager { - public class PersistentDictionary + public class PersistentDictionary : IPersistentDictionary { public string Filepath { get; } public bool IsReadOnly { get; } @@ -60,21 +59,8 @@ namespace FileManager objectCache[propertyName] = defaultValue; return defaultValue; } - if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj; - if (obj is JObject jObject) return jObject.ToObject(); - if (obj is JValue jValue) - { - if (typeof(T).IsAssignableTo(typeof(Enum))) - { - return - Enum.TryParse(typeof(T), jValue.Value(), out var enumVal) - ? (T)enumVal - : Enum.GetValues(typeof(T)).Cast().First(); - } - return jValue.Value(); - } - throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}"); - } + return IPersistentDictionary.UpCast(obj); + } public object? GetObject(string propertyName) { @@ -89,7 +75,6 @@ namespace FileManager return objectCache[propertyName]; } - public string? GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}"); public string? GetStringFromJsonPath(string jsonPath) { if (!stringCache.ContainsKey(jsonPath)) diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index da86862c..fdff7595 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -70,13 +70,11 @@ - - - + - - - + + + diff --git a/Source/HangoverAvalonia/Program.cs b/Source/HangoverAvalonia/Program.cs index 037d6c57..fc42d4e9 100644 --- a/Source/HangoverAvalonia/Program.cs +++ b/Source/HangoverAvalonia/Program.cs @@ -1,5 +1,5 @@ using Avalonia; -using Avalonia.ReactiveUI; +using ReactiveUI.Avalonia; using System; namespace HangoverAvalonia diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index bbff098b..db969cf4 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -1,261 +1,261 @@ using ApplicationServices; using Avalonia; +using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Data.Core.Plugins; using Avalonia.Markup.Xaml; using Avalonia.Platform; using Avalonia.Styling; +using Avalonia.Threading; +using Dinah.Core; using LibationAvalonia.Dialogs; +using LibationAvalonia.Themes; using LibationAvalonia.Views; using LibationFileManager; +using LibationUiBase.Forms; using System; using System.Collections.Generic; using System.IO; -using System.Threading.Tasks; -using Avalonia.Threading; -using Dinah.Core; -using LibationAvalonia.Themes; -using Avalonia.Data.Core.Plugins; using System.Linq; -using LibationUiBase.Forms; -using Avalonia.Controls; +using System.Threading.Tasks; #nullable enable -namespace LibationAvalonia +namespace LibationAvalonia; + +public class App : Application { - public class App : Application + public static Task>? LibraryTask { get; set; } + public static ChardonnayTheme? DefaultThemeColors { get; private set; } + public static MainWindow? MainWindow { get; private set; } + public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/"); + public static new Application Current => Application.Current ?? throw new InvalidOperationException("The Avalonia app hasn't started yet."); + + public static Stream OpenAsset(string assetRelativePath) + => AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath)); + + public override void Initialize() => AvaloniaXamlLoader.Load(this); + + public override void OnFrameworkInitializationCompleted() { - public static Task>? LibraryTask { get; set; } - public static ChardonnayTheme? DefaultThemeColors { get; private set; } - public static MainWindow? MainWindow { get; private set; } - public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/"); - public static new Application Current => Application.Current ?? throw new InvalidOperationException("The Avalonia app hasn't started yet."); + DefaultThemeColors = ChardonnayTheme.GetLiveTheme(); - public static Stream OpenAsset(string assetRelativePath) - => AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath)); - - public override void Initialize() + if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - AvaloniaXamlLoader.Load(this); - } + // Chardonnay uses the OnLastWindowClose shutdown mode. As long as the application lifetime + // has one active window, the application will stay alive. Setup windows must be daisy chained, + // each closing windows opens the next window before closing itself to prevent the app from exiting. - public override void OnFrameworkInitializationCompleted() - { - DefaultThemeColors = ChardonnayTheme.GetLiveTheme(); + MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => + MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition); - if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) + // Avoid duplicate validations from both Avalonia and the CommunityToolkit. + // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins + DisableAvaloniaDataAnnotationValidation(); + + Configuration config = Configuration.Instance; + + if (!config.LibationSettingsAreValid) { - MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => - MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition); + string defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory; - // Avoid duplicate validations from both Avalonia and the CommunityToolkit. - // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins - DisableAvaloniaDataAnnotationValidation(); + // check for existing settings in default location + string defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); + if (Configuration.SettingsFileIsValid(defaultSettingsFile)) + Configuration.SetLibationFiles(defaultLibationFilesDir); - var config = Configuration.Instance; - - if (!config.LibationSettingsAreValid) + if (config.LibationSettingsAreValid) { - var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory; - - // check for existing settings in default location - var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); - if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - Configuration.SetLibationFiles(defaultLibationFilesDir); - - if (config.LibationSettingsAreValid) - { - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - ShowMainWindow(desktop); - } - else - { - var setupDialog = new SetupDialog { Config = config }; - setupDialog.Closing += Setup_Closing; - desktop.MainWindow = setupDialog; - } + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + ShowMainWindow(desktop); } else - ShowMainWindow(desktop); - } - - base.OnFrameworkInitializationCompleted(); - } - private void DisableAvaloniaDataAnnotationValidation() - { - // Get an array of plugins to remove - var dataValidationPluginsToRemove = - BindingPlugins.DataValidators.OfType().ToArray(); - - // remove each entry found - foreach (var plugin in dataValidationPluginsToRemove) - { - BindingPlugins.DataValidators.Remove(plugin); - } - } - - private async void Setup_Closing(object? sender, System.ComponentModel.CancelEventArgs e) - { - if (sender is not SetupDialog setupDialog || ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop) - return; - - try - { - // all returns should be preceded by either: - // - if config.LibationSettingsAreValid - // - error message, Exit() - if (setupDialog.IsNewUser) { - Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory); - setupDialog.Config.Books = Configuration.DefaultBooksDirectory; - - if (setupDialog.Config.LibationSettingsAreValid) - { - string? theme = setupDialog.SelectedTheme.Content as string; - - setupDialog.Config.SetString(theme, nameof(ThemeVariant)); - - await RunMigrationsAsync(setupDialog.Config); - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - { - e.Cancel = true; - await CancelInstallation(setupDialog); - } + SetupDialog setupDialog = new() { Config = config }; + setupDialog.Closing += (_, e) => SetupClosing(setupDialog, desktop, e); + desktop.MainWindow = setupDialog; } - else if (setupDialog.IsReturningUser) + } + else + { + ShowMainWindow(desktop); + } + } + + base.OnFrameworkInitializationCompleted(); + } + + private void DisableAvaloniaDataAnnotationValidation() + { + // Get an array of plugins to remove + DataAnnotationsValidationPlugin[] dataValidationPluginsToRemove = + BindingPlugins.DataValidators.OfType().ToArray(); + + // remove each entry found + foreach (DataAnnotationsValidationPlugin? plugin in dataValidationPluginsToRemove) + { + BindingPlugins.DataValidators.Remove(plugin); + } + } + + private async void SetupClosing(SetupDialog setupDialog, IClassicDesktopStyleApplicationLifetime desktop, System.ComponentModel.CancelEventArgs e) + { + try + { + if (setupDialog.IsNewUser) + { + Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory); + setupDialog.Config.Books = Configuration.DefaultBooksDirectory; + + if (setupDialog.Config.LibationSettingsAreValid) { - ShowLibationFilesDialog(desktop, setupDialog.Config, OnLibationFilesCompleted); + string? theme = setupDialog.SelectedTheme.Content as string; + setupDialog.Config.SetString(theme, nameof(ThemeVariant)); + + await RunMigrationsAsync(setupDialog.Config); + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + ShowMainWindow(desktop); } else { e.Cancel = true; await CancelInstallation(setupDialog); - return; } - } - catch (Exception ex) + else if (setupDialog.IsReturningUser) { - var title = "Fatal error, pre-logging"; - var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; - try - { - await MessageBox.ShowAdminAlert(setupDialog, body, title, ex); - } - catch - { - await MessageBox.Show(setupDialog, $"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error); - } - return; - } - } - - private async Task RunMigrationsAsync(Configuration config) - { - // most migrations go in here - AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); - - await MessageBox.VerboseLoggingWarning_ShowIfTrue(); - - // logging is init'd here - AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config); - } - - private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config, Action OnClose) - { - var libationFilesDialog = new LibationFilesDialog(); - desktop.MainWindow = libationFilesDialog; - libationFilesDialog.Show(); - - void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) - { - libationFilesDialog.Closing -= WindowClosing; - e.Cancel = true; - OnClose?.Invoke(desktop, libationFilesDialog, config); - } - libationFilesDialog.Closing += WindowClosing; - } - - private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config) - { - Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); - if (config.LibationSettingsAreValid) - { - await RunMigrationsAsync(config); - - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); + ShowLibationFilesDialog(desktop, setupDialog.Config); } else { - // path did not result in valid settings - var continueResult = await MessageBox.Show( - libationFilesDialog, - $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", - "New install?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); + e.Cancel = true; + await CancelInstallation(setupDialog); + } + } + catch (Exception ex) + { + string title = "Fatal error, pre-logging"; + string body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; - if (continueResult == DialogResult.Yes) + MessageBoxAlertAdminDialog alert = new(body, title, ex); + desktop.MainWindow = alert; + alert.Show(); + } + } + + private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config) + { + LibationFilesDialog libationFilesDialog = new(); + desktop.MainWindow = libationFilesDialog; + libationFilesDialog.Show(); + + async void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) + { + libationFilesDialog.Closing -= WindowClosing; + e.Cancel = true; + if (libationFilesDialog.DialogResult == DialogResult.OK) + OnLibationFilesCompleted(desktop, libationFilesDialog, config); + else + await CancelInstallation(libationFilesDialog); + } + libationFilesDialog.Closing += WindowClosing; + } + + private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config) + { + Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); + if (config.LibationSettingsAreValid) + { + await RunMigrationsAsync(config); + + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + ShowMainWindow(desktop); + } + else + { + // path did not result in valid settings + DialogResult continueResult = await MessageBox.Show( + libationFilesDialog, + $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", + "New install?", + MessageBoxButtons.YesNo, + MessageBoxIcon.Question); + + if (continueResult == DialogResult.Yes) + { + config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books)); + + if (config.LibationSettingsAreValid) { - config.Books = Configuration.DefaultBooksDirectory; - - if (config.LibationSettingsAreValid) - { - await RunMigrationsAsync(config); - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - await CancelInstallation(libationFilesDialog); + await RunMigrationsAsync(config); + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); + ShowMainWindow(desktop); } else + { await CancelInstallation(libationFilesDialog); + } } - - libationFilesDialog.Close(); - } - - static async Task CancelInstallation(Window window) - { - await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Environment.Exit(0); - } - - private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) - { - Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged; - OpenAndApplyTheme(Configuration.Instance.GetString(propertyName: nameof(ThemeVariant))); - - var mainWindow = new MainWindow(); - desktop.MainWindow = MainWindow = mainWindow; - mainWindow.Loaded += MainWindow_Loaded; - mainWindow.RestoreSizeAndLocation(Configuration.Instance); - mainWindow.Show(); - } - - [PropertyChangeFilter(nameof(ThemeVariant))] - private static void ThemeVariant_PropertyChanged(object sender, PropertyChangedEventArgsEx e) - => OpenAndApplyTheme(e.NewValue as string); - - private static void OpenAndApplyTheme(string? themeVariant) - { - using var themePersister = ChardonnayThemePersister.Create(); - themePersister?.Target.ApplyTheme(themeVariant); - } - - private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) - { - if (LibraryTask is not null && MainWindow is not null) + else { - var library = await LibraryTask; - await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library)); + await CancelInstallation(libationFilesDialog); } } + + libationFilesDialog.Close(); + } + + private static async Task CancelInstallation(Window window) + { + await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + Environment.Exit(-1); + } + + private async Task RunMigrationsAsync(Configuration config) + { + // most migrations go in here + AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); + + await MessageBox.VerboseLoggingWarning_ShowIfTrue(); + + // logging is init'd here + AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config); + } + + private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) + { + Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged; + Current.ActualThemeVariantChanged += OnActualThemeVariantChanged; + OnActualThemeVariantChanged(Current, EventArgs.Empty); + + MainWindow mainWindow = new(); + desktop.MainWindow = MainWindow = mainWindow; + mainWindow.Loaded += MainWindow_Loaded; + mainWindow.RestoreSizeAndLocation(Configuration.Instance); + mainWindow.Show(); + } + + [PropertyChangeFilter(nameof(ThemeVariant))] + private static void ThemeVariant_PropertyChanged(object sender, PropertyChangedEventArgsEx e) + => OpenAndApplyTheme(e.NewValue as string); + + private static void OnActualThemeVariantChanged(object? sender, EventArgs e) + => OpenAndApplyTheme(Configuration.Instance.GetString(propertyName: nameof(ThemeVariant))); + + private static void OpenAndApplyTheme(string? themeVariant) + { + using ChardonnayThemePersister? themePersister = ChardonnayThemePersister.Create(); + themePersister?.Target.ApplyTheme(themeVariant); + } + + private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + if (LibraryTask is not null && MainWindow is not null) + { + List library = await LibraryTask; + await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library)); + } } } diff --git a/Source/LibationAvalonia/AvaloniaUtils.cs b/Source/LibationAvalonia/AvaloniaUtils.cs index 4bfc1aa7..82a360ba 100644 --- a/Source/LibationAvalonia/AvaloniaUtils.cs +++ b/Source/LibationAvalonia/AvaloniaUtils.cs @@ -23,11 +23,6 @@ namespace LibationAvalonia ? dialogWindow.ShowDialog(window) : Task.FromResult(DialogResult.None); - public static Task ShowDialogAsync(this Dialogs.Login.WebLoginDialog dialogWindow, Window? owner = null) - => ((owner ?? App.MainWindow) is Window window) - ? dialogWindow.ShowDialog(window) - : Task.FromResult(DialogResult.None); - public static Window? GetParentWindow(this Control control) => control.GetVisualRoot() as Window; diff --git a/Source/LibationAvalonia/Controls/NativeWebView.cs b/Source/LibationAvalonia/Controls/NativeWebView.cs deleted file mode 100644 index 0aad165d..00000000 --- a/Source/LibationAvalonia/Controls/NativeWebView.cs +++ /dev/null @@ -1,176 +0,0 @@ -using Avalonia.Controls; -using Avalonia.Input; -using Avalonia.Platform; -using Avalonia; -using LibationFileManager; -using System; -using System.Threading.Tasks; - -namespace LibationAvalonia.Controls; - -#nullable enable -public class NativeWebView : NativeControlHost, IWebView -{ - private IWebViewAdapter? _webViewAdapter; - private Uri? _delayedSource; - private TaskCompletionSource _webViewReadyCompletion = new(); - - public event EventHandler? NavigationCompleted; - - public event EventHandler? NavigationStarted; - public event EventHandler? DOMContentLoaded; - - public bool CanGoBack => _webViewAdapter?.CanGoBack ?? false; - - public bool CanGoForward => _webViewAdapter?.CanGoForward ?? false; - - public Uri? Source - { - get => _webViewAdapter?.Source ?? throw new InvalidOperationException("Control was not initialized"); - set - { - if (_webViewAdapter is null) - { - _delayedSource = value; - return; - } - _webViewAdapter.Source = value; - } - } - - - public bool GoBack() - { - return _webViewAdapter?.GoBack() ?? throw new InvalidOperationException("Control was not initialized"); - } - - public bool GoForward() - { - return _webViewAdapter?.GoForward() ?? throw new InvalidOperationException("Control was not initialized"); - } - - public Task InvokeScriptAsync(string scriptName) - { - return _webViewAdapter is null - ? throw new InvalidOperationException("Control was not initialized") - : _webViewAdapter.InvokeScriptAsync(scriptName); - } - - public void Navigate(Uri url) - { - (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) - .Navigate(url); - } - - public Task NavigateToString(string text) - { - return (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) - .NavigateToString(text); - } - - public void Refresh() - { - (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) - .Refresh(); - } - - public void Stop() - { - (_webViewAdapter ?? throw new InvalidOperationException("Control was not initialized")) - .Stop(); - } - - public Task WaitForNativeHost() - { - return _webViewReadyCompletion.Task; - } - - private class PlatformHandle : IPlatformHandle - { - public nint Handle { get; init; } - - public string? HandleDescriptor { get; init; } - } - - protected override IPlatformHandle CreateNativeControlCore(IPlatformHandle parent) - { - _webViewAdapter = InteropFactory.Create().CreateWebViewAdapter(); - - if (_webViewAdapter is null) - return base.CreateNativeControlCore(parent); - else - { - SubscribeOnEvents(); - var handle = new PlatformHandle - { - Handle = _webViewAdapter.PlatformHandle.Handle, - HandleDescriptor = _webViewAdapter.PlatformHandle.HandleDescriptor - }; - - if (_delayedSource is not null) - { - _webViewAdapter.Source = _delayedSource; - } - - _webViewReadyCompletion.TrySetResult(); - - return handle; - } - } - - private void SubscribeOnEvents() - { - if (_webViewAdapter is not null) - { - _webViewAdapter.NavigationStarted += WebViewAdapterOnNavigationStarted; - _webViewAdapter.NavigationCompleted += WebViewAdapterOnNavigationCompleted; - _webViewAdapter.DOMContentLoaded += _webViewAdapter_DOMContentLoaded; - } - } - - private void _webViewAdapter_DOMContentLoaded(object? sender, EventArgs e) - { - DOMContentLoaded?.Invoke(this, e); - } - - private void WebViewAdapterOnNavigationStarted(object? sender, WebViewNavigationEventArgs e) - { - NavigationStarted?.Invoke(this, e); - } - - private void WebViewAdapterOnNavigationCompleted(object? sender, WebViewNavigationEventArgs e) - { - NavigationCompleted?.Invoke(this, e); - } - - protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change) - { - base.OnPropertyChanged(change); - if (change.Property == BoundsProperty && change.NewValue is Rect rect) - { - var scaling = (float)(VisualRoot?.RenderScaling ?? 1.0f); - _webViewAdapter?.HandleResize((int)(rect.Width * scaling), (int)(rect.Height * scaling), scaling); - } - } - - protected override void OnKeyDown(KeyEventArgs e) - { - if (_webViewAdapter != null) - { - e.Handled = _webViewAdapter.HandleKeyDown((uint)e.Key, (uint)e.KeyModifiers); - } - - base.OnKeyDown(e); - } - - protected override void DestroyNativeControlCore(IPlatformHandle control) - { - if (_webViewAdapter is not null) - { - _webViewReadyCompletion = new TaskCompletionSource(); - _webViewAdapter.NavigationStarted -= WebViewAdapterOnNavigationStarted; - _webViewAdapter.NavigationCompleted -= WebViewAdapterOnNavigationCompleted; - (_webViewAdapter as IDisposable)?.Dispose(); - } - } -} diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs index 63758b69..d87e9401 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml.cs @@ -19,8 +19,7 @@ namespace LibationAvalonia.Controls.Settings InitializeComponent(); if (Design.IsDesignMode) { - _ = Configuration.Instance.LibationFiles; - DataContext = new AudioSettingsVM(Configuration.Instance); + DataContext = new AudioSettingsVM(Configuration.CreateMockInstance()); } } @@ -39,7 +38,7 @@ namespace LibationAvalonia.Controls.Settings { using var accounts = AudibleApiStorage.GetAccountsSettingsPersister(); - if (!accounts.AccountsSettings.Accounts.Any(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) + if (!accounts.AccountsSettings.Accounts.All(a => a.IdentityTokens.DeviceType == AudibleApi.Resources.DeviceType)) { if (VisualRoot is Window parent) { diff --git a/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs b/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs index 2be2bb5b..aa49d94d 100644 --- a/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml.cs @@ -16,8 +16,7 @@ namespace LibationAvalonia.Controls.Settings InitializeComponent(); if (Design.IsDesignMode) { - _ = Configuration.Instance.LibationFiles; - DataContext = new DownloadDecryptSettingsVM(Configuration.Instance); + DataContext = new DownloadDecryptSettingsVM(Configuration.CreateMockInstance()); } } diff --git a/Source/LibationAvalonia/Controls/Settings/Import.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Import.axaml.cs index cd6faaf3..163a7d38 100644 --- a/Source/LibationAvalonia/Controls/Settings/Import.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Import.axaml.cs @@ -12,8 +12,7 @@ namespace LibationAvalonia.Controls.Settings if (Design.IsDesignMode) { - _ = Configuration.Instance.LibationFiles; - DataContext = new ImportSettingsVM(Configuration.Instance); + DataContext = new ImportSettingsVM(Configuration.CreateMockInstance()); } } } diff --git a/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs index 272f5da7..66148fc8 100644 --- a/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs @@ -18,8 +18,7 @@ namespace LibationAvalonia.Controls.Settings InitializeComponent(); if (Design.IsDesignMode) { - _ = Configuration.Instance.LibationFiles; - DataContext = new ImportantSettingsVM(Configuration.Instance); + DataContext = new ImportantSettingsVM(Configuration.CreateMockInstance()); } ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged; diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs index 029b46f3..5966103f 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs @@ -2,11 +2,8 @@ using Avalonia.Controls; using DataLayer; using Dinah.Core.ErrorHandling; using LibationAvalonia.ViewModels; -using LibationFileManager; using LibationUiBase.ProcessQueue; -using System; using System.Collections.Generic; -using System.IO; using System.Linq; using System.Reflection; using System.Threading.Tasks; @@ -32,9 +29,7 @@ public partial class ThemePreviewControl : UserControl if (Design.IsDesignMode) { - using var ms1 = new MemoryStream(); - App.OpenAsset("img-coverart-prod-unavailable_80x80.jpg").CopyTo(ms1); - PictureStorage.SetDefaultImage(PictureSize._80x80, ms1.ToArray()); + MainVM.Configure_NonUI(); } QueuedBook = new ProcessBookViewModel(sampleEntries[0]) { Status = ProcessBookStatus.Queued }; @@ -56,32 +51,12 @@ public partial class ThemePreviewControl : UserControl private IEnumerable CreateMockBooks() { - var author = new Contributor("Some Author", "asin_contributor"); - var narrator = new Contributor("Some Narrator", "asin_narrator"); - - var book1 = new Book(new AudibleProductId("asin_book1"), "Some Book 1", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us"); - var book2 = new Book(new AudibleProductId("asin_book2"), "Some Book 2", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us"); - var book3 = new Book(new AudibleProductId("asin_book3"), "Some Book 3", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us"); - var book4 = new Book(new AudibleProductId("asin_book4"), "Some Book 4", "The Theming", "Demo Book Entry", 525600, ContentType.Product, [author], [narrator], "us"); - var seriesParent = new Book(new AudibleProductId("asin_series"), "Some Series", "", "Demo Series Entry", 0, ContentType.Parent, [author], [narrator], "us"); - var episode = new Book(new AudibleProductId("asin_episode"), "Some Episode", "Episode 1", "Demo Episode Entry", 56, ContentType.Episode, [author], [narrator], "us"); - - var series = new Series(new AudibleSeriesId(seriesParent.AudibleProductId), seriesParent.Title); - - seriesParent.UpsertSeries(series, ""); - episode.UpsertSeries(series, "1"); - - book1.UserDefinedItem.BookStatus = LiberatedStatus.Liberated; - book4.UserDefinedItem.BookStatus = LiberatedStatus.Error; - //Set the backing field directly to preserve LiberatedStatus.PartialDownload - typeof(UserDefinedItem).GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance).SetValue(book2.UserDefinedItem, LiberatedStatus.PartialDownload); - - yield return new LibraryBook(book1, System.DateTime.Now.AddDays(4), "someone@email.co"); - yield return new LibraryBook(book2, System.DateTime.Now.AddDays(3), "someone@email.co"); - yield return new LibraryBook(book3, System.DateTime.Now.AddDays(2), "someone@email.co") { AbsentFromLastScan = true }; - yield return new LibraryBook(book4, System.DateTime.Now.AddDays(1), "someone@email.co"); - yield return new LibraryBook(seriesParent, System.DateTime.Now, "someone@email.co"); - yield return new LibraryBook(episode, System.DateTime.Now, "someone@email.co"); + yield return MockLibraryBook.CreateBook(title: "Some Book 1", subtitle: "The Theming", dateAdded: System.DateTime.Now.AddDays(4)).WithBookStatus(LiberatedStatus.Liberated); + yield return MockLibraryBook.CreateBook(title: "Some Book 2", dateAdded: System.DateTime.Now.AddDays(3)).WithBookStatus(LiberatedStatus.PartialDownload); + yield return MockLibraryBook.CreateBook(title: "Some Book 3", dateAdded: System.DateTime.Now.AddDays(2), absetFromLastScan: true).WithPdfStatus(LiberatedStatus.NotLiberated); + yield return MockLibraryBook.CreateBook(title: "Some Book 4", dateAdded: System.DateTime.Now.AddDays(1)).WithBookStatus(LiberatedStatus.Error); + yield return MockLibraryBook.CreateBook(title: "Some Series", subtitle: "", contentType: ContentType.Parent).AddSeries("Some Series", 0); + yield return MockLibraryBook.CreateBook(title: "Some Episode", subtitle: "Episode 1", contentType: ContentType.Episode).AddSeries("Some Series", 1); } private class MockProcessable : FileLiberator.Processable diff --git a/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs index 8218882f..30ed6303 100644 --- a/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/AboutDialog.axaml.cs @@ -16,9 +16,6 @@ namespace LibationAvalonia.Dialogs private readonly AboutVM _viewModel; public AboutDialog() : base(saveAndRestorePosition:false) { - if (Design.IsDesignMode) - _ = Configuration.Instance.LibationFiles; - InitializeComponent(); DataContext = _viewModel = new AboutVM(); diff --git a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs index fe30623c..b0dabb41 100644 --- a/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/BookDetailsDialog.axaml.cs @@ -37,8 +37,17 @@ namespace LibationAvalonia.Dialogs if (Design.IsDesignMode) { - using var context = DbContexts.GetContext(); - LibraryBook = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"); + MainVM.Configure_NonUI(); + LibraryBook + = MockLibraryBook + .CreateBook() + .AddAuthor("Author 2") + .AddNarrator("Narrator 2") + .AddSeries("Series Name", 1) + .AddCategoryLadder("Parent", "Child Category") + .AddCategoryLadder("Parent", "Child Category 2") + .WithBookStatus(LiberatedStatus.NotLiberated) + .WithPdfStatus(LiberatedStatus.Liberated); } } public BookDetailsDialog(LibraryBook libraryBook) : this() diff --git a/Source/LibationAvalonia/Dialogs/DialogWindow.cs b/Source/LibationAvalonia/Dialogs/DialogWindow.cs index 016bbd3a..1bb07286 100644 --- a/Source/LibationAvalonia/Dialogs/DialogWindow.cs +++ b/Source/LibationAvalonia/Dialogs/DialogWindow.cs @@ -16,6 +16,7 @@ namespace LibationAvalonia.Dialogs public bool SaveAndRestorePosition { get; set; } public Control ControlToFocusOnShow { get; set; } protected override Type StyleKeyOverride => typeof(DialogWindow); + public DialogResult DialogResult { get; private set; } = DialogResult.None; public DialogWindow(bool saveAndRestorePosition = true) { @@ -27,7 +28,15 @@ namespace LibationAvalonia.Dialogs Closing += DialogWindow_Closing; if (Design.IsDesignMode) - RequestedThemeVariant = ThemeVariant.Dark; + { + var themeVariant = Configuration.CreateMockInstance().GetString(propertyName: nameof(ThemeVariant)); + RequestedThemeVariant = themeVariant switch + { + nameof(ThemeVariant.Dark) => ThemeVariant.Dark, + nameof(ThemeVariant.Light) => ThemeVariant.Light, + _ => ThemeVariant.Default, + }; + } } private void DialogWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e) @@ -66,6 +75,12 @@ namespace LibationAvalonia.Dialogs ControlToFocusOnShow?.Focus(); } + public void Close(DialogResult dialogResult) + { + DialogResult = dialogResult; + base.Close(dialogResult); + } + protected virtual void SaveAndClose() => Close(DialogResult.OK); protected virtual async Task SaveAndCloseAsync() => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(SaveAndClose); protected virtual void CancelAndClose() => Close(DialogResult.Cancel); diff --git a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs index 07725b3c..fe82746a 100644 --- a/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs +++ b/Source/LibationAvalonia/Dialogs/EditTemplateDialog.axaml.cs @@ -25,10 +25,11 @@ public partial class EditTemplateDialog : DialogWindow if (Design.IsDesignMode) { - _ = Configuration.Instance.LibationFiles; + var mockInstance = Configuration.CreateMockInstance(); + mockInstance.Books = Configuration.DefaultBooksDirectory; RequestedThemeVariant = ThemeVariant.Dark; - var editor = TemplateEditor.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate); - _viewModel = new(Configuration.Instance, editor); + var editor = TemplateEditor.CreateFilenameEditor(mockInstance.Books, mockInstance.FileTemplate); + _viewModel = new(mockInstance, editor); _viewModel.ResetTextBox(editor.EditingTemplate.TemplateText); Title = $"Edit {editor.TemplateName}"; DataContext = _viewModel; diff --git a/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml b/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml deleted file mode 100644 index 8d52c43c..00000000 --- a/Source/LibationAvalonia/Dialogs/Login/ApprovalNeededDialog.axaml +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - - - -