Compare commits

..

68 Commits

Author SHA1 Message Date
Robert McRackan
768afd8ecd incr ver 2023-08-04 11:57:15 -04:00
rmcrackan
32c3fa85ce Merge pull request #699 from Mbucari/master
Fix broken template editor (#698)
2023-08-04 11:55:30 -04:00
Mbucari
6986c8f018 Fix broken template editor (#698) 2023-08-04 09:26:51 -06:00
Robert McRackan
f69c2b1cfc incr ver 2023-08-02 21:55:19 -04:00
rmcrackan
b11675c36a Merge pull request #696 from Mbucari/master
Bug fixes
2023-08-02 21:52:57 -04:00
Mbucari
379c2ed62d Fix account nickname retrieval (#629) 2023-08-02 13:54:49 -06:00
Mbucari
7c8489b52f Fix walkthrough causing freeze (#695) 2023-08-02 13:15:58 -06:00
rmcrackan
c61a863edd Merge pull request #694 from Mbucari/master
Fix DPI scaling bug (#692)
2023-08-01 15:03:21 -04:00
Mbucari
1d54f32ef3 Fix DPI scaling bug (#692) 2023-08-01 11:55:23 -06:00
rmcrackan
fabe4afd94 Merge pull request #691 from Mbucari/master
Fix #686 and enable nullable in FileManager and LibationFileManager
2023-07-30 20:22:55 -04:00
MBucari
61efa3c0c1 Update dependencies 2023-07-30 14:00:12 -06:00
MBucari
fe70daf0bc Update avalonia to v11.0.1 2023-07-30 13:54:44 -06:00
MBucari
34033e7947 Enable Nullable 2023-07-30 13:31:57 -06:00
MBucari
e8c63e9a6e Fix UI control overlapping label (#686) 2023-07-30 13:15:43 -06:00
Robert McRackan
9315165f80 update dependencies 2023-07-21 21:21:48 -04:00
Robert McRackan
ce624399ba incr ver 2023-07-19 07:48:11 -04:00
rmcrackan
63e9700c4a Merge pull request #682 from Mbucari/master
Fix #680 and Add category ladders
2023-07-19 07:46:05 -04:00
Mbucari
914e574bf8 Improve GridView
- Remove LongDescription
- Description has the full description
- Better MyRating updating
2023-07-18 16:18:01 -06:00
Mbucari
b94f9bbc15 Fix grid update bug 2023-07-18 16:11:22 -06:00
Mbucari
4e34834c35 Fix category search indexing 2023-07-18 16:00:06 -06:00
Mbucari
3211b2dc85 Improved Category Ladders 2023-07-18 15:51:02 -06:00
Mbucari
ea6adeb58f Add category ladders 2023-07-17 16:50:45 -06:00
Mbucari
90eccbf2f6 Fix FilePathCache NRE (#680) 2023-07-17 08:55:55 -06:00
rmcrackan
668cd7dba8 Merge pull request #679 from Mbucari/master
Mp3 embedded cuesheet and raw metadata
2023-07-16 14:32:49 -04:00
MBucari
c08b2b575c UI Tweak 2023-07-16 10:57:17 -06:00
MBucari
07eaa48e10 Save raw json metadata 2023-07-16 10:54:05 -06:00
MBucari
3cf5fc1d99 Add mp3 embedded cuesheet (#677) 2023-07-15 10:44:31 -06:00
Robert McRackan
15ad753fa1 update dependencies 2023-07-14 20:58:26 -04:00
rmcrackan
75b984bdb2 Merge pull request #678 from Mbucari/master
Fix quick filter not being applied on startup
2023-07-14 20:53:57 -04:00
Mbucari
f586d1d59f Fix quick filter not being applied on startup 2023-07-13 11:00:05 -06:00
Mbucari
cb91a591f0 inc ver 2023-07-13 09:58:45 -06:00
Mbucari
0c0c556c6a Merge pull request #674 from Mbucari/master
Fix #673
2023-07-13 09:31:28 -06:00
MBucari
ff63b73c09 Fix #673 2023-07-13 09:30:02 -06:00
Mbucari
c1d56adbd2 Add groupbox title 2023-07-12 21:29:00 -06:00
rmcrackan
bcd99fd208 Merge pull request #670 from Mbucari/master
Add products grid scaling setting
2023-07-12 21:10:51 -04:00
Mbucari
d1df10d060 Add products grid scaling setting
- Add Grid Scaling Settings
- Add WinForms DPI migration to remove stored form sizes
- Add textbox clear button
2023-07-12 15:32:37 -06:00
Mbucari
1fa415628f Update ProductsGrid.cs 2023-07-10 11:39:33 -06:00
rmcrackan
a83fe9e532 Merge pull request #667 from Mbucari/master
Fix setting Panel2MinSize min width bug (#666)
2023-07-10 11:19:45 -04:00
Mbucari
f85462ffec Fix setting Panel2MinSize min width bug (#666) 2023-07-10 09:11:38 -06:00
Robert McRackan
156349c293 incr ver 2023-07-10 09:26:26 -04:00
rmcrackan
5976706e40 Merge pull request #664 from Mbucari/startup-2
New settings, context menu, and performance improvements
2023-07-10 09:25:13 -04:00
Mbucari
1e40180f0c Fix unit test 2023-07-09 16:42:08 -06:00
Mbucari
7d09728e6b Add Re-download context menu item 2023-07-09 16:26:58 -06:00
Mbucari
4899ef3007 Add new settings and settings dialog help tips
Add CombineNestedChapterTitles setting (#663)
Add SaveMetadataToFile setting
Add extended setting descriptions for select options
2023-07-09 16:07:13 -06:00
Mbucari
296c2b43eb Remove extra library load and move comments to Main 2023-07-09 10:10:00 -06:00
Mbucari
932472cb91 Add full context menu to call columns 2023-07-09 09:53:28 -06:00
Mbucari
1bf86b05ec Download high quality cover art 2023-07-09 09:35:40 -06:00
Mbucari
5d5e3a6671 improve startup time 2023-07-09 09:23:58 -06:00
Robert McRackan
9720a573c7 incr ver 2023-07-07 20:27:57 -04:00
rmcrackan
1cf01aa92a Merge pull request #660 from Mbucari/master
Crash logging to chardonnay
2023-07-07 20:27:09 -04:00
Mbucari
4df9e5abbf Add unhandled error handling and crash logging to chardonnay 2023-07-07 14:14:12 -06:00
Mbucari
9243aa47e7 Upgrade Avalonia to v11.0.0 2023-07-07 14:13:54 -06:00
rmcrackan
c69f41a2a6 Merge pull request #659 from Mbucari/master
Fix classic scaling on high dpi displays
2023-07-07 08:06:22 -04:00
Mbucari
27c74e52ca Fix classic scaling on high dpi displays 2023-07-06 21:34:29 -06:00
Robert McRackan
bfa7f5cca9 Bug fix #657 : Settings dialog size was recently changed. Save and Cancel buttons were pushed outside of the dialog's bounds 2023-07-06 09:27:52 -04:00
rmcrackan
22a3dcbc1f Merge pull request #656 from Mbucari/master
Fix query parsing tags with underscores (#655)
2023-07-06 09:16:20 -04:00
Mbucari
ec9d11cf52 Fix query parsing tags with underscores (#655) 2023-07-05 15:47:37 -06:00
Mbucari
fbc29dfb0a Set Variety correctly 2023-07-04 09:58:39 -06:00
Robert McRackan
03d30ff6af incr. ver. 2023-07-03 22:06:00 -04:00
rmcrackan
ecfe0dc033 Merge pull request #651 from Mbucari/master
Overhaul LibationCli and add Download Quality Option
2023-07-03 21:57:04 -04:00
Mbucari
f2d475a9b0 Add audiobookshelf tags for m4b and mp3
Fix the following tag fields so they are correctly parsed and displayed in audiobookshelf:
Language
Publisher
Series name and number
ASIN
2023-07-03 15:57:11 -06:00
Mbucari
86124fc609 Address comments 2023-07-03 10:01:25 -06:00
Mbucari
db2b10d2a4 Performance improvement 2023-07-03 07:04:29 -06:00
Mbucari
83402028fd Update Avalonia 2023-07-02 19:27:58 -06:00
Mbucari
423b5312f7 Add setting to choose downloaded audio quality ((#648) 2023-07-02 19:19:28 -06:00
Mbucari
3be7d8e825 Minor cli edits and fix potential deadlock 2023-07-02 18:29:36 -06:00
Mbucari
29803c6ba0 Overhaul LibationCli
Add version verb with option to check for upgrade
Add Search verb to search the library
Add export file type inference
Add more set-status options
Add console progress bar and ETA
Add processable option to liberate specific book IDs
Scan accounts by nickname or account ID
Improve startup performance for halp and on parsing error
More useful error messages
2023-07-02 15:01:10 -06:00
Mbucari
bb05847b25 Improve finding audio file by ID 2023-07-02 14:08:27 -06:00
195 changed files with 4330 additions and 1721 deletions

View File

@@ -75,6 +75,7 @@ jobs:
LibationCli/LibationCli.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="1.0.4" />
<PackageReference Include="AAXClean.Codecs" Version="1.1.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -51,16 +51,37 @@ namespace AaxDecrypter
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("&#169;", "©");
//Add audiobook shelf tags
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
const string tagDomain = "com.pilabor.tone";
AaxFile.AppleTags.Title = DownloadOptions.Title;
if (DownloadOptions.Subtitle is string subtitle)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
if (DownloadOptions.Publisher is string publisher)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
if (DownloadOptions.Language is string language)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
if (DownloadOptions.AudibleProductId is string asin)
{
AaxFile.AppleTags.Asin = asin;
AaxFile.AppleTags.AppleListBox.EditOrAddTag("asin", asin);
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
}
if (DownloadOptions.SeriesName is string series)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
if (DownloadOptions.SeriesNumber is float part)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
}
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
@@ -70,5 +91,7 @@ namespace AaxDecrypter
return !IsCanceled;
}
protected virtual void OnInitialized() { }
}
}

View File

@@ -21,6 +21,18 @@ namespace AaxDecrypter
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
chapters: null);
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489

View File

@@ -3,7 +3,6 @@ using AAXClean.Codecs;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
@@ -26,6 +25,18 @@ namespace AaxDecrypter
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
DownloadOptions.ChapterInfo);
}
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
FileUtility.SaferDelete(OutputFileName);

View File

@@ -118,11 +118,7 @@ namespace AaxDecrypter
public abstract Task CancelAsync();
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
if (coverArt is not null)
OnRetrievedCoverArt(coverArt);
}
public virtual void SetCoverArt(byte[] coverArt) { }
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);

View File

@@ -21,7 +21,14 @@ namespace AaxDecrypter
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
NAudio.Lame.LameConfig LameConfig { get; }
string AudibleProductId { get; }
string Title { get; }
string Subtitle { get; }
string Publisher { get; }
string Language { get; }
string SeriesName { get; }
float? SeriesNumber { get; }
NAudio.Lame.LameConfig LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }

View File

@@ -1,4 +1,5 @@
using AAXClean;
using AAXClean.Codecs;
using NAudio.Lame;
using System;
@@ -6,7 +7,13 @@ namespace AaxDecrypter
{
public static class MpegUtil
{
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
private const string TagDomain = "com.pilabor.tone";
public static void ConfigureLameOptions(
Mp4File mp4File,
LameConfig lameConfig,
bool downsample,
bool matchSourceBitrate,
ChapterInfo chapters)
{
double bitrateMultiple = 1;
@@ -36,6 +43,27 @@ namespace AaxDecrypter
else if (lameConfig.VBR == VBRMode.ABR)
lameConfig.ABRRateKbps = kbps;
}
//Setup metadata tags
lameConfig.ID3 = mp4File.AppleTags.ToIDTags();
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
lameConfig.ID3.Subtitle = subtitle;
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
lameConfig.ID3.UserDefinedText.Add("SERIES", series);
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
lameConfig.ID3.UserDefinedText.Add("PART", part);
if (chapters?.Count > 0)
{
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
}
}
}
}

View File

@@ -14,7 +14,6 @@ namespace AaxDecrypter
public class NetworkFileStream : Stream, IUpdatable
{
public event EventHandler Updated;
public event EventHandler DownloadCompleted;
#region Public Properties
@@ -41,6 +40,9 @@ namespace AaxDecrypter
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
[JsonIgnore]
public Task DownloadTask { get; private set; }
private long _speedLimit = 0;
/// <summary>bytes per second</summary>
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
@@ -52,7 +54,6 @@ namespace AaxDecrypter
private FileStream _readFile { get; }
private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle _downloadedPiece { get; set; }
private Task _backgroundDownloadTask { get; set; }
#endregion
@@ -126,9 +127,11 @@ namespace AaxDecrypter
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
throw new ArgumentException($"New uri to the same file must have the same file name.");
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (_backgroundDownloadTask is not null)
if (DownloadTask is not null)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
@@ -141,7 +144,7 @@ namespace AaxDecrypter
{
if (ContentLength != 0 && WritePosition == ContentLength)
{
_backgroundDownloadTask = Task.CompletedTask;
DownloadTask = Task.CompletedTask;
return;
}
@@ -167,7 +170,8 @@ namespace AaxDecrypter
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
_backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
}
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
@@ -234,7 +238,6 @@ namespace AaxDecrypter
_writeFile.Close();
_downloadedPiece.Set();
OnUpdate();
DownloadCompleted?.Invoke(this, null);
}
}
@@ -256,7 +259,7 @@ namespace AaxDecrypter
{
get
{
if (_backgroundDownloadTask is null)
if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
return ContentLength;
}
@@ -280,7 +283,7 @@ namespace AaxDecrypter
public override int Read(byte[] buffer, int offset, int count)
{
if (_backgroundDownloadTask is null)
if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
var toRead = Math.Min(count, Length - Position);
@@ -306,7 +309,7 @@ namespace AaxDecrypter
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
&& _backgroundDownloadTask?.IsCompleted is false
&& DownloadTask?.IsCompleted is false
&& !IsCancelled)
{
_downloadedPiece.WaitOne(50);
@@ -326,7 +329,7 @@ namespace AaxDecrypter
if (disposing && !disposed)
{
_cancellationSource.Cancel();
_backgroundDownloadTask?.GetAwaiter().GetResult();
DownloadTask?.GetAwaiter().GetResult();
_downloadedPiece?.Dispose();
_cancellationSource?.Dispose();
_readFile.Dispose();

View File

@@ -26,11 +26,7 @@ namespace AaxDecrypter
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
TaskCompletionSource completionSource = new();
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
await completionSource.Task;
await InputFileStream.DownloadTask;
if (IsCanceled)
return false;

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>10.4.3.1</Version>
<Version>10.6.5.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="6.0.0" />
<PackageReference Include="Octokit" Version="7.1.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="1.0.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -43,21 +43,6 @@ namespace AppScaffolding
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static Variety Variety { get; private set; }
public static void SetReleaseIdentifier(Variety varietyType)
{
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
if (Enum.IsDefined(releaseID))
ReleaseIdentifier = releaseID;
else
{
ReleaseIdentifier = ReleaseIdentifier.None;
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
}
}
// AppScaffolding
private static Assembly _executingAssembly;
private static Assembly ExecutingAssembly
@@ -105,8 +90,14 @@ namespace AppScaffolding
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Configuration config)
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
{
Variety = Enum.IsDefined(variety) ? variety : Variety.None;
var releaseID = (ReleaseIdentifier)((int)variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
ReleaseIdentifier = Enum.IsDefined(releaseID) ? releaseID : ReleaseIdentifier.None;
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);

View File

@@ -6,7 +6,7 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="NPOI" Version="2.6.0" />
<PackageReference Include="NPOI" Version="2.6.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -141,7 +141,7 @@ namespace ApplicationServices
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="8.4.1.1" />
<PackageReference Include="AudibleApi" Version="8.4.3.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
namespace DataLayer.Configurations
{
internal class BookCategoryConfig : IEntityTypeConfiguration<BookCategory>
{
public void Configure(EntityTypeBuilder<BookCategory> entity)
{
entity.HasKey(bc => new { bc.BookId, bc.CategoryLadderId });
entity.HasIndex(bc => bc.BookId);
entity.HasIndex(bc => bc.CategoryLadderId);
entity
.HasOne(bc => bc.Book)
.WithMany(b => b.CategoriesLink)
.HasForeignKey(bc => bc.BookId);
entity
.HasOne(bc => bc.CategoryLadder)
.WithMany(c => c.BooksLink)
.HasForeignKey(bc => bc.CategoryLadderId);
}
}
}

View File

@@ -21,10 +21,7 @@ namespace DataLayer.Configurations
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
entity.Ignore(nameof(Book.TitleWithSubtitle));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
entity.Ignore(b => b.Categories);
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
@@ -58,23 +55,6 @@ namespace DataLayer.Configurations
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);
});
entity
.Metadata
.FindNavigation(nameof(Book.ContributorsLink))
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
.SetPropertyAccessMode(PropertyAccessMode.Field);
entity
.Metadata
.FindNavigation(nameof(Book.SeriesLink))
// PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
.SetPropertyAccessMode(PropertyAccessMode.Field);
entity
.HasOne(b => b.Category)
.WithMany()
.HasForeignKey(b => b.CategoryId);
}
}
}

View File

@@ -10,8 +10,11 @@ namespace DataLayer.Configurations
entity.HasKey(c => c.CategoryId);
entity.HasIndex(c => c.AudibleCategoryId);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
entity.HasData(Category.GetEmpty());
entity.Ignore(c => c.CategoryLadders);
entity
.HasMany(e => e._categoryLadders)
.WithMany(e => e._categories);
}
}
}

View File

@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
namespace DataLayer.Configurations
{
internal class CategoryLadderConfig : IEntityTypeConfiguration<CategoryLadder>
{
public void Configure(EntityTypeBuilder<CategoryLadder> entity)
{
entity.HasKey(cl => cl.CategoryLadderId);
entity.Ignore(cl => cl.Categories);
entity
.HasMany(cl => cl._categories)
.WithMany(c => c._categoryLadders);
entity
.Metadata
.FindNavigation(nameof(CategoryLadder.BooksLink))
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
}

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.5">
<PackageReference Include="Dinah.Core" Version="7.3.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.3.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.9">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -13,7 +13,11 @@ namespace DataLayer
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
}
AAX_22_32 = LC_32_22050_stereo,
AAX_22_64 = LC_64_22050_stereo,
AAX_44_64 = LC_64_44100_stereo,
AAX_44_128 = LC_128_44100_stereo
}
public class AudioFormat : IComparable<AudioFormat>, IComparable
{

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ namespace DataLayer
public string Subtitle { get; private set; }
private string _titleWithSubtitle;
public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}";
public string Description { get; private set; }
public string Description { get; private set; }
public int LengthInMinutes { get; private set; }
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
@@ -55,10 +56,6 @@ namespace DataLayer
public DateTime? DatePublished { get; private set; }
public string Language { get; private set; }
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
public Category Category { get; private set; }
// is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; }
@@ -77,9 +74,8 @@ namespace DataLayer
string description,
int lengthInMinutes,
ContentType contentType,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
Category category,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
string localeName)
{
// validate
@@ -87,7 +83,7 @@ namespace DataLayer
var productId = audibleProductId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
// assign as soon as possible. stuff below relies on this
// assign as soon as possible. stuff below relies on this
AudibleProductId = productId;
Locale = localeName;
@@ -95,43 +91,34 @@ namespace DataLayer
// non-ef-ctor init.s
UserDefinedItem = new UserDefinedItem(this);
_contributorsLink = new HashSet<BookContributor>();
ContributorsLink = new HashSet<BookContributor>();
CategoriesLink = new HashSet<BookCategory>();
_seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>();
Category = category;
// simple assigns
UpdateTitle(title, subtitle);
Description = description?.Trim() ?? "";
Description = description?.Trim() ?? "";
LengthInMinutes = lengthInMinutes;
ContentType = contentType;
// assigns with biz logic
ReplaceAuthors(authors);
ReplaceNarrators(narrators);
}
public void UpdateTitle(string title, string subtitle)
}
public void UpdateTitle(string title, string subtitle)
{
Title = title?.Trim() ?? "";
Subtitle = subtitle?.Trim() ?? "";
_titleWithSubtitle = null;
}
}
#region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded
private HashSet<BookContributor> _contributorsLink;
// i'd like this to be internal but migration throws this exception when i try:
// Value cannot be null.
// Parameter name: property
public IEnumerable<BookContributor> ContributorsLink
=> _contributorsLink?
.OrderBy(bc => bc.Order)
.ToList();
#region contributors, authors, narrators
internal HashSet<BookContributor> ContributorsLink { get; private set; }
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public IEnumerable<Contributor> Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList();
public IEnumerable<Contributor> Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
=> replaceContributors(authors, Role.Author, context);
@@ -144,47 +131,70 @@ namespace DataLayer
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
// the edge cases of doing local-loaded vs remote-only got weird. just load it
if (_contributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load();
if (ContributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load();
var isIdentical
= ContributorsLink
.ByRole(role)
.Select(c => c.Contributor)
.SequenceEqual(newContributors);
var roleContributions = getContributions(role);
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
if (isIdentical)
return;
_contributorsLink.RemoveWhere(bc => bc.Role == role);
ContributorsLink.RemoveWhere(bc => bc.Role == role);
addNewContributors(newContributors, role);
}
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
{
byte order = 0;
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
var newContributions = new HashSet<BookContributor>(newContributionsEnum);
_contributorsLink.UnionWith(newContributions);
ContributorsLink.UnionWith(newContributions);
}
private List<BookContributor> getContributions(Role role)
=> ContributorsLink
.Where(a => a.Role == role)
.OrderBy(a => a.Order)
.ToList();
#endregion
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
}
#region categories
internal HashSet<BookCategory> CategoriesLink { get; private set; }
private ReadOnlyCollection<BookCategory> _categoriesReadOnly;
public ReadOnlyCollection<BookCategory> Categories
{
get
{
if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true)
_categoriesReadOnly = CategoriesLink.ToList().AsReadOnly();
return _categoriesReadOnly;
}
}
public void SetCategoryLadders(IEnumerable<CategoryLadder> ladders)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
ArgumentValidator.EnsureNotNull(ladders, nameof(ladders));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
//Replace all existing category ladders.
//Some books make have duplocate ladders
CategoriesLink.Clear();
CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l)));
}
#endregion
#region series
private HashSet<SeriesBook> _seriesLink;
#region series
private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
public void UpsertSeries(Series series, string order, DbContext context = null)
@@ -234,15 +244,6 @@ namespace DataLayer
Language = language?.FirstCharToUpper() ?? Language;
}
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category is null)
getEntry(context).Reference(s => s.Category).Load();
Category = category;
}
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
}
}

View File

@@ -0,0 +1,20 @@
using Dinah.Core;
namespace DataLayer
{
public class BookCategory
{
internal int BookId { get; private set; }
internal int CategoryLadderId { get; private set; }
public Book Book { get; private set; }
public CategoryLadder CategoryLadder { get; private set; }
private BookCategory() { }
internal BookCategory(Book book, CategoryLadder categoriesList)
{
Book = ArgumentValidator.EnsureNotNull(book, nameof(book));
CategoryLadder = ArgumentValidator.EnsureNotNull(categoriesList, nameof(categoriesList));
}
}
}

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
#nullable enable
namespace DataLayer
{
public class AudibleCategoryId
@@ -15,20 +15,29 @@ namespace DataLayer
Id = id;
}
}
public class Category
{
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }
public string? AudibleCategoryId { get; private set; }
public string Name { get; private set; }
public Category ParentCategory { get; private set; }
public string? Name { get; internal set; }
private Category() { }
internal List<CategoryLadder> _categoryLadders = new();
private ReadOnlyCollection<CategoryLadder>? _categoryLaddersReadOnly;
public ReadOnlyCollection<CategoryLadder> CategoryLadders
{
get
{
if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true)
_categoryLaddersReadOnly = _categoryLadders.AsReadOnly();
return _categoryLaddersReadOnly;
}
}
private Category() { }
/// <summary>special id class b/c it's too easy to get string order mixed up</summary>
public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null)
public Category(AudibleCategoryId audibleSeriesId, string name)
{
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
var id = audibleSeriesId.Id;
@@ -37,15 +46,6 @@ namespace DataLayer
AudibleCategoryId = id;
Name = name;
UpdateParentCategory(parentCategory);
}
public void UpdateParentCategory(Category parentCategory)
{
// don't overwrite with null but not an error
if (parentCategory is not null)
ParentCategory = parentCategory;
}
public override string ToString() => $"[{AudibleCategoryId}] {Name}";

View File

@@ -0,0 +1,58 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
#nullable enable
namespace DataLayer
{
public class CategoryLadder : IEquatable<CategoryLadder>
{
internal int CategoryLadderId { get; private set; }
internal List<Category> _categories;
private ReadOnlyCollection<Category>? _categoriesReadOnly;
public ReadOnlyCollection<Category> Categories
{
get
{
if (_categoriesReadOnly?.SequenceEqual(_categories) is not true)
_categoriesReadOnly = _categories.AsReadOnly();
return _categoriesReadOnly;
}
}
private HashSet<BookCategory>? _booksLink;
public IEnumerable<BookCategory>? BooksLink => _booksLink?.ToList();
private CategoryLadder() { _categories = new(); }
public CategoryLadder(List<Category> categories)
{
ArgumentValidator.EnsureNotNull(categories, nameof(categories));
ArgumentValidator.EnsureGreaterThan(categories.Count, nameof(categories), 0);
_booksLink = new HashSet<BookCategory>();
_categories = categories;
}
public override int GetHashCode()
{
HashCode hashCode = default;
foreach (var category in _categories)
hashCode.Add(category.AudibleCategoryId);
return hashCode.ToHashCode();
}
public bool Equals(CategoryLadder? other)
=> other?._categories is not null
&& Equals(other._categories.Select(c => c.AudibleCategoryId));
public bool Equals(IEnumerable<string?>? categoryIds)
=> categoryIds is not null
&& _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds);
public override bool Equals(object? obj)
=> obj is CategoryLadder other && Equals(other);
public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name));
}
}

View File

@@ -7,8 +7,13 @@ using System.Threading.Tasks;
namespace DataLayer
{
public static class EntityExtensions
{
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
{
public static IEnumerable<BookContributor> ByRole(this IEnumerable<BookContributor> contributors, Role role)
=> contributors
.Where(a => a.Role == role)
.OrderBy(a => a.Order);
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
@@ -46,14 +51,31 @@ namespace DataLayer
? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name;
}
public static string[] CategoriesNames(this Book book)
=> book.Category is null ? new string[0]
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
: new[] { book.Category.ParentCategory.Name, book.Category.Name };
public static string[] CategoriesIds(this Book book)
=> book.Category is null ? null
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId }
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId };
public static string[] LowestCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
.Where(c => c is not null)
.Distinct()
.ToArray();
public static string[] AllCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public static string[] AllCategoryIds(this Book book)
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
{

View File

@@ -23,6 +23,7 @@ namespace DataLayer
public DbSet<Contributor> Contributors { get; private set; }
public DbSet<Series> Series { get; private set; }
public DbSet<Category> Categories { get; private set; }
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
public static LibationContext Create(string connectionString)
{
@@ -39,13 +40,15 @@ namespace DataLayer
{
base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new BookConfig());
modelBuilder.ApplyConfiguration(new BookConfig());
modelBuilder.ApplyConfiguration(new ContributorConfig());
modelBuilder.ApplyConfiguration(new BookContributorConfig());
modelBuilder.ApplyConfiguration(new LibraryBookConfig());
modelBuilder.ApplyConfiguration(new SeriesConfig());
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig());
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types

View File

@@ -0,0 +1,465 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20230718214617_AddCategoryLadder")]
partial class AddCategoryLadder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,174 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddCategoryLadder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Books_Categories_CategoryId",
table: "Books");
migrationBuilder.DropForeignKey(
name: "FK_Categories_Categories_ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropIndex(
name: "IX_Categories_ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropIndex(
name: "IX_Books_CategoryId",
table: "Books");
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "CategoryId",
keyValue: -1);
migrationBuilder.DropColumn(
name: "ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "Books");
migrationBuilder.CreateTable(
name: "CategoryLadders",
columns: table => new
{
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
});
migrationBuilder.CreateTable(
name: "BookCategory",
columns: table => new
{
BookId = table.Column<int>(type: "INTEGER", nullable: false),
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
table.ForeignKey(
name: "FK_BookCategory_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
column: x => x.CategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CategoryCategoryLadder",
columns: table => new
{
_categoriesCategoryId = table.Column<int>(type: "INTEGER", nullable: false),
_categoryLaddersCategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
table.ForeignKey(
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
column: x => x._categoriesCategoryId,
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCategoryLadderId",
column: x => x._categoryLaddersCategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BookCategory_BookId",
table: "BookCategory",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookCategory_CategoryLadderId",
table: "BookCategory",
column: "CategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
table: "CategoryCategoryLadder",
column: "_categoryLaddersCategoryLadderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookCategory");
migrationBuilder.DropTable(
name: "CategoryCategoryLadder");
migrationBuilder.DropTable(
name: "CategoryLadders");
migrationBuilder.AddColumn<int>(
name: "ParentCategoryCategoryId",
table: "Categories",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "Books",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
values: new object[] { -1, "", "", null });
migrationBuilder.CreateIndex(
name: "IX_Categories_ParentCategoryCategoryId",
table: "Categories",
column: "ParentCategoryCategoryId");
migrationBuilder.CreateIndex(
name: "IX_Books_CategoryId",
table: "Books",
column: "CategoryId");
migrationBuilder.AddForeignKey(
name: "FK_Books_Categories_CategoryId",
table: "Books",
column: "CategoryId",
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Categories_Categories_ParentCategoryCategoryId",
table: "Categories",
column: "ParentCategoryCategoryId",
principalTable: "Categories",
principalColumn: "CategoryId");
}
}
}

View File

@@ -17,6 +17,21 @@ namespace DataLayer.Migrations
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
@@ -26,9 +41,6 @@ namespace DataLayer.Migrations
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
@@ -69,11 +81,26 @@ namespace DataLayer.Migrations
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
@@ -109,24 +136,22 @@ namespace DataLayer.Migrations
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
});
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
@@ -216,14 +241,23 @@ namespace DataLayer.Migrations
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", "Category")
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("CategoryId")
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
@@ -324,8 +358,6 @@ namespace DataLayer.Migrations
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
@@ -333,6 +365,25 @@ namespace DataLayer.Migrations
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
@@ -352,15 +403,6 @@ namespace DataLayer.Migrations
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
@@ -393,11 +435,18 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");

View File

@@ -34,7 +34,7 @@ namespace DataLayer
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
.Include(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
public static bool IsProduct(this Book book)
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;

View File

@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace DataLayer
{
public static class CategoryQueries
{
public static IQueryable<CategoryLadder> GetCategoryLadders(this LibationContext context)
=> context.CategoryLadders.Include(c => c._categories);
}
}

View File

@@ -55,7 +55,7 @@ namespace DataLayer
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
.Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);

View File

@@ -99,20 +99,6 @@ namespace DtoImporterService
.Select(n => contributorImporter.Cache[n.Name])
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is also possible
// CATEGORY HACK: only use the 1st 2 categories
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var lastCategory
= item.Categories.Length == 0 ? ""
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
// 2+
: item.Categories[1].CategoryId;
var category = categoryImporter.Cache[lastCategory];
Book book;
try
{
@@ -125,7 +111,6 @@ namespace DtoImporterService
contentType,
authors,
narrators,
category,
importItem.LocaleName)
).Entity;
Cache.Add(book.AudibleProductId, book);
@@ -140,7 +125,6 @@ namespace DtoImporterService
contentType,
QtyAuthors = authors?.Count,
QtyNarrators = narrators?.Count,
Category = category?.Name,
importItem.LocaleName
});
throw;
@@ -201,6 +185,19 @@ namespace DtoImporterService
book.UpsertSeries(series, seriesEntry.Sequence);
}
}
if (item.CategoryLadders is not null)
{
var ladders = new List<DataLayer.CategoryLadder>();
foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0))
{
var categoryIds = ladder.Select(l => l.CategoryId).ToList();
ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)));
}
//Set all ladders at once so ladders that have been
//removed by audible can be removed from the DB
book.SetCategoryLadders(ladders);
}
}
private static DataLayer.ContentType GetContentType(Item item)

View File

@@ -12,76 +12,84 @@ namespace DtoImporterService
{
protected override IValidator Validator => new CategoryValidator();
public Dictionary<string, Category> Cache { get; private set; } = new();
private Dictionary<string, Category> CategoryCache { get; set; } = new();
public HashSet<DataLayer.CategoryLadder> LadderCache { get; private set; } = new();
public CategoryImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// get distinct
var categoryIds = importItems
.Select(i => i.DtoItem)
.GetCategoriesDistinct()
.Select(c => c.CategoryId)
.Distinct()
.ToList();
// load db existing => .Local
loadLocal_categories(categoryIds);
loadLocal_categories();
// upsert
var categoryPairs = importItems
.Select(i => i.DtoItem)
.GetCategoryPairsDistinct()
var categoryLadders = importItems
.SelectMany(i => i.DtoItem.CategoryLadders)
.Select(cl => cl.Ladder)
.Where(l => l?.Length > 0)
.ToList();
var qtyNew = upsertCategories(categoryPairs);
var qtyNew = upsertCategories(categoryLadders);
return qtyNew;
}
private void loadLocal_categories(List<string> categoryIds)
private void loadLocal_categories()
{
// must include default/empty/missing
categoryIds.Add(Category.GetEmpty().AudibleCategoryId);
// load existing => local
Cache = DbContext.Categories
.Where(c => categoryIds.Contains(c.AudibleCategoryId))
.ToDictionarySafe(c => c.AudibleCategoryId);
LadderCache = DbContext.GetCategoryLadders().ToHashSet();
CategoryCache = LadderCache.SelectMany(cl => cl.Categories).ToDictionarySafe(c => c.AudibleCategoryId);
}
// only use after loading contributors => local
private int upsertCategories(List<Ladder[]> categoryPairs)
private int upsertCategories(List<Ladder[]> ladders)
{
var qtyNew = 0;
foreach (var pair in categoryPairs)
foreach (var ladder in ladders)
{
for (var i = 0; i < pair.Length; i++)
var categories = new List<Category>(ladder.Length);
for (var i = 0; i < ladder.Length; i++)
{
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
if (i > 1)
break;
var id = ladder[i].CategoryId;
var name = ladder[i].CategoryName;
var id = pair[i].CategoryId;
var name = pair[i].CategoryName;
Category parentCategory = null;
if (i == 1)
Cache.TryGetValue(pair[0].CategoryId, out parentCategory);
if (!Cache.TryGetValue(id, out var category))
if (!CategoryCache.TryGetValue(id, out var category))
{
category = addCategory(id, name);
qtyNew++;
}
category.UpdateParentCategory(parentCategory);
categories.Add(category);
}
var categoryLadder = new DataLayer.CategoryLadder(categories);
if (!LadderCache.Contains(categoryLadder))
{
addCategoryLadder(categoryLadder);
qtyNew++;
}
}
return qtyNew;
}
private DataLayer.CategoryLadder addCategoryLadder(DataLayer.CategoryLadder categoryList)
{
try
{
var entityEntry = DbContext.CategoryLadders.Add(categoryList);
var entity = entityEntry.Entity;
LadderCache.Add(entity);
return entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList);
throw;
}
}
private Category addCategory(string id, string name)
{
try
@@ -91,7 +99,7 @@ namespace DtoImporterService
var entityEntry = DbContext.Categories.Add(category);
var entity = entityEntry.Entity;
Cache.Add(entity.AudibleCategoryId, entity);
CategoryCache.Add(entity.AudibleCategoryId, entity);
return entity;
}
catch (Exception ex)

View File

@@ -51,18 +51,19 @@ namespace FileLiberator
var config = Configuration.Instance;
var lameConfig = GetLameOptions(config);
var chapters = m4bBook.GetChaptersFromMetadata();
//Finishing configuring lame encoder.
AaxDecrypter.MpegUtil.ConfigureLameOptions(
m4bBook,
lameConfig,
config.LameDownsampleMono,
config.LameMatchSourceBR);
config.LameMatchSourceBR,
chapters);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
try
{
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
await Mp4Operation;

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
using AudibleApi.Common;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -121,14 +122,15 @@ namespace FileLiberator
downloadValidation(libraryBook);
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
if (contentLic.DrmType != DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
else
{
@@ -138,7 +140,7 @@ namespace FileLiberator
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
abDownloader = converter;
}
@@ -151,16 +153,29 @@ namespace FileLiberator
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
if (success && config.SaveMetadataToFile)
{
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile);
}
return success;
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
{
//If DrmType != Adrm the delivered file is an unencrypted mp3.
var outputFormat
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
? OutputFormat.Mp3
: OutputFormat.M4b;
@@ -169,7 +184,10 @@ namespace FileLiberator
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
//Set the requested AudioFormat for use in file naming templates
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat);
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
{
AudibleKey = contentLic?.Voucher?.Key,
AudibleIV = contentLic?.Voucher?.Iv,
@@ -179,7 +197,11 @@ namespace FileLiberator
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
};
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs)
.ToList();
if (config.MergeOpeningAndEndCredits)
combineCredits(chapters);
@@ -276,14 +298,19 @@ namespace FileLiberator
*/
public static List<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
{
List<AudibleApi.Common.Chapter> chaps = new();
List<Chapter> chaps = new();
foreach (var c in chapters)
{
if (c.Chapters is null)
chaps.Add(c);
else if (titleConcat is null)
{
chaps.Add(c);
chaps.AddRange(flattenChapters(c.Chapters));
}
else
{
if (c.LengthMs < 10000)
@@ -301,13 +328,12 @@ namespace FileLiberator
child.Title = $"{c.Title}{titleConcat}{child.Title}";
chaps.AddRange(children);
c.Chapters = null;
}
}
return chaps;
}
public static void combineCredits(IList<AudibleApi.Common.Chapter> chapters)
public static void combineCredits(IList<Chapter> chapters)
{
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
{
@@ -347,11 +373,15 @@ namespace FileLiberator
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
{
if (Configuration.Instance.AllowLibationFixup)
{
e = OnRequestCoverArt();
abDownloader.SetCoverArt(e);
}
if (e is not null)
OnCoverImageDiscovered(e);
else if (Configuration.Instance.AllowLibationFixup)
abDownloader.SetCoverArt(OnRequestCoverArt());
}
}
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>

View File

@@ -21,6 +21,13 @@ namespace FileLiberator
public TimeSpan RuntimeLength { get; init; }
public OutputFormat OutputFormat { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public string Title => LibraryBook.Book.Title;
public string Subtitle => LibraryBook.Book.Subtitle;
public string Publisher => LibraryBook.Book.Publisher;
public string Language => LibraryBook.Book.Language;
public string AudibleProductId => LibraryBookDto.AudibleProductId;
public string SeriesName => LibraryBookDto.SeriesName;
public float? SeriesNumber => LibraryBookDto.SeriesNumber;
public NAudio.Lame.LameConfig LameConfig { get; init; }
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;

View File

@@ -30,7 +30,7 @@ namespace FileLiberator
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var nickname
= persister.AccountsSettings.Accounts
.FirstOrDefault(a => a.AccountId == libraryBook.Account)
.FirstOrDefault(a => a.AccountId == libraryBook.Account && a.Locale.Name == libraryBook.Book.Locale)
?.AccountName;
return new()

View File

@@ -5,6 +5,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace FileManager
{
/// <summary>
@@ -16,9 +17,9 @@ namespace FileManager
public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; }
private FileSystemWatcher fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
private Task backgroundScanner { get; set; }
private FileSystemWatcher? fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs>? directoryChangesEvents { get; set; }
private Task? backgroundScanner { get; set; }
private object fsCacheLocker { get; } = new();
private List<LongPath> fsCache { get; } = new();
@@ -32,7 +33,7 @@ namespace FileManager
Init();
}
public LongPath FindFile(System.Text.RegularExpressions.Regex regex)
public LongPath? FindFile(System.Text.RegularExpressions.Regex regex)
{
lock (fsCacheLocker)
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
@@ -105,13 +106,13 @@ namespace FileManager
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
{
directoryChangesEvents.Add(e);
directoryChangesEvents?.Add(e);
}
#region Background Thread
private void BackgroundScanner()
{
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
while (directoryChangesEvents?.TryTake(out var change, -1) is true)
{
lock (fsCacheLocker)
UpdateLocalCache(change);

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Dinah.Core" Version="7.3.0.1" />
<PackageReference Include="Polly" Version="7.2.4" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
@@ -7,20 +8,20 @@ using Dinah.Core;
using Polly;
using Polly.Retry;
#nullable enable
namespace FileManager
{
public static class FileUtility
{
/// <summary>
/// "txt" => ".txt"
/// <br />".txt" => ".txt"
/// <br />null or whitespace => ""
/// </summary>
public static string GetStandardizedExtension(string extension)
[return: NotNull]
public static string GetStandardizedExtension(string? extension)
=> string.IsNullOrWhiteSpace(extension)
? (extension ?? "")?.Trim()
? string.Empty
: '.' + extension.Trim().Trim('.');
/// <summary>
@@ -48,18 +49,18 @@ namespace FileManager
/// <br/>- ensure uniqueness
/// <br/>- enforce max file length
/// </summary>
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false)
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string? fileExtension, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
fileExtension = GetStandardizedExtension(fileExtension);
// remove invalid chars
path = GetSafePath(path, replacements);
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path);
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var dir = Path.GetDirectoryName(path)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var fileName = Path.GetFileName(path);
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
@@ -84,6 +85,7 @@ namespace FileManager
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
var pathNoPrefix = path.PathWithoutPrefix;
@@ -159,7 +161,7 @@ namespace FileManager
LongPath source,
LongPath destination,
ReplacementCharacters replacements,
string extension = null,
string? extension = null,
bool overwrite = false)
{
extension ??= Path.GetExtension(source);
@@ -213,6 +215,9 @@ namespace FileManager
SaferDelete(destination);
var dir = Path.GetDirectoryName(destination);
if (dir is null)
throw new DirectoryNotFoundException();
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir);

View File

@@ -8,9 +8,10 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
#nullable enable
namespace FileManager
{
public sealed class LogArchiver : IAsyncDisposable
public sealed class LogArchiver : IAsyncDisposable, IDisposable
{
public Encoding Encoding { get; set; }
public string FileName { get; }
@@ -39,28 +40,28 @@ namespace FileManager
e.Delete();
}
public async Task AddFileAsync(string name, JObject contents, string comment = null)
public async Task AddFileAsync(string name, JObject contents, string? comment = null)
{
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment);
}
public async Task AddFileAsync(string name, string contents, string comment = null)
public async Task AddFileAsync(string name, string contents, string? comment = null)
{
ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents), comment);
}
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string comment = null)
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string? comment = null)
{
ArgumentValidator.EnsureNotNull(name, nameof(name));
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
return Task.Run(() => AddfileInternal(name, contents.Span, comment));
return Task.Run(() => AddFileInternal(name, contents.Span, comment));
}
private readonly object lockObj = new();
private void AddfileInternal(string name, ReadOnlySpan<byte> contents, string comment)
private void AddFileInternal(string name, ReadOnlySpan<byte> contents, string? comment)
{
lock (lockObj)
{
@@ -73,5 +74,7 @@ namespace FileManager
}
public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose);
public void Dispose() => archive.Dispose();
}
}

View File

@@ -1,9 +1,11 @@
using Newtonsoft.Json;
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
#nullable enable
namespace FileManager
{
public class LongPath
@@ -15,9 +17,9 @@ namespace FileManager
public static readonly int MaxPathLength;
private const int WIN_MAX_PATH = 260;
private const string WIN_LONG_PATH_PREFIX = @"\\?\";
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
internal static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
internal static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
internal static readonly bool IsOSX = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public string Path { get; }
@@ -60,7 +62,8 @@ namespace FileManager
=> IsWindows ? filename.Length
: Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path)
[return: NotNullIfNotNull(nameof(path))]
public static implicit operator LongPath?(string? path)
{
if (path is null) return null;
@@ -93,7 +96,8 @@ namespace FileManager
}
}
public static implicit operator string(LongPath path) => path?.Path;
[return: NotNullIfNotNull(nameof(path))]
public static implicit operator string?(LongPath? path) => path?.Path;
[JsonIgnore]
public string ShortPathName
@@ -127,8 +131,6 @@ namespace FileManager
//for newly-created entries in ther file system. Existing entries made while
//8dot3 names were disabled will not be reachable by short paths.
if (Path is null) return null;
StringBuilder shortPathBuffer = new(MaxPathLength);
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
return shortPathBuffer.ToString();
@@ -141,7 +143,6 @@ namespace FileManager
get
{
if (!IsWindows) return Path;
if (Path is null) return null;
StringBuilder longPathBuffer = new(MaxPathLength);
GetLongPathName(Path, longPathBuffer, MaxPathLength);
@@ -156,13 +157,19 @@ namespace FileManager
{
if (!IsWindows) return Path;
return
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
:Path;
Path.StartsWith(WIN_LONG_PATH_PREFIX)
? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
: Path;
}
}
public override string ToString() => Path;
public override int GetHashCode() => Path.GetHashCode();
public override bool Equals(object? obj) => obj is LongPath other && Path == other.Path;
public static bool operator ==(LongPath? path1, LongPath? path2) => path1?.Equals(path2) is true;
public static bool operator !=(LongPath? path1, LongPath? path2) => path1 is null || path2 is null || !path1.Equals(path2);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);

View File

@@ -1,7 +1,9 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
internal interface IClosingPropertyTag : IPropertyTag
@@ -17,7 +19,7 @@ internal interface IClosingPropertyTag : IPropertyTag
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag);
bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
}
public class ConditionalTagCollection<TClass> : TagCollection
@@ -37,6 +39,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
private class ConditionalTag : TagBase, IClosingPropertyTag
{
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
@@ -46,7 +49,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
}
public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag)
public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
{
var match = NameCloseMatcher.Match(templateString);
if (match.Success)

View File

@@ -1,19 +1,21 @@
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
#nullable enable
namespace FileManager.NamingTemplate;
public class NamingTemplate
{
public string TemplateText { get; private set; }
public string TemplateText { get; private set; } = string.Empty;
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
public IEnumerable<string> Warnings => errors.Concat(warnings);
public IEnumerable<string> Errors => errors;
private Delegate templateToString;
private Delegate? templateToString;
private readonly List<string> warnings = new();
private readonly List<string> errors = new();
private readonly IEnumerable<TagCollection> TagCollections;
@@ -30,6 +32,9 @@ public class NamingTemplate
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
public TemplatePart Evaluate(params object[] propertyClasses)
{
if (templateToString is null)
throw new InvalidOperationException();
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
// First parameter is "this", so ignore it.
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
@@ -39,7 +44,7 @@ public class NamingTemplate
if (args.Length != delegateArgTypes.Count())
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
return ((TemplatePart)templateToString.DynamicInvoke(args)).FirstPart;
return (templateToString.DynamicInvoke(args) as TemplatePart)!.FirstPart;
}
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
@@ -69,7 +74,7 @@ public class NamingTemplate
}
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
private static Expression GetExpressionTree(BinaryNode node)
private static Expression GetExpressionTree(BinaryNode? node)
{
if (node is null) return TemplatePart.Blank;
else if (node.IsValue) return node.Expression;
@@ -81,10 +86,10 @@ public class NamingTemplate
}
/// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary>
private BinaryNode IntermediateParse(string templateString)
private BinaryNode IntermediateParse(string? templateString)
{
if (templateString is null)
throw new NullReferenceException(ERROR_NULL_IS_INVALID);
throw new ArgumentException(ERROR_NULL_IS_INVALID);
else if (string.IsNullOrEmpty(templateString))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(templateString))
@@ -93,12 +98,12 @@ public class NamingTemplate
TemplateText = templateString;
BinaryNode topNode = BinaryNode.CreateRoot();
BinaryNode currentNode = topNode;
BinaryNode? currentNode = topNode;
List<char> literalChars = new();
while (templateString.Length > 0)
{
if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression))
if (StartsWith(templateString, out var exactPropertyName, out var propertyTag, out var valueExpression))
{
checkAndAddLiterals();
@@ -116,7 +121,7 @@ public class NamingTemplate
{
checkAndAddLiterals();
BinaryNode lastParenth = currentNode;
BinaryNode? lastParenth = currentNode;
while (lastParenth?.IsConditional is false)
lastParenth = lastParenth.Parent;
@@ -168,7 +173,7 @@ public class NamingTemplate
}
}
private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression)
private bool StartsWith(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? valueExpression)
{
foreach (var pc in TagCollections)
{
@@ -182,7 +187,7 @@ public class NamingTemplate
return false;
}
private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag)
private bool StartsWithClosing(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
{
foreach (var pc in TagCollections)
{
@@ -198,36 +203,36 @@ public class NamingTemplate
private class BinaryNode
{
public string Name { get; }
public BinaryNode Parent { get; private set; }
public BinaryNode RightChild { get; private set; }
public BinaryNode LeftChild { get; private set; }
public Expression Expression { get; private init; }
public BinaryNode? Parent { get; private set; }
public BinaryNode? RightChild { get; private set; }
public BinaryNode? LeftChild { get; private set; }
public Expression Expression { get; }
public bool IsConditional { get; private init; } = false;
public bool IsValue { get; private init; } = false;
public static BinaryNode CreateRoot() => new("Root");
public static BinaryNode CreateRoot() => new("Root", Expression.Empty());
public static BinaryNode CreateValue(string literal) => new("Literal")
{
IsValue = true,
Expression = TemplatePart.CreateLiteral(literal)
};
public static BinaryNode CreateValue(string literal)
=> new("Literal", TemplatePart.CreateLiteral(literal))
{
IsValue = true
};
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
{
IsValue = true,
Expression = TemplatePart.CreateProperty(templateTag, property)
};
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property)
=> new(templateTag.TagName, TemplatePart.CreateProperty(templateTag, property))
{
IsValue = true
};
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property) => new(templateTag.TagName)
{
IsConditional = true,
Expression = property
};
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property)
=> new(templateTag.TagName, property)
{
IsConditional = true
};
private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right)
{
var newNode = new BinaryNode("Concatenation")
var newNode = new BinaryNode("Concatenation", Expression.Empty())
{
LeftChild = left,
RightChild = right
@@ -237,7 +242,12 @@ public class NamingTemplate
return newNode;
}
private BinaryNode(string name) => Name = name;
private BinaryNode(string name, Expression expression)
{
Name = name;
Expression = expression;
}
public override string ToString() => Name;
public BinaryNode AddNewNode(BinaryNode newNode)

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
@@ -37,7 +38,7 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty> formatter = null)
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
where TProperty : struct
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
@@ -59,7 +60,7 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty> formatter = null)
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
=> RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary>
@@ -72,14 +73,15 @@ public class PropertyTagCollection<TClass> : TagCollection
=> RegisterWithToString(templateTag, propertyGetter, toString);
private void RegisterWithFormatter<TProperty, TPropertyValue>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue> formatter)
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue>? formatter)
{
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
formatter ??= GetDefaultFormatter<TPropertyValue>();
if ((formatter ??= GetDefaultFormatter<TPropertyValue>()) is null)
if (formatter is null)
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
else
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
@@ -97,7 +99,7 @@ public class PropertyTagCollection<TClass> : TagCollection
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
private PropertyFormatter<T> GetDefaultFormatter<T>()
private PropertyFormatter<T>? GetDefaultFormatter<T>()
{
try
{
@@ -109,6 +111,7 @@ public class PropertyTagCollection<TClass> : TagCollection
private class PropertyTag<TPropertyValue> : TagBase
{
public override Regex NameMatcher { get; }
private Func<Expression, string, Expression> CreateToStringExpression { get; }
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)

View File

@@ -1,7 +1,9 @@
using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
internal interface IPropertyTag
@@ -22,13 +24,13 @@ internal interface IPropertyTag
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWith(string templateString, out string exactName, out Expression propertyValue);
bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue);
}
internal abstract class TagBase : IPropertyTag
{
public ITemplateTag TemplateTag { get; }
public Regex NameMatcher { get; protected init; }
public abstract Regex NameMatcher { get; }
public Type ReturnType => ValueExpression.Type;
protected Expression ValueExpression { get; }
@@ -43,7 +45,7 @@ internal abstract class TagBase : IPropertyTag
/// <param name="formatter">The optional format string in the match inside the square brackets</param>
protected abstract Expression GetTagExpression(string exactName, string formatter);
public bool StartsWith(string templateString, out string exactName, out Expression propertyValue)
public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
{
var match = NameMatcher.Match(templateString);
if (match.Success)

View File

@@ -1,10 +1,12 @@
using System;
using System.Collections;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
using System.Linq.Expressions;
using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate;
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
@@ -32,7 +34,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
internal bool StartsWith(string templateString, out string exactName, out IPropertyTag propertyTag, out Expression propertyValue)
internal bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? propertyValue)
{
foreach (var p in PropertyTags)
{
@@ -57,7 +59,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag)
internal bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
{
foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>())
{

View File

@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq.Expressions;
using System.Reflection;
#nullable enable
namespace FileManager.NamingTemplate;
/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary>
@@ -15,13 +16,13 @@ public class TemplatePart : IEnumerable<TemplatePart>
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
/// a registered property, otherwise <see cref="null"/> for string literals. </summary>
public ITemplateTag TemplateTag { get; }
public ITemplateTag? TemplateTag { get; }
/// <summary>The evaluated string.</summary>
public string Value { get; }
private TemplatePart previous;
private TemplatePart next;
private TemplatePart? previous;
private TemplatePart? next;
private TemplatePart(string name, string value)
{
TagName = name;
@@ -53,14 +54,33 @@ public class TemplatePart : IEnumerable<TemplatePart>
private static Expression CreateExpression(string name, Expression value)
=> Expression.New(constructorInfo, Expression.Constant(name), value);
private static readonly ConstructorInfo constructorInfo
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(string), typeof(string) });
private static readonly ConstructorInfo constructorInfo;
private static readonly ConstructorInfo tagTemplateConstructorInfo;
private static readonly MethodInfo addMethodInfo;
static TemplatePart()
{
var type = typeof(TemplatePart);
private static readonly ConstructorInfo tagTemplateConstructorInfo
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(ITemplateTag), typeof(string) });
if (type.GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
new Type[] { typeof(string), typeof(string) }) is not ConstructorInfo c1)
throw new MissingMethodException(nameof(TemplatePart));
private static readonly MethodInfo addMethodInfo
= typeof(TemplatePart).GetMethod(nameof(Concatenate), BindingFlags.NonPublic | BindingFlags.Static, new Type[] { typeof(TemplatePart), typeof(TemplatePart) });
if (type.GetConstructor(
BindingFlags.NonPublic | BindingFlags.Instance,
new Type[] { typeof(ITemplateTag), typeof(string) }) is not ConstructorInfo c2)
throw new MissingMethodException(nameof(TemplatePart));
if (type.GetMethod(
nameof(Concatenate),
BindingFlags.NonPublic | BindingFlags.Static,
new Type[] { typeof(TemplatePart), typeof(TemplatePart) }) is not MethodInfo m1)
throw new MissingMethodException(nameof(Concatenate));
constructorInfo = c1;
tagTemplateConstructorInfo = c2;
addMethodInfo = m1;
}
public IEnumerator<TemplatePart> GetEnumerator()
{

View File

@@ -1,10 +1,12 @@
using System;
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
@@ -13,19 +15,19 @@ namespace FileManager
public bool IsReadOnly { get; }
// optimize for strings. expectation is most settings will be strings and a rare exception will be something else
private Dictionary<string, string> stringCache { get; } = new Dictionary<string, string>();
private Dictionary<string, object> objectCache { get; } = new Dictionary<string, object>();
private Dictionary<string, string?> stringCache { get; } = new();
private Dictionary<string, object?> objectCache { get; } = new();
public PersistentDictionary(string filepath, bool isReadOnly = false)
{
Filepath = filepath;
IsReadOnly = isReadOnly;
if (File.Exists(Filepath))
if (File.Exists(Filepath) || Path.GetDirectoryName(Filepath) is not string dirName)
return;
// will create any missing directories, incl subdirectories. if all already exist: no action
Directory.CreateDirectory(Path.GetDirectoryName(filepath));
Directory.CreateDirectory(dirName);
if (IsReadOnly)
return;
@@ -33,13 +35,14 @@ namespace FileManager
createNewFile();
}
public string GetString(string propertyName, string defaultValue = null)
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string propertyName, string? defaultValue = null)
{
if (!stringCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (jObject.ContainsKey(propertyName))
stringCache[propertyName] = jObject[propertyName].Value<string>();
stringCache[propertyName] = jObject[propertyName]?.Value<string>();
else
stringCache[propertyName] = defaultValue;
}
@@ -47,7 +50,8 @@ namespace FileManager
return stringCache[propertyName];
}
public T GetNonString<T>(string propertyName, T defaultValue = default)
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
{
var obj = GetObject(propertyName);
@@ -72,21 +76,21 @@ namespace FileManager
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
public object GetObject(string propertyName)
public object? GetObject(string propertyName)
{
if (!objectCache.ContainsKey(propertyName))
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return null;
objectCache[propertyName] = jObject[propertyName].Value<object>();
objectCache[propertyName] = jObject[propertyName]?.Value<object>();
}
return objectCache[propertyName];
}
public string GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string GetStringFromJsonPath(string jsonPath)
public string? GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string? GetStringFromJsonPath(string jsonPath)
{
if (!stringCache.ContainsKey(jsonPath))
{
@@ -96,7 +100,7 @@ namespace FileManager
var token = jObject.SelectToken(jsonPath);
if (token is null)
return null;
stringCache[jsonPath] = (string)token;
stringCache[jsonPath] = token.Value<string>();
}
catch
{
@@ -110,7 +114,7 @@ namespace FileManager
public bool Exists(string propertyName) => readFile().ContainsKey(propertyName);
private object locker { get; } = new object();
public void SetString(string propertyName, string newValue)
public void SetString(string propertyName, string? newValue)
{
// only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
@@ -122,7 +126,7 @@ namespace FileManager
writeFile(propertyName, newValue);
}
public void SetNonString(string propertyName, object newValue)
public void SetNonString(string propertyName, object? newValue)
{
// set cache
objectCache[propertyName] = newValue;
@@ -131,7 +135,36 @@ namespace FileManager
writeFile(propertyName, parsedNewValue);
}
private void writeFile(string propertyName, JToken newValue)
public bool RemoveProperty(string propertyName)
{
if (IsReadOnly)
return false;
var success = false;
try
{
lock (locker)
{
var jObject = readFile();
if (!jObject.ContainsKey(propertyName))
return false;
jObject.Remove(propertyName);
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
File.WriteAllText(Filepath, endContents);
success = true;
}
Serilog.Log.Logger.Information("Removed property. {@DebugInfo}", propertyName);
}
catch { }
return success;
}
private void writeFile(string propertyName, JToken? newValue)
{
if (IsReadOnly)
return;
@@ -161,7 +194,7 @@ namespace FileManager
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
/// <returns>Value was changed</returns>
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{
if (IsReadOnly)
return false;
@@ -213,7 +246,7 @@ namespace FileManager
return true;
}
private static string formatValueForLog(string value)
private static string formatValueForLog(string? value)
=> value is null ? "[null]"
: string.IsNullOrEmpty(value) ? "[empty]"
: string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]"
@@ -254,7 +287,6 @@ namespace FileManager
private void createNewFile()
{
File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
}
}
}

View File

@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
#nullable enable
namespace FileManager
{
public record Replacement
@@ -59,7 +60,7 @@ namespace FileManager
[JsonConverter(typeof(ReplacementCharactersConverter))]
public class ReplacementCharacters
{
public override bool Equals(object obj)
public override bool Equals(object? obj)
{
if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count)
{
@@ -173,7 +174,7 @@ namespace FileManager
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray();
public IReadOnlyList<Replacement> Replacements { get; init; }
required public IReadOnlyList<Replacement> Replacements { get; init; }
private string DefaultReplacement => Replacements[0].ReplacementString;
private Replacement ForwardSlash => Replacements[1];
private Replacement BackSlash => Replacements[2];
@@ -298,12 +299,14 @@ namespace FileManager
public override bool CanConvert(Type objectType)
=> objectType == typeof(ReplacementCharacters);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var replaceArr = jObj[nameof(Replacement)];
IReadOnlyList<Replacement> dict = replaceArr
.ToObject<Replacement[]>().ToList();
var dict
= replaceArr?.ToObject<Replacement[]>()?.ToList()
?? ReplacementCharacters.Default.Replacements;
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
//If not, reset to default.
@@ -325,9 +328,10 @@ namespace FileManager
return new ReplacementCharacters { Replacements = dict };
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{
ReplacementCharacters replacements = (ReplacementCharacters)value;
if (value is not ReplacementCharacters replacements)
return;
var propertyNames = replacements.Replacements
.Select(JObject.FromObject).ToList();

View File

@@ -67,13 +67,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia" Version="11.0.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.2" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc1.1" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.2" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

View File

@@ -13,7 +13,7 @@ namespace HangoverAvalonia.Views
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
}
public void OnLoad()

View File

@@ -219,8 +219,8 @@
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Controls.Add(this.tabControl1);
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));

View File

@@ -10,7 +10,7 @@ namespace HangoverWinForms
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
cliTab.VisibleChanged += cliTab_VisibleChanged;

View File

@@ -141,7 +141,7 @@ namespace LibationAvalonia
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
// logging is init'd here
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(config);
AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config);
}
private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config, Action<IClassicDesktopStyleApplicationLifetime, LibationFilesDialog, Configuration> OnClose)

View File

@@ -8,9 +8,6 @@
<Panel Background="Transparent" HorizontalAlignment="Stretch" VerticalAlignment="Stretch">
<Grid Name="ratingsGrid" HorizontalAlignment="Left" VerticalAlignment="Center" Margin="3,0,0,0" ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto">
<Grid.Styles>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="11" />
</Style>
<Style Selector="StackPanel > TextBlock">
<Setter Property="Padding" Value="0,0,-2,0" />
</Style>

View File

@@ -2,7 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="600"
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="650"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudioSettingsVM"
@@ -32,6 +32,18 @@
Grid.Row="0"
Grid.Column="0">
<Grid ColumnDefinitions="*,Auto">
<TextBlock
VerticalAlignment="Center"
Text="{CompiledBinding FileDownloadQualityText}" />
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
ItemsSource="{CompiledBinding DownloadQualities}"
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
</Grid>
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
</CheckBox>
@@ -61,7 +73,15 @@
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<CheckBox
ToolTip.Tip="{CompiledBinding CombineNestedChapterTitlesTip}"
IsChecked="{CompiledBinding CombineNestedChapterTitles, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CombineNestedChapterTitlesText}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{CompiledBinding AllowLibationFixupTip}"
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
</CheckBox>
</StackPanel>

View File

@@ -165,18 +165,32 @@
</StackPanel>
</controls:GroupBox>
<CheckBox
<StackPanel
Grid.Row="3"
Margin="5"
VerticalAlignment="Top"
IsVisible="{CompiledBinding !Config.IsLinux}"
IsChecked="{CompiledBinding UseCoverAsFolderIcon, Mode=TwoWay}">
Orientation="Horizontal">
<TextBlock
TextWrapping="Wrap"
Text="{CompiledBinding UseCoverAsFolderIconText}" />
<CheckBox
Margin="5"
VerticalAlignment="Top"
IsVisible="{CompiledBinding !Config.IsLinux}"
IsChecked="{CompiledBinding UseCoverAsFolderIcon, Mode=TwoWay}">
</CheckBox>
<TextBlock
TextWrapping="Wrap"
Text="{CompiledBinding UseCoverAsFolderIconText}" />
</CheckBox>
<CheckBox
Margin="5"
VerticalAlignment="Top"
IsChecked="{CompiledBinding SaveMetadataToFile, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="{CompiledBinding SaveMetadataToFileText}" />
</CheckBox>
</StackPanel>
</Grid>
</UserControl>

View File

@@ -2,18 +2,18 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
mc:Ignorable="d" d:DesignWidth="700" d:DesignHeight="600"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:ImportantSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Important">
<Grid RowDefinitions="Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,*">
<controls:GroupBox
Grid.Row="0"
Margin="5"
Label="Books Location">
<StackPanel>
<TextBlock
Margin="5"
@@ -35,7 +35,7 @@
<Grid
RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,*">
<TextBlock
VerticalAlignment="Center"
Margin="0,0,10,0"
@@ -95,8 +95,63 @@
</StackPanel>
<Grid
<controls:GroupBox
Grid.Row="2"
Margin="5"
Label="Display Settings">
<Grid
RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,Auto,*">
<TextBlock
Margin="0,0,10,0"
VerticalAlignment="Center"
Text="{CompiledBinding GridScaleFactorText}"/>
<Slider
Grid.Column="1"
Width="200"
Value="{CompiledBinding GridScaleFactor, Mode=TwoWay}"
VerticalAlignment="Center"
Minimum="-100"
Maximum="100"
IsSnapToTickEnabled="False"
TickFrequency="25"
TickPlacement="BottomRight">
</Slider>
<TextBlock
Margin="0,0,10,0"
Grid.Row="1"
VerticalAlignment="Center"
Text="{CompiledBinding GridFontScaleFactorText}"/>
<Slider
Grid.Column="1"
Grid.Row="1"
Width="200"
Value="{CompiledBinding GridFontScaleFactor, Mode=TwoWay}"
VerticalAlignment="Center"
Minimum="-100"
Maximum="100"
IsSnapToTickEnabled="False"
TickFrequency="25"
TickPlacement="BottomRight">
</Slider>
<Button
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Right"
Margin="0,5"
Padding="20,0"
VerticalAlignment="Stretch"
Content="Apply Display Settings"
Command="{CompiledBinding ApplyDisplaySettings}"/>
</Grid>
</controls:GroupBox>
<Grid
Grid.Row="3"
ColumnDefinitions="Auto,Auto,*"
Margin="10"
VerticalAlignment="Bottom">
@@ -111,7 +166,7 @@
MinWidth="80"
SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}"
ItemsSource="{CompiledBinding Themes}"/>
<TextBlock
Grid.Column="2"
FontSize="16"

View File

@@ -115,7 +115,7 @@ Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(" > ", Book.CategoriesNames())}
Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language}
Audible ID: {Book.AudibleProductId}

View File

@@ -70,13 +70,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-rc1.1" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-rc1.1" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.2" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,7 +1,6 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AppScaffolding;
@@ -35,36 +34,45 @@ namespace LibationAvalonia
$"\"{Configuration.ProcessDirectory}\"");
return;
}
AppDomain.CurrentDomain.UnhandledException += (o, e) => LogError(e.ExceptionObject);
bool loggingEnabled = false;
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
// Migrations which must occur before configuration is loaded for the first time. Usually ones which alter the Configuration
var config = LibationScaffolding.RunPreConfigMigrations();
//Start as much work in parallel as possible.
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
LibationScaffolding.SetReleaseIdentifier(Variety.Chardonnay);
if (LibationScaffolding.ReleaseIdentifier is ReleaseIdentifier.None)
return;
if (config.LibationSettingsAreValid)
try
{
if (!RunDbMigrations(config))
return;
var config = LibationScaffolding.RunPreConfigMigrations();
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
//Start as much work in parallel as possible.
var classicLifetimeTask = Task.Run(() => new ClassicDesktopStyleApplicationLifetime());
var appBuilderTask = Task.Run(BuildAvaloniaApp);
if (config.LibationSettingsAreValid)
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
loggingEnabled = true;
//Start loading the library before loading the main form
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
}
appBuilderTask.GetAwaiter().GetResult().SetupWithLifetime(classicLifetimeTask.GetAwaiter().GetResult());
classicLifetimeTask.Result.Start(null);
}
catch (Exception ex)
{
if (loggingEnabled)
Serilog.Log.Logger.Error(ex, "CRASH");
else
LogError(ex);
}
appBuilderTask.GetAwaiter().GetResult().SetupWithLifetime(classicLifetimeTask.GetAwaiter().GetResult());
classicLifetimeTask.Result.Start(null);
}
public static AppBuilder BuildAvaloniaApp()
@@ -73,20 +81,35 @@ namespace LibationAvalonia
.LogToTrace()
.UseReactiveUI();
public static bool RunDbMigrations(Configuration config)
private static void LogError(object exceptionObject)
{
try
{
// most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
var logError = $"""
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
return true;
}
catch (Exception exDebug)
var crashLog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log");
using var sw = new StreamWriter(crashLog, true);
sw.WriteLine(logError);
static string getConfigValue(Func<Configuration, string> selector)
{
Serilog.Log.Logger.Debug(exDebug, "Silent failure");
return false;
try
{
return selector(Configuration.Instance);
}
catch (Exception ex)
{
return ex.ToString();
}
}
}
}

View File

@@ -1,7 +1,10 @@
using ApplicationServices;
using Avalonia.Threading;
using DataLayer;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
@@ -41,16 +44,17 @@ namespace LibationAvalonia.ViewModels
private void Configure_BackupCounts()
{
MainWindow.Loaded += setBackupCounts;
LibraryCommands.LibrarySizeChanged += setBackupCounts;
LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts;
MainWindow.LibraryLoaded += (_, e) => setBackupCounts(e.Where(l => !l.Book.IsEpisodeParent()));
LibraryCommands.LibrarySizeChanged += (_,_) => setBackupCounts();
LibraryCommands.BookUserDefinedItemCommitted += (_, _) => setBackupCounts();
}
private async void setBackupCounts(object _, object __)
private async void setBackupCounts(IEnumerable<LibraryBook> libraryBooks = null)
{
if (updateCountsTask?.IsCompleted ?? true)
{
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts());
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts(libraryBooks));
var stats = await updateCountsTask;
await Dispatcher.UIThread.InvokeAsync(() => LibraryStats = stats);

View File

@@ -264,9 +264,12 @@ namespace LibationAvalonia.ViewModels
private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e)
{
byte[] coverData = PictureStorage
.GetPictureSynchronously(
new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500));
var quality
= Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High
? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native)
: new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500);
byte[] coverData = PictureStorage.GetPictureSynchronously(quality);
AudioDecodable_CoverImageDiscovered(this, coverData);
return coverData;

View File

@@ -91,37 +91,21 @@ namespace LibationAvalonia.ViewModels
#region Display Functions
internal void BindToGrid(List<LibraryBook> dbBooks)
internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{
GridEntries = new(SOURCE) { Filter = CollectionFilter };
var geList = dbBooks
.Where(lb => lb.Book.IsProduct())
.Select(b => new LibraryBookEntry<AvaloniaEntryStatus>(b))
.ToList<IGridEntry>();
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks);
var episodes = dbBooks.Where(lb => lb.Book.IsEpisodeChild()).ToList();
var seriesBooks = dbBooks.Where(lb => lb.Book.IsEpisodeParent()).ToList();
foreach (var parent in seriesBooks)
{
var seriesEpisodes = episodes.FindChildren(parent);
if (!seriesEpisodes.Any()) continue;
var seriesEntry = new SeriesEntry<AvaloniaEntryStatus>(parent, seriesEpisodes);
seriesEntry.Liberate.Expanded = false;
geList.Add(seriesEntry);
}
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = geList.Union(geList.OfType<ISeriesEntry>().SelectMany(s => s.Children)).FilterEntries(FilterString);
SOURCE.AddRange(geList.OrderDescending(new RowComparer(null)));
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
//Adding entries to the Source list will invoke CollectionFilter
SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
//Add all children beneath their parent
foreach (var series in SOURCE.OfType<ISeriesEntry>().ToList())
foreach (var series in seriesEntries)
{
var seriesIndex = SOURCE.IndexOf(series);
foreach (var child in series.Children)

View File

@@ -44,10 +44,12 @@ namespace LibationAvalonia.ViewModels.Settings
public void LoadSettings(Configuration config)
{
CreateCueSheet = config.CreateCueSheet;
CombineNestedChapterTitles = config.CombineNestedChapterTitles;
AllowLibationFixup = config.AllowLibationFixup;
DownloadCoverArt = config.DownloadCoverArt;
RetainAaxFile = config.RetainAaxFile;
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
FileDownloadQuality = config.FileDownloadQuality;
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
SplitFilesByChapter = config.SplitFilesByChapter;
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
@@ -70,10 +72,12 @@ namespace LibationAvalonia.ViewModels.Settings
public void SaveSettings(Configuration config)
{
config.CreateCueSheet = CreateCueSheet;
config.CombineNestedChapterTitles = CombineNestedChapterTitles;
config.AllowLibationFixup = AllowLibationFixup;
config.DownloadCoverArt = DownloadCoverArt;
config.RetainAaxFile = RetainAaxFile;
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
config.FileDownloadQuality = FileDownloadQuality;
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
config.SplitFilesByChapter = SplitFilesByChapter;
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
@@ -93,9 +97,14 @@ namespace LibationAvalonia.ViewModels.Settings
config.MaxSampleRate = SelectedSampleRate?.Value ?? config.MaxSampleRate;
}
public AvaloniaList<Configuration.DownloadQuality> DownloadQualities { get; } = new(Enum<Configuration.DownloadQuality>.GetValues());
public AvaloniaList<Configuration.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
public string FileDownloadQualityText { get; } = Configuration.GetDescription(nameof(Configuration.FileDownloadQuality));
public string CreateCueSheetText { get; } = Configuration.GetDescription(nameof(Configuration.CreateCueSheet));
public string CombineNestedChapterTitlesText { get; } = Configuration.GetDescription(nameof(Configuration.CombineNestedChapterTitles));
public string CombineNestedChapterTitlesTip => Configuration.GetHelpText(nameof(CombineNestedChapterTitles));
public string AllowLibationFixupText { get; } = Configuration.GetDescription(nameof(Configuration.AllowLibationFixup));
public string AllowLibationFixupTip => Configuration.GetHelpText(nameof(AllowLibationFixup));
public string DownloadCoverArtText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadCoverArt));
public string RetainAaxFileText { get; } = Configuration.GetDescription(nameof(Configuration.RetainAaxFile));
public string SplitFilesByChapterText { get; } = Configuration.GetDescription(nameof(Configuration.SplitFilesByChapter));
@@ -106,9 +115,11 @@ namespace LibationAvalonia.ViewModels.Settings
public string MoveMoovToBeginningText { get; } = Configuration.GetDescription(nameof(Configuration.MoveMoovToBeginning));
public bool CreateCueSheet { get; set; }
public bool CombineNestedChapterTitles { get; set; }
public bool DownloadCoverArt { get; set; }
public bool RetainAaxFile { get; set; }
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
public Configuration.DownloadQuality FileDownloadQuality { get; set; }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }
public bool StripAudibleBrandAudio { get; set; }

View File

@@ -38,6 +38,7 @@ namespace LibationAvalonia.ViewModels.Settings
ChapterFileTemplate = config.ChapterFileTemplate;
InProgressDirectory = config.InProgress;
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
SaveMetadataToFile = config.SaveMetadataToFile;
}
public void SaveSettings(Configuration config)
@@ -54,9 +55,11 @@ namespace LibationAvalonia.ViewModels.Settings
config.InProgress = InProgressDirectory;
config.UseCoverAsFolderIcon = UseCoverAsFolderIcon;
config.SaveMetadataToFile = SaveMetadataToFile;
}
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));
public string SaveMetadataToFileText { get; } = Configuration.GetDescription(nameof(Configuration.SaveMetadataToFile));
public string BadBookGroupboxText { get; } = Configuration.GetDescription(nameof(Configuration.BadBook));
public string BadBookAskText { get; } = Configuration.BadBookAction.Ask.GetDescription();
public string BadBookAbortText { get; } = Configuration.BadBookAction.Abort.GetDescription();
@@ -72,6 +75,7 @@ namespace LibationAvalonia.ViewModels.Settings
public string FileTemplate { get => _fileTemplate; set { this.RaiseAndSetIfChanged(ref _fileTemplate, value); } }
public string ChapterFileTemplate { get => _chapterFileTemplate; set { this.RaiseAndSetIfChanged(ref _chapterFileTemplate, value); } }
public bool UseCoverAsFolderIcon { get; set; }
public bool SaveMetadataToFile { get; set; }
public bool BadBookAsk { get; set; }
public bool BadBookAbort { get; set; }

View File

@@ -13,9 +13,11 @@ namespace LibationAvalonia.ViewModels.Settings
{
private string themeVariant;
private string initialThemeVariant;
private readonly Configuration config;
public ImportantSettingsVM(Configuration config)
{
this.config = config;
LoadSettings(config);
}
@@ -27,6 +29,8 @@ namespace LibationAvalonia.ViewModels.Settings
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
LastWriteTime = DateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? DateTimeSources[0];
LoggingLevel = config.LogLevel;
GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor);
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
ThemeVariant = initialThemeVariant
= Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) is nameof(Avalonia.Styling.ThemeVariant.Dark)
? nameof(Avalonia.Styling.ThemeVariant.Dark)
@@ -47,6 +51,16 @@ namespace LibationAvalonia.ViewModels.Settings
Configuration.Instance.SetString(ThemeVariant, nameof(ThemeVariant));
}
private static float scaleFactorToLinearRange(float scaleFactor)
=> float.Round(100 * MathF.Log2(scaleFactor));
private static float linearRangeToScaleFactor(float value)
=> MathF.Pow(2, value / 100f);
public void ApplyDisplaySettings()
{
config.GridFontScaleFactor = linearRangeToScaleFactor(GridFontScaleFactor);
config.GridScaleFactor = linearRangeToScaleFactor(GridScaleFactor);
}
public void OpenLogFolderButton() => Go.To.Folder(((LongPath)Configuration.Instance.LibationFiles).ShortPathName);
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
@@ -66,12 +80,16 @@ namespace LibationAvalonia.ViewModels.Settings
.Select(v => new EnumDiaplay<Configuration.DateTimeSource>(v))
.ToArray();
public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues<Serilog.Events.LogEventLevel>();
public string GridScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridScaleFactor));
public string GridFontScaleFactorText { get; } = Configuration.GetDescription(nameof(Configuration.GridFontScaleFactor));
public string BetaOptInText { get; } = Configuration.GetDescription(nameof(Configuration.BetaOptIn));
public string[] Themes { get; } = { nameof(Avalonia.Styling.ThemeVariant.Light), nameof(Avalonia.Styling.ThemeVariant.Dark) };
public string BooksDirectory { get; set; }
public bool SavePodcastsToParentFolder { get; set; }
public bool OverwriteExisting { get; set; }
public float GridScaleFactor { get; set; }
public float GridFontScaleFactor { get; set; }
public EnumDiaplay<Configuration.DateTimeSource> CreationTime { get; set; }
public EnumDiaplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }

View File

@@ -3,7 +3,7 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels"
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="200" MinWidth="64" MinHeight="64"
mc:Ignorable="d" d:DesignWidth="200" d:DesignHeight="200" MinWidth="37" MinHeight="40"
Background="Transparent"
x:DataType="vm:LiberateStatusButtonViewModel"
x:Class="LibationAvalonia.Views.LiberateStatusButton">
@@ -35,48 +35,51 @@
Name="button"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
IsEnabled="{CompiledBinding IsButtonEnabled}" Padding="0" Click="Button_Click" >
<Panel>
<Panel
Width="64" Height="64"
IsVisible="{CompiledBinding !IsError}">
Padding="0"
IsEnabled="{CompiledBinding IsButtonEnabled}" Click="Button_Click" >
<Panel IsVisible="{CompiledBinding IsSeries}">
<Path IsVisible="{CompiledBinding Expanded}" Data="{StaticResource CollapseIcon}" />
<Path IsVisible="{CompiledBinding !Expanded}" Data="{StaticResource ExpandIcon}" />
<Grid RowDefinitions="*,8*,*">
<Viewbox
Grid.Row="1"
Stretch="Uniform"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch">
<Panel>
<Panel IsVisible="{CompiledBinding !IsError}">
<Panel IsVisible="{CompiledBinding IsSeries}">
<Path IsVisible="{CompiledBinding Expanded}" Data="{StaticResource CollapseIcon}" />
<Path IsVisible="{CompiledBinding !Expanded}" Data="{StaticResource ExpandIcon}" />
</Panel>
<Grid
IsVisible="{CompiledBinding !IsSeries}"
HorizontalAlignment="Center"
ColumnDefinitions="Auto,Auto">
<Canvas Width="29.44" Height="64">
<Rectangle Canvas.Left="5" Canvas.Top="5" IsVisible="{CompiledBinding RedVisible}" Fill="{DynamicResource StoplightRed}" />
<Rectangle Canvas.Left="5" Canvas.Top="23" IsVisible="{CompiledBinding YellowVisible}" Fill="{DynamicResource StoplightYellow}" />
<Rectangle Canvas.Left="5" Canvas.Top="42" IsVisible="{CompiledBinding GreenVisible}" Fill="{DynamicResource StoplightGreen}" />
<Path Height="64" Stretch="Uniform" Data="{StaticResource StoplightBodyIcon}"/>
</Canvas>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfDownloadedVisible}" Data="{StaticResource PdfDownloadedIcon}"/>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfNotDownloadedVisible}" Data="{StaticResource PdfNotDownloadedIcon}"/>
</Grid>
</Panel>
<Path
Stretch="None" Width="64"
IsVisible="{CompiledBinding IsError}"
Fill="{DynamicResource CancelRed}"
Data="{StaticResource BookErrorIcon}" />
<Path
Stretch="Fill"
IsVisible="{CompiledBinding !IsButtonEnabled}"
Fill="{DynamicResource DisabledGrayBrush}"
Data="M0,0 H1 V1 H0" />
</Panel>
<Grid
IsVisible="{CompiledBinding !IsSeries}"
HorizontalAlignment="Center"
ColumnDefinitions="Auto,Auto">
<Canvas Width="29.44" Height="64">
<Rectangle Canvas.Left="5" Canvas.Top="5" IsVisible="{CompiledBinding RedVisible}" Fill="{DynamicResource StoplightRed}" />
<Rectangle Canvas.Left="5" Canvas.Top="23" IsVisible="{CompiledBinding YellowVisible}" Fill="{DynamicResource StoplightYellow}" />
<Rectangle Canvas.Left="5" Canvas.Top="42" IsVisible="{CompiledBinding GreenVisible}" Fill="{DynamicResource StoplightGreen}" />
<Path Height="64" Stretch="Uniform" Data="{StaticResource StoplightBodyIcon}"/>
</Canvas>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfDownloadedVisible}" Data="{StaticResource PdfDownloadedIcon}"/>
<Path Grid.Column="1" IsVisible="{CompiledBinding PdfNotDownloadedVisible}" Data="{StaticResource PdfNotDownloadedIcon}"/>
</Grid>
</Panel>
<Path
Stretch="None" Width="64"
IsVisible="{CompiledBinding IsError}"
Fill="{DynamicResource CancelRed}"
Data="{StaticResource BookErrorIcon}" />
<Path
Stretch="Fill"
IsVisible="{CompiledBinding !IsButtonEnabled}"
Fill="{DynamicResource DisabledGrayBrush}"
Data="M0,0 H1 V1 H0" />
</Panel>
</Viewbox>
</Grid>
</Button>
</UserControl>

View File

@@ -61,7 +61,7 @@ namespace LibationAvalonia.Views
if (QuickFilters.UseDefault)
await ViewModel.PerformFilter(QuickFilters.Filters.FirstOrDefault());
ViewModel.ProductsDisplay.BindToGrid(dbBooks);
await ViewModel.ProductsDisplay.BindToGridAsync(dbBooks);
}
private void selectAndFocusSearchBox()

View File

@@ -20,9 +20,6 @@
CanUserReorderColumns="True">
<DataGrid.Styles>
<Style Selector="DataGridCell">
<Setter Property="Height" Value="80"/>
</Style>
<Style Selector="DataGridCell > Panel">
<Setter Property="VerticalAlignment" Value="Stretch"/>
</Style>
@@ -31,7 +28,6 @@
<Setter Property="HorizontalAlignment" Value="Stretch"/>
<Setter Property="TextWrapping" Value="Wrap"/>
<Setter Property="Padding" Value="4"/>
<Setter Property="FontSize" Value="12"/>
</Style>
<Style Selector="DataGridCell Path">
<Setter Property="Stretch" Value="Uniform" />
@@ -69,7 +65,7 @@
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="75" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<controls:DataGridTemplateColumnExt CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<views:LiberateStatusButton
@@ -84,19 +80,19 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<DataGridTemplateColumn CanUserSort="False" Width="80" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<controls:DataGridTemplateColumnExt CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Height="80" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
<DataTemplate x:DataType="uibase:IGridEntry">
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
<TextBlock FontSize="14" Text="{CompiledBinding Title}" />
<TextBlock Classes="h1" Text="{CompiledBinding Title}" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -152,11 +148,11 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}">
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
<TextBlock Text="{CompiledBinding Description}" FontSize="11" VerticalAlignment="Top" />
<TextBlock Text="{CompiledBinding Description}" VerticalAlignment="Top" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -208,7 +204,7 @@
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
<TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" FontSize="10" />
<TextBlock Text="{CompiledBinding Misc}" TextWrapping="WrapWithOverflow" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -218,7 +214,7 @@
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
<TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" FontSize="10" />
<TextBlock Text="{CompiledBinding LastDownload}" TextWrapping="WrapWithOverflow" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
@@ -227,13 +223,30 @@
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry">
<Button IsVisible="{CompiledBinding !Liberate.IsSeries}" Width="100" Height="80" Click="OnTagsButtonClick" ToolTip.Tip="Click to edit tags" >
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
<Panel Width="24" Height="24" IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Path Stretch="Uniform" Fill="{DynamicResource IconFill}" Data="{StaticResource EditTagsIcon}" />
</Panel>
<TextBlock IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" FontSize="12" TextWrapping="WrapWithOverflow" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{CompiledBinding BookTags}"/>
</Panel>
<Button
IsVisible="{CompiledBinding !Liberate.IsSeries}"
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
VerticalContentAlignment="Stretch"
HorizontalContentAlignment="Stretch"
Click="OnTagsButtonClick"
ToolTip.Tip="Click to edit tags">
<Grid
RowDefinitions="*,*,*"
Opacity="{CompiledBinding Liberate.Opacity}">
<Viewbox
Grid.Row="1"
Stretch="Uniform"
IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNullOrEmpty}}">
<Path Fill="{DynamicResource IconFill}" Data="{StaticResource EditTagsIcon}" />
</Viewbox>
<TextBlock
Classes="h2"
Grid.RowSpan="3"
IsVisible="{CompiledBinding BookTags, Converter={x:Static StringConverters.IsNotNullOrEmpty}}" TextWrapping="WrapWithOverflow" HorizontalAlignment="Center" VerticalAlignment="Center" Text="{CompiledBinding BookTags}"/>
</Grid>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>

View File

@@ -2,7 +2,9 @@ using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using Avalonia.Styling;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationAvalonia.Controls;
using LibationAvalonia.Dialogs;
@@ -30,6 +32,25 @@ namespace LibationAvalonia.Views
InitializeComponent();
DataGridContextMenus.CellContextMenuStripNeeded += ProductsGrid_CellContextMenuStripNeeded;
var cellSelector = Selectors.Is<DataGridCell>(null);
rowHeightStyle = new Style(_ => cellSelector);
rowHeightStyle.Setters.Add(rowHeightSetter);
var tboxSelector = cellSelector.Descendant().Is<TextBlock>();
fontSizeStyle = new Style(_ => tboxSelector);
fontSizeStyle.Setters.Add(fontSizeSetter);
var tboxH1Selector = cellSelector.Child().Is<Panel>().Child().Is<TextBlock>().Class("h1");
fontSizeH1Style = new Style(_ => tboxH1Selector);
fontSizeH1Style.Setters.Add(fontSizeH1Setter);
var tboxH2Selector = cellSelector.Child().Is<Panel>().Child().Is<TextBlock>().Class("h2");
fontSizeH2Style = new Style(_ => tboxH2Selector);
fontSizeH2Style.Setters.Add(fontSizeH2Setter);
Configuration.Instance.PropertyChanged += Configuration_GridScaleChanged;
Configuration.Instance.PropertyChanged += Configuration_FontChanged;
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
@@ -51,12 +72,16 @@ namespace LibationAvalonia.Views
catch { sampleEntries = new(); }
var pdvm = new ProductsDisplayViewModel();
pdvm.BindToGrid(sampleEntries);
_ = pdvm.BindToGridAsync(sampleEntries);
DataContext = pdvm;
setGridScale(1);
setFontScale(1);
return;
}
setGridScale(Configuration.Instance.GridScaleFactor);
setFontScale(Configuration.Instance.GridFontScaleFactor);
Configure_ColumnCustomization();
foreach (var column in productsGrid.Columns)
@@ -67,174 +92,257 @@ namespace LibationAvalonia.Views
private void RemoveColumn_PropertyChanged(object sender, AvaloniaPropertyChangedEventArgs e)
{
if (sender is DataGridColumn col && e.Property.Name == nameof(DataGridColumn.IsVisible))
if (sender is DataGridColumn col && e.Property == DataGridColumn.IsVisibleProperty)
{
col.DisplayIndex = 0;
col.CanUserReorder = false;
}
}
#region Scaling
[PropertyChangeFilter(nameof(Configuration.GridScaleFactor))]
private void Configuration_GridScaleChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e)
{
setGridScale((float)e.NewValue);
}
[PropertyChangeFilter(nameof(Configuration.GridFontScaleFactor))]
private void Configuration_FontChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e)
{
setFontScale((float)e.NewValue);
}
private readonly Style rowHeightStyle;
private readonly Setter rowHeightSetter = new() { Property = DataGridCell.HeightProperty };
private readonly Style fontSizeStyle;
private readonly Setter fontSizeSetter = new() { Property = TextBlock.FontSizeProperty };
private readonly Style fontSizeH1Style;
private readonly Setter fontSizeH1Setter = new() { Property = TextBlock.FontSizeProperty };
private readonly Style fontSizeH2Style;
private readonly Setter fontSizeH2Setter = new() { Property = TextBlock.FontSizeProperty };
private void setFontScale(double scaleFactor)
{
const double TextBlockFontSize = 11;
const double H1FontSize = 14;
const double H2FontSize = 12;
fontSizeSetter.Value = TextBlockFontSize * scaleFactor;
fontSizeH1Setter.Value = H1FontSize * scaleFactor;
fontSizeH2Setter.Value = H2FontSize * scaleFactor;
productsGrid.Styles.Remove(fontSizeStyle);
productsGrid.Styles.Remove(fontSizeH1Style);
productsGrid.Styles.Remove(fontSizeH2Style);
productsGrid.Styles.Add(fontSizeStyle);
productsGrid.Styles.Add(fontSizeH1Style);
productsGrid.Styles.Add(fontSizeH2Style);
}
private void setGridScale(double scaleFactor)
{
const float BaseRowHeight = 80;
const float BaseLiberateWidth = 75;
const float BaseCoverWidth = 80;
foreach (var column in productsGrid.Columns)
{
switch (column.SortMemberPath)
{
case nameof(IGridEntry.Liberate):
column.Width = new DataGridLength(BaseLiberateWidth * scaleFactor);
break;
case nameof(IGridEntry.Cover):
column.Width = new DataGridLength(BaseCoverWidth * scaleFactor);
break;
}
}
rowHeightSetter.Value = BaseRowHeight * scaleFactor;
productsGrid.Styles.Remove(rowHeightStyle);
productsGrid.Styles.Add(rowHeightStyle);
}
#endregion
#region Cell Context Menu
public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args)
{
// stop light
if (args.Column.SortMemberPath == "Liberate")
if (args.Column.SortMemberPath is not "Liberate" and not "Cover")
{
var entry = args.GridEntry;
#region Liberate all Episodes
if (entry.Liberate.IsSeries)
{
var liberateEpisodesMenuItem = new MenuItem()
{
Header = "_Liberate All Episodes",
IsEnabled = ((ISeriesEntry)entry).Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
};
args.ContextMenuItems.Add(liberateEpisodesMenuItem);
liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, ((ISeriesEntry)entry));
}
#endregion
#region Set Download status to Downloaded
var setDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || entry.Liberate.IsSeries
};
args.ContextMenuItems.Add(setDownloadMenuItem);
if (entry.Liberate.IsSeries)
setDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated);
else
setDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated);
#endregion
#region Set Download status to Not Downloaded
var setNotDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Not Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || entry.Liberate.IsSeries
};
args.ContextMenuItems.Add(setNotDownloadMenuItem);
if (entry.Liberate.IsSeries)
setNotDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated);
else
setNotDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated);
#endregion
#region Remove from library
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
args.ContextMenuItems.Add(removeMenuItem);
if (entry.Liberate.IsSeries)
removeMenuItem.Click += async (_, __) => await ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).RemoveBooksAsync();
else
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
#endregion
if (!entry.Liberate.IsSeries)
{
#region Locate file
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
args.ContextMenuItems.Add(locateFileMenuItem);
locateFileMenuItem.Click += async (_, __) =>
{
try
{
var window = this.GetParentWindow();
var openFileDialogOptions = new FilePickerOpenOptions
{
Title = $"Locate the audio file for '{entry.Book.TitleWithSubtitle}'",
AllowMultiple = false,
SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix),
FileTypeFilter = new FilePickerFileType[]
{
new("All files (*.*)") { Patterns = new[] { "*" } },
}
};
var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath();
if (selectedFile is not null)
FilePathCache.Insert(entry.AudibleProductId, selectedFile);
}
catch (Exception ex)
{
var msg = "Error saving book's location";
await MessageBox.ShowAdminAlert(null, msg, msg, ex);
}
};
#endregion
#region Convert to Mp3
var convertToMp3MenuItem = new MenuItem
{
Header = "_Convert to Mp3",
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
args.ContextMenuItems.Add(convertToMp3MenuItem);
convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook);
#endregion
}
args.ContextMenuItems.Add(new Separator());
#region View Bookmarks/Clips
if (!entry.Liberate.IsSeries)
{
var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" };
args.ContextMenuItems.Add(bookRecordMenuItem);
bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window);
}
#endregion
#region View All Series
if (entry.Book.SeriesLink.Any())
{
var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
var viewSeriesMenuItem = new MenuItem { Header = header };
args.ContextMenuItems.Add(viewSeriesMenuItem);
viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show();
}
#endregion
}
else
{
// any non-stop light column
// (except for the Cover column which does not have a context menu)
var menuItem = new MenuItem { Header = "_Copy Cell Contents" };
menuItem.Click += async (s, e)
=> await App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents);
args.ContextMenuItems.Add(menuItem);
args.ContextMenuItems.Add(new Separator());
}
var entry = args.GridEntry;
#region Liberate all Episodes
if (entry.Liberate.IsSeries)
{
var liberateEpisodesMenuItem = new MenuItem()
{
Header = "_Liberate All Episodes",
IsEnabled = ((ISeriesEntry)entry).Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
};
args.ContextMenuItems.Add(liberateEpisodesMenuItem);
liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, ((ISeriesEntry)entry));
}
#endregion
#region Set Download status to Downloaded
var setDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || entry.Liberate.IsSeries
};
args.ContextMenuItems.Add(setDownloadMenuItem);
if (entry.Liberate.IsSeries)
setDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated);
else
setDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated);
#endregion
#region Set Download status to Not Downloaded
var setNotDownloadMenuItem = new MenuItem()
{
Header = "Set Download status to '_Not Downloaded'",
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || entry.Liberate.IsSeries
};
args.ContextMenuItems.Add(setNotDownloadMenuItem);
if (entry.Liberate.IsSeries)
setNotDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated);
else
setNotDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated);
#endregion
#region Remove from library
var removeMenuItem = new MenuItem() { Header = "_Remove from library" };
args.ContextMenuItems.Add(removeMenuItem);
if (entry.Liberate.IsSeries)
removeMenuItem.Click += async (_, __) => await ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).RemoveBooksAsync();
else
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
#endregion
if (!entry.Liberate.IsSeries)
{
#region Locate file
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
args.ContextMenuItems.Add(locateFileMenuItem);
locateFileMenuItem.Click += async (_, __) =>
{
try
{
var window = this.GetParentWindow();
var openFileDialogOptions = new FilePickerOpenOptions
{
Title = $"Locate the audio file for '{entry.Book.TitleWithSubtitle}'",
AllowMultiple = false,
SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix),
FileTypeFilter = new FilePickerFileType[]
{
new("All files (*.*)") { Patterns = new[] { "*" } },
}
};
var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath();
if (selectedFile is not null)
FilePathCache.Insert(entry.AudibleProductId, selectedFile);
}
catch (Exception ex)
{
var msg = "Error saving book's location";
await MessageBox.ShowAdminAlert(null, msg, msg, ex);
}
};
#endregion
#region Convert to Mp3
var convertToMp3MenuItem = new MenuItem
{
Header = "_Convert to Mp3",
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
args.ContextMenuItems.Add(convertToMp3MenuItem);
convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook);
#endregion
}
#region Force Re-Download
if (!entry.Liberate.IsSeries)
{
var reDownloadMenuItem = new MenuItem()
{
Header = "Re-download this audiobook",
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated
};
args.ContextMenuItems.Add(reDownloadMenuItem);
reDownloadMenuItem.Click += (s, _) =>
{
//No need to persist this change. It only needs to last long for the file to start downloading
entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated;
LiberateClicked?.Invoke(s, entry.LibraryBook);
};
}
#endregion
args.ContextMenuItems.Add(new Separator());
#region View Bookmarks/Clips
if (!entry.Liberate.IsSeries)
{
var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" };
args.ContextMenuItems.Add(bookRecordMenuItem);
bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window);
}
#endregion
#region View All Series
if (entry.Book.SeriesLink.Any())
{
var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series";
var viewSeriesMenuItem = new MenuItem { Header = header };
args.ContextMenuItems.Add(viewSeriesMenuItem);
viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show();
}
#endregion
}
#endregion
@@ -428,7 +536,7 @@ namespace LibationAvalonia.Views
var displayWindow = new DescriptionDisplayDialog
{
SpawnLocation = new Point(pt.X, pt.Y),
DescriptionText = gEntry.LongDescription,
DescriptionText = gEntry.Description,
};
void CloseWindow(object o, DataGridRowEventArgs e)

View File

@@ -3,7 +3,6 @@ using AudibleUtilities;
using Avalonia.Controls;
using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media;
using Avalonia.Styling;
using Dinah.Core.StepRunner;
@@ -13,7 +12,6 @@ using LibationFileManager;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using static Avalonia.Threading.Dispatcher;
namespace LibationAvalonia
{
@@ -36,12 +34,13 @@ namespace LibationAvalonia
AutoScan = Configuration.Instance.AutoScan;
Configuration.Instance.AutoScan = false;
MainForm = mainForm;
sequence[nameof(ShowAccountDialog)] = () => UIThread.InvokeAsync(ShowAccountDialog);
sequence[nameof(ShowSettingsDialog)] = () => UIThread.InvokeAsync(ShowSettingsDialog);
sequence[nameof(ShowAccountScanning)] = () => UIThread.InvokeAsync(ShowAccountScanning);
sequence[nameof(ShowSearching)] = () => UIThread.InvokeAsync(ShowSearching);
sequence[nameof(ShowQuickFilters)] = () => UIThread.InvokeAsync(ShowQuickFilters);
sequence[nameof(ShowTourComplete)] = () => UIThread.InvokeAsync(ShowTourComplete);
var uiDispatcher = Avalonia.Threading.Dispatcher.UIThread;
sequence[nameof(ShowAccountDialog)] = () => uiDispatcher.InvokeAsync(ShowAccountDialog);
sequence[nameof(ShowSettingsDialog)] = () => uiDispatcher.InvokeAsync(ShowSettingsDialog);
sequence[nameof(ShowAccountScanning)] = () => uiDispatcher.InvokeAsync(ShowAccountScanning);
sequence[nameof(ShowSearching)] = () => uiDispatcher.InvokeAsync(ShowSearching);
sequence[nameof(ShowQuickFilters)] = () => uiDispatcher.InvokeAsync(ShowQuickFilters);
sequence[nameof(ShowTourComplete)] = () => uiDispatcher.InvokeAsync(ShowTourComplete);
}
public async Task RunAsync()
@@ -60,7 +59,7 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.accountsToolStripMenuItem);
var accountSettings = new AccountsDialog();
accountSettings.Loaded += async (_, _) => await MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account");
accountSettings.Opened += async (_, _) => await MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account");
await accountSettings.ShowDialog(MainForm);
return true;
}
@@ -74,14 +73,14 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.settingsToolStripMenuItem);
await displayControlAsync(MainForm.basicSettingsToolStripMenuItem);
var settingsDialog = await UIThread.InvokeAsync(() => new SettingsDialog());
var settingsDialog = new SettingsDialog();
var tabsToVisit = settingsDialog.tabControl.Items.OfType<TabItem>().ToList();
foreach (var tab in tabsToVisit)
tab.PropertyChanged += TabControl_PropertyChanged;
settingsDialog.Loaded += SettingsDialog_Loaded;
settingsDialog.Opened += SettingsDialog_Opened;
settingsDialog.Closing += SettingsDialog_FormClosing;
settingsDialog.saveBtn.Content = "Next Tab";
@@ -103,7 +102,7 @@ namespace LibationAvalonia
settingTabMessages.Remove(header.Text);
}
async void SettingsDialog_Loaded(object sender, RoutedEventArgs e)
async void SettingsDialog_Opened(object sender, System.EventArgs e)
{
await ShowTabPageMessageBoxAsync(tabsToVisit[0]);
}
@@ -227,7 +226,7 @@ namespace LibationAvalonia
await displayControlAsync(editQuickFiltersToolStripMenuItem);
var editQuickFilters = new EditQuickFilters();
editQuickFilters.Loaded += async (_, _) => await MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters");
editQuickFilters.Opened += async (_, _) => await MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters");
await editQuickFilters.ShowDialog(MainForm);
return true;
@@ -247,12 +246,12 @@ namespace LibationAvalonia
private async Task displayControlAsync(TemplatedControl control)
{
await UIThread.InvokeAsync(() => control.IsEnabled = false);
await UIThread.InvokeAsync(() => MainForm.productsDisplay.Focus());
await UIThread.InvokeAsync(() => flashControlAsync(control));
if (control is MenuItem menuItem) await UIThread.InvokeAsync(menuItem.Open);
control.IsEnabled = false;
MainForm.productsDisplay.Focus();
await flashControlAsync(control);
if (control is MenuItem menuItem) menuItem.Open();
await Task.Delay(500);
await UIThread.InvokeAsync(() => control.IsEnabled = true);
control.IsEnabled = true;
}
private static async Task flashControlAsync(TemplatedControl control, int flashCount = 3)

View File

@@ -0,0 +1,76 @@
using System;
using System.IO;
namespace LibationCli;
internal class ConsoleProgressBar
{
public TextWriter Output { get; }
public int MaxWidth { get; }
public char ProgressChar { get; }
public char NoProgressChar { get; }
public double? Progress
{
get => m_Progress;
set
{
m_Progress = value ?? 0;
WriteProgress();
}
}
public TimeSpan RemainingTime
{
get => m_RemainingTime;
set
{
m_RemainingTime = value;
WriteProgress();
}
}
private double m_Progress;
private TimeSpan m_RemainingTime;
private int m_LastWriteLength = 0;
private const int MAX_ETA_LEN = 10;
private readonly int m_NumProgressPieces;
public ConsoleProgressBar(
TextWriter output,
int maxWidth = 80,
char progressCharr = '#',
char noProgressChar = '.')
{
Output = output;
MaxWidth = maxWidth;
ProgressChar = progressCharr;
NoProgressChar = noProgressChar;
m_NumProgressPieces = MaxWidth - MAX_ETA_LEN - 4;
}
private void WriteProgress()
{
var numCompleted = (int)Math.Round(double.Min(100, m_Progress) * m_NumProgressPieces / 100);
var numRemaining = m_NumProgressPieces - numCompleted;
var progressBar = $"[{new string(ProgressChar, numCompleted)}{new string(NoProgressChar, numRemaining)}] ";
progressBar += RemainingTime.TotalMinutes > 1000
? "ETA ∞"
: $"ETA {(int)RemainingTime.TotalMinutes}:{RemainingTime.Seconds:D2}";
Output.Write(new string('\b', m_LastWriteLength) + progressBar);
if (progressBar.Length < m_LastWriteLength)
{
var extra = m_LastWriteLength - progressBar.Length;
Output.Write(new string(' ', extra) + new string('\b', extra));
}
m_LastWriteLength = progressBar.Length;
}
public void Clear()
=> Output.Write(
new string('\b', m_LastWriteLength) +
new string(' ', m_LastWriteLength) +
new string('\b', m_LastWriteLength));
}

View File

@@ -0,0 +1,48 @@
using AppScaffolding;
using CommandLine;
using CommandLine.Text;
namespace LibationCli;
[Verb("help", HelpText = "Display more information on a specific command.")]
internal class HelpVerb
{
/// <summary>
/// Name of the verb to get help about
/// </summary>
[Value(0, Default = "")]
public string HelpType { get; set; }
/// <summary>
/// Create a base <see cref="HelpText"/> for <see cref="LibationCli"/>
/// </summary>
public static HelpText CreateHelpText() => new HelpText
{
AutoVersion = false,
AutoHelp = false,
Heading = $"LibationCli v{LibationScaffolding.BuildVersion.ToString(3)}",
AdditionalNewLineAfterOption = true,
MaximumDisplayWidth = 80
};
/// <summary>
/// Get the <see cref="HelpType"/>'s <see cref="HelpText"/>
/// </summary>
public HelpText GetHelpText()
{
var helpText = CreateHelpText();
var result = new Parser().ParseArguments(new string[] { HelpType }, Program.VerbTypes);
if (result.TypeInfo.Current == typeof(NullInstance))
{
//HelpType is not a defined verb so get LibationCli usage
helpText.AddVerbs(Program.VerbTypes);
}
else
{
helpText.AutoHelp = true;
helpText.AddDashesToOption = true;
helpText.AddOptions(result);
}
return helpText;
}
}

View File

@@ -3,7 +3,8 @@
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net7.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>

View File

@@ -1,8 +1,5 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommandLine;
using System.Threading.Tasks;
using CommandLine;
namespace LibationCli
{

View File

@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AudibleUtilities;
using ApplicationServices;
using CommandLine;
using System;
using System.IO;
using System.Threading.Tasks;
namespace LibationCli
{
@@ -29,26 +27,38 @@ namespace LibationCli
}
*/
#endregion
[Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)]
[Option(shortName: 'x', longName: "xlsx", HelpText = "Microsoft Excel Spreadsheet", SetName = "xlsx")]
public bool xlsx { get; set; }
[Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)]
[Option(shortName: 'c', longName: "csv", HelpText = "Comma-separated values", SetName = "csv")]
public bool csv { get; set; }
[Option(shortName: 'j', longName: "json", SetName = "json", Required = true)]
[Option(shortName: 'j', longName: "json", HelpText = "JavaScript Object Notation", SetName = "json")]
public bool json { get; set; }
protected override Task ProcessAsync()
{
if (xlsx)
LibraryExporter.ToXlsx(FilePath);
if (csv)
LibraryExporter.ToCsv(FilePath);
if (json)
LibraryExporter.ToJson(FilePath);
Console.WriteLine($"Library exported to: {FilePath}");
Action<string> exporter
= csv ? LibraryExporter.ToCsv
: json ? LibraryExporter.ToJson
: xlsx ? LibraryExporter.ToXlsx
: Path.GetExtension(FilePath)?.ToLower() switch
{
".xlsx" => LibraryExporter.ToXlsx,
".csv" => LibraryExporter.ToCsv,
".json" => LibraryExporter.ToJson,
_ => null
};
if (exporter is null)
{
PrintVerbUsage($"Undefined export format for file type \"{Path.GetExtension(FilePath)}\"");
}
else
{
exporter(FilePath);
Console.WriteLine($"Library exported to: {FilePath}");
}
return Task.CompletedTask;
}
}

View File

@@ -1,10 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
using CommandLine;
using DataLayer;
using FileLiberator;
using System.Threading.Tasks;
namespace LibationCli
{

View File

@@ -1,18 +1,18 @@
using System;
using ApplicationServices;
using AudibleUtilities;
using CommandLine;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AudibleUtilities;
using CommandLine;
namespace LibationCli
{
[Verb("scan", HelpText = "Scan library. Default: scan all accounts. Optional: use 'account' flag to specify a single account.")]
public class ScanOptions : OptionsBase
{
[Value(0, MetaName = "Accounts", HelpText = "Optional: nicknames of accounts to scan.", Required = false)]
public IEnumerable<string> AccountNicknames { get; set; }
[Value(0, MetaName = "Accounts", HelpText = "Optional: user ID or nicknames of accounts to scan.", Required = false)]
public IEnumerable<string> AccountNames { get; set; }
protected override async Task ProcessAsync()
{
@@ -42,13 +42,19 @@ namespace LibationCli
private Account[] getAccounts()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll().ToArray();
var allAccounts = persister.AccountsSettings.GetAll().ToArray();
if (!AccountNicknames.Any())
return accounts;
if (!AccountNames.Any())
return allAccounts;
var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray();
var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray();
var accountNames = AccountNames.Select(n => n.ToLower()).ToArray();
var found
= allAccounts
.Where(acct => accountNames.Contains(acct.AccountName.ToLower()) || accountNames.Contains(acct.AccountId.ToLower()))
.ToArray();
var notFound = allAccounts.Except(found).ToArray();
// no accounts found. do not continue
if (!found.Any())

View File

@@ -0,0 +1,56 @@
using ApplicationServices;
using CommandLine;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace LibationCli.Options;
[Verb("search", HelpText = "Search for books in your library")]
internal class SearchOptions : OptionsBase
{
[Option('n', Default = 10, HelpText = "Number of search results per page")]
public int NumResultsPerPage { get; set; }
[Value(0, MetaName = "query", Required = true, HelpText = "Lucene search string")]
public IEnumerable<string> Query { get; set; }
protected override Task ProcessAsync()
{
var query = string.Join(" ", Query).Trim('\"');
var results = SearchEngineCommands.Search(query).Docs.ToList();
Console.WriteLine($"Found {results.Count} matching results.");
string nextPrompt = "Press any key for the next " + NumResultsPerPage + " results or Esc for all results";
bool waitForNextBatch = true;
for (int i = 0; i < results.Count; i += NumResultsPerPage)
{
var sb = new StringBuilder();
for (int j = i; j < int.Min(results.Count, i + NumResultsPerPage); j++)
sb.AppendLine(getDocDisplay(results[j].Doc));
Console.Write(sb.ToString());
if (waitForNextBatch)
{
Console.Write(nextPrompt);
waitForNextBatch = Console.ReadKey(intercept: true).Key != ConsoleKey.Escape;
ReplaceConsoleText(Console.Out, nextPrompt.Length, "");
Console.CursorLeft = 0;
}
}
return Task.CompletedTask;
}
private static string getDocDisplay(Lucene.Net.Documents.Document doc)
{
var title = doc.GetField("title");
var id = doc.GetField("_ID_");
return $"[{id.StringValue}] - {title.StringValue}";
}
}

View File

@@ -1,37 +1,70 @@
using System;
using ApplicationServices;
using CommandLine;
using DataLayer;
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AudibleUtilities;
using CommandLine;
namespace LibationCli
{
[Verb("set-status", HelpText = """
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
[Verb("set-status", HelpText = """
Set download statuses throughout library based on whether each book's audio file can be found.
""")]
public class SetDownloadStatusOptions : OptionsBase
{
[Option(shortName: 'd', longName: "downloaded", Required = true)]
public bool SetDownloaded { get; set; }
public class SetDownloadStatusOptions : OptionsBase
{
//https://github.com/commandlineparser/commandline/wiki/Option-Groups
[Option(shortName: 'd', longName: "downloaded", Group = "Download Status", HelpText = "set download status to 'Downloaded'")]
public bool SetDownloaded { get; set; }
[Option(shortName: 'n', longName: "not-downloaded", Required = true)]
public bool SetNotDownloaded { get; set; }
[Option(shortName: 'n', longName: "not-downloaded", Group = "Download Status", HelpText = "set download status to 'Not Downloaded'")]
public bool SetNotDownloaded { get; set; }
protected override async Task ProcessAsync()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
[Option("force", HelpText = "Set the download status regardless of whether the book's audio file can be found. Only one download status option may be used with this option.")]
public bool Force { get; set; }
var bulkSetStatus = new BulkSetDownloadStatus(libraryBooks, SetDownloaded, SetNotDownloaded);
await Task.Run(() => bulkSetStatus.Discover());
bulkSetStatus.Execute();
[Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books on which to set download status.")]
public IEnumerable<string> Asins { get; set; }
foreach (var msg in bulkSetStatus.Messages)
Console.WriteLine(msg);
}
}
protected override async Task ProcessAsync()
{
if (Force && SetDownloaded && SetNotDownloaded)
{
PrintVerbUsage("ERROR:\nWhen run with --force option, only one download status option may be used.");
return;
}
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
if (Asins.Any())
{
var asins = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray();
libraryBooks = libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asins)).ToList();
if (libraryBooks.Count == 0)
{
Console.Error.WriteLine("Could not find any books matching asins");
return;
}
}
if (Force)
{
var status = SetDownloaded ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
var num = libraryBooks.UpdateBookStatus(status);
Console.WriteLine($"Set LiberatedStatus to '{status}' on {"book".PluralizeWithCount(num)}");
}
else
{
var bulkSetStatus = new BulkSetDownloadStatus(libraryBooks, SetDownloaded, SetNotDownloaded);
await Task.Run(() => bulkSetStatus.Discover());
bulkSetStatus.Execute();
foreach (var msg in bulkSetStatus.Messages)
Console.WriteLine(msg);
}
}
}
}

View File

@@ -0,0 +1,59 @@
using AppScaffolding;
using CommandLine;
using System;
using System.Threading.Tasks;
namespace LibationCli.Options;
[Verb("version", HelpText = "Display version information.")]
internal class VersionOptions : OptionsBase
{
[Option('c', "check", Required = false, HelpText = "Check if an upgrade is available")]
public bool CheckForUpgrade { get; set; }
protected override Task ProcessAsync()
{
const string checkingForUpgrade = "Checking for upgrade...";
Console.WriteLine($"Libation {LibationScaffolding.Variety} v{LibationScaffolding.BuildVersion.ToString(3)}");
if (CheckForUpgrade)
{
Console.Write(checkingForUpgrade);
var origColor = Console.ForegroundColor;
try
{
var upgradeProperties = LibationScaffolding.GetLatestRelease();
if (upgradeProperties is null)
{
Console.ForegroundColor = ConsoleColor.Green;
ReplaceConsoleText(Console.Out, checkingForUpgrade.Length, "No available upgrade");
Console.WriteLine();
}
else
{
Console.ForegroundColor = ConsoleColor.Red;
ReplaceConsoleText(Console.Out, checkingForUpgrade.Length, $"Upgrade Available: v{upgradeProperties.LatestRelease.ToString(3)}");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine(upgradeProperties.ZipUrl);
Console.WriteLine();
Console.WriteLine("Release Notes");
Console.WriteLine("=============");
Console.WriteLine(upgradeProperties.Notes);
}
}
catch
{
Console.Error.WriteLine("ERROR CHECKING FOR UPGRADE");
}
finally
{
Console.ForegroundColor = origColor;
}
}
return Task.CompletedTask;
}
}

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CommandLine;
using System;
using System.IO;
using System.Reflection;
using System.Threading.Tasks;
using CommandLine;
namespace LibationCli
{
@@ -17,15 +17,34 @@ namespace LibationCli
catch (Exception ex)
{
Environment.ExitCode = (int)ExitCode.RunTimeError;
Console.Error.WriteLine("ERROR");
Console.Error.WriteLine("=====");
Console.Error.WriteLine(ex.Message);
Console.Error.WriteLine();
Console.Error.WriteLine(ex.StackTrace);
PrintVerbUsage(new string[]
{
"ERROR",
"=====",
ex.Message,
"",
ex.StackTrace
});
}
}
protected void PrintVerbUsage(params string[] linesBeforeUsage)
{
var verb = GetType().GetCustomAttribute<VerbAttribute>().Name;
var helpText = new HelpVerb { HelpType = verb }.GetHelpText();
helpText.AddPreOptionsLines(linesBeforeUsage);
helpText.AddPreOptionsLine("");
helpText.AddPreOptionsLine($"{verb} Usage:");
Console.Error.WriteLine(helpText);
}
protected static void ReplaceConsoleText(TextWriter writer, int previousLength, string newText)
{
writer.Write(new string('\b', previousLength));
writer.Write(newText);
writer.Write(new string(' ', int.Max(0, previousLength - newText.Length)));
}
protected abstract Task ProcessAsync();
}
}

View File

@@ -1,23 +1,34 @@
using System;
using ApplicationServices;
using CommandLine;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using CommandLine;
using DataLayer;
using FileLiberator;
namespace LibationCli
{
public abstract class ProcessableOptionsBase : OptionsBase
{
[Value(0, MetaName = "[asins]", HelpText = "Optional product IDs of books to process.")]
public IEnumerable<string> Asins { get; set; }
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
where TProcessable : Processable, new()
{
var progressBar = new ConsoleProgressBar(Console.Out);
var strProc = new TProcessable();
strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
strProc.Completed += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
strProc.Completed += (o, e) =>
{
progressBar.Clear();
Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
};
strProc.Completed += (s, e) =>
{
@@ -32,13 +43,28 @@ namespace LibationCli
}
};
strProc.StreamingTimeRemaining += (_, e) => progressBar.RemainingTime = e;
strProc.StreamingProgressChanged += (_, e) => progressBar.Progress = e.ProgressPercentage;
return strProc;
}
protected static async Task RunAsync(Processable Processable)
protected async Task RunAsync(Processable Processable)
{
foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking()))
await ProcessOneAsync(Processable, libraryBook, false);
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
if (Asins.Any())
{
var asinsLower = Asins.Select(a => a.TrimStart('[').TrimEnd(']').ToLower()).ToArray();
foreach (var lb in libraryBooks.Where(lb => lb.Book.AudibleProductId.ToLower().In(asinsLower)))
await ProcessOneAsync(Processable, lb, true);
}
else
{
foreach (var lb in Processable.GetValidLibraryBooks(libraryBooks))
await ProcessOneAsync(Processable, lb, false);
}
var done = "Done. All books have been processed";
Console.WriteLine(done);

View File

@@ -1,12 +1,9 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
using CommandLine;
using CommandLine.Text;
using Dinah.Core;
using Dinah.Core.Collections;
using Dinah.Core.Collections.Generic;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace LibationCli
{
@@ -19,47 +16,63 @@ namespace LibationCli
}
class Program
{
static async Task<int> Main(string[] args)
public readonly static Type[] VerbTypes = Setup.LoadVerbs();
static async Task Main(string[] args)
{
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
Setup.Initialize();
var types = Setup.LoadVerbs();
#if DEBUG
string input = null;
string input = "";
//input = " set-status -n --force B017V4IM1G";
//input = " liberate B017V4IM1G";
//input = " convert B017V4IM1G";
//input = " search \"-liberated\"";
//input = " export --help";
//input = " version --check";
//input = " scan rmcrackan";
//input = " help set-status";
//input = " liberate ";
// note: this hack will fail for quoted file paths with spaces because it will break on those spaces
if (!string.IsNullOrWhiteSpace(input))
args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var setBreakPointHere = args;
#endif
var result = Parser.Default.ParseArguments(args, types);
var result = new Parser(ConfigureParser).ParseArguments(args, VerbTypes);
// if successfully parsed
// async: run parsed options
await result.WithParsedAsync<OptionsBase>(opt => opt.Run());
if (result.Value is HelpVerb helper)
Console.Error.WriteLine(helper.GetHelpText());
else if (result.TypeInfo.Current == typeof(HelpVerb))
{
//Error parsing the command, but the verb type was identified as HelpVerb
//Print LibationCli usage
var helpText = HelpVerb.CreateHelpText();
helpText.AddVerbs(VerbTypes);
Console.Error.WriteLine(helpText);
}
else if (result.Errors.Any())
HandleErrors(result);
else
{
//Everything parsed correctly, so execute the command
// if not successfully parsed
// sync: handle parse errors
result.WithNotParsed(errors => HandleErrors(result, errors));
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
Setup.Initialize();
return Environment.ExitCode;
// if successfully parsed
// async: run parsed options
await result.WithParsedAsync<OptionsBase>(opt => opt.Run());
}
}
private static void HandleErrors(ParserResult<object> result, IEnumerable<Error> errors)
private static void HandleErrors(ParserResult<object> result)
{
var errorsList = errors.ToList();
var errorsList = result.Errors.ToList();
if (errorsList.Any(e => e.Tag.In(ErrorType.HelpRequestedError, ErrorType.VersionRequestedError, ErrorType.HelpVerbRequestedError)))
{
Environment.ExitCode = (int)ExitCode.NonRunNonError;
@@ -67,17 +80,36 @@ namespace LibationCli
}
Environment.ExitCode = (int)ExitCode.ParseError;
var helpText = HelpVerb.CreateHelpText();
if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError)))
if (errorsList.OfType<NoVerbSelectedError>().Any())
{
Console.Error.WriteLine("No verb selected");
return;
//Print LibationCli usage
helpText.AddPreOptionsLine("No verb selected");
helpText.AddVerbs(VerbTypes);
}
else
{
//print the specified verb's usage
helpText.AddDashesToOption = true;
helpText.AutoHelp = true;
var helpText = HelpText.AutoBuild(result,
h => HelpText.DefaultParsingErrorsHandler(result, h),
e => e);
Console.WriteLine(helpText);
if (!errorsList.OfType<UnknownOptionError>().Any(o => o.Token.ToLower() == "help"))
{
//verb was not executed with the "--help" option,
//so print verb option parsing error info.
helpText = HelpText.DefaultParsingErrorsHandler(result, helpText);
}
helpText.AddOptions(result);
}
Console.Error.WriteLine(helpText);
}
private static void ConfigureParser(ParserSettings settings)
{
settings.AutoVersion = false;
settings.AutoHelp = false;
}
}
}

View File

@@ -1,14 +1,8 @@
using System;
using System.Collections.Generic;
using AppScaffolding;
using CommandLine;
using System;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AppScaffolding;
using CommandLine;
using CommandLine.Text;
using Dinah.Core;
using Dinah.Core.Collections;
using Dinah.Core.Collections.Generic;
namespace LibationCli
{
@@ -23,33 +17,15 @@ namespace LibationCli
//***********************************************//
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
#if !DEBUG
checkForUpdate();
#if classic
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
#else
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
#endif
}
private static void checkForUpdate()
{
var upgradeProperties = LibationScaffolding.GetLatestRelease();
if (upgradeProperties is null)
return;
var origColor = Console.ForegroundColor;
try
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"UPDATE AVAILABLE @ {upgradeProperties.ZipUrl}");
}
finally
{
Console.ForegroundColor = origColor;
}
}
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)

View File

@@ -9,11 +9,12 @@ using System.Threading.Tasks;
using System.Threading;
using FileManager;
#nullable enable
namespace LibationFileManager
{
public abstract class AudibleFileStorage
{
protected abstract LongPath GetFilePathCustom(string productId);
protected abstract LongPath? GetFilePathCustom(string productId);
protected abstract List<LongPath> GetFilePathsCustom(string productId);
#region static
@@ -57,7 +58,7 @@ namespace LibationFileManager
regexTemplate = $@"{{0}}.*?\.({extAggr})$";
}
protected LongPath GetFilePath(string productId)
protected LongPath? GetFilePath(string productId)
{
// primary lookup
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
@@ -87,7 +88,7 @@ namespace LibationFileManager
{
internal AaxcFileStorage() : base(FileType.AAXC) { }
protected override LongPath GetFilePathCustom(string productId)
protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
@@ -104,9 +105,9 @@ namespace LibationFileManager
public class AudioFileStorage : AudibleFileStorage
{
internal AudioFileStorage() : base(FileType.Audio)
=> BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
=> BookDirectoryFiles ??= newBookDirectoryFiles();
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
private static BackgroundFileSystem? BookDirectoryFiles { get; set; }
private static object bookDirectoryFilesLocker { get; } = new();
private static EnumerationOptions enumerationOptions { get; } = new()
{
@@ -115,23 +116,42 @@ namespace LibationFileManager
MatchCasing = MatchCasing.CaseInsensitive
};
protected override LongPath GetFilePathCustom(string productId)
protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
private static BackgroundFileSystem newBookDirectoryFiles()
=> new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
protected override List<LongPath> GetFilePathsCustom(string productId)
{
// If user changed the BooksDirectory: reinitialize
lock (bookDirectoryFilesLocker)
if (BooksDirectory != BookDirectoryFiles.RootDirectory)
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
if (BooksDirectory != BookDirectoryFiles?.RootDirectory)
BookDirectoryFiles = newBookDirectoryFiles();
var regex = GetBookSearchRegex(productId);
return BookDirectoryFiles.FindFiles(regex);
var regex = GetBookSearchRegex(productId);
//Find all extant files matching the productId
//using both the file system and the file path cache
return
FilePathCache
.GetFiles(productId)
.Where(c => c.fileType == FileType.Audio && File.Exists(c.path))
.Select(c => c.path)
.Union(BookDirectoryFiles.FindFiles(regex))
.ToList();
}
public void Refresh() => BookDirectoryFiles.RefreshFiles();
public void Refresh()
{
if (BookDirectoryFiles is null)
lock (bookDirectoryFilesLocker)
BookDirectoryFiles = newBookDirectoryFiles();
else
BookDirectoryFiles?.RefreshFiles();
}
public LongPath GetPath(string productId) => GetFilePath(productId);
public LongPath? GetPath(string productId) => GetFilePath(productId);
public static async IAsyncEnumerable<FilePathCache.CacheEntry> FindAudiobooksAsync(LongPath searchDirectory, [EnumeratorCancellation] CancellationToken cancellationToken)
{
@@ -142,7 +162,7 @@ namespace LibationFileManager
if (cancellationToken.IsCancellationRequested)
yield break;
FilePathCache.CacheEntry audioFile = default;
FilePathCache.CacheEntry? audioFile = default;
try
{

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Text;
using System.Threading.Tasks;
#nullable enable
namespace LibationFileManager
{
[Flags]
@@ -20,7 +21,7 @@ namespace LibationFileManager
public static bool IsWindows { get; } = OperatingSystem.IsWindows();
public static bool IsLinux { get; } = OperatingSystem.IsLinux();
public static bool IsMacOs { get; } = OperatingSystem.IsMacOS();
public static Version LibationVersion { get; private set; }
public static Version? LibationVersion { get; private set; }
public static void SetLibationVersion(Version version) => LibationVersion = version;
public static OS OS { get; }

View File

@@ -0,0 +1,36 @@
using System.Collections.Generic;
using System.Collections.ObjectModel;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
public static ReadOnlyDictionary<string, string> HelpText { get; } = new Dictionary<string, string>
{
{ nameof(CombineNestedChapterTitles),"""
If the book has nested chapters, e.g. a chapter named "Part 1"
that contains chapters "Chapter 1" and "Chapter 2", then combine
the chapter titles like the following example:
Part 1: Chapter 1
Part 1: Chapter 2
"""},
{nameof(AllowLibationFixup), """
In addition to the options that are enabled if you allow
"fixing up" the audiobook, it does the following:
* Sets the ©gen metadata tag for the genres.
* Adds the TCOM (@wrt in M4B files) metadata tag for the narrators.
* Unescapes the copyright symbol (replace &#169; with ©)
* Replaces the recording copyright (P) string with
* Adds various other metadata tags recognized by AudiobookShelf
* Sets the embedded cover art image with cover art retrieved from Audible
""" },
}
.AsReadOnly();
public static string? GetHelpText(string settingName)
=> HelpText.TryGetValue(settingName, out var value) ? value : null;
}
}

View File

@@ -5,11 +5,12 @@ using System.IO;
using System.Linq;
using Dinah.Core;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
public static string ProcessDirectory { get; } = Path.GetDirectoryName(Exe.FileLocationOnDisk);
public static string ProcessDirectory { get; } = Path.GetDirectoryName(Exe.FileLocationOnDisk)!;
public static string AppDir_Relative => $@".{Path.PathSeparator}{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
@@ -36,7 +37,7 @@ namespace LibationFileManager
LibationFiles = 5
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
private static List<(KnownDirectories directory, Func<string?> getPathFunc)> directoryOptionsPaths { get; } = new()
{
(KnownDirectories.None, () => null),
(KnownDirectories.UserProfile, () => UserProfile),
@@ -47,7 +48,7 @@ namespace LibationFileManager
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
};
public static string GetKnownDirectoryPath(KnownDirectories directory)
public static string? GetKnownDirectoryPath(KnownDirectories directory)
{
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
return dirFunc == default ? null : dirFunc.getPathFunc();

View File

@@ -7,6 +7,7 @@ using Newtonsoft.Json;
using Serilog;
using Dinah.Core.Logging;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
@@ -44,7 +45,7 @@ namespace LibationFileManager
}
}
private static string libationFilesPathCache { get; set; }
private static string? libationFilesPathCache { get; set; }
/// <summary>
/// Try to find appsettings.json in the following locations:
@@ -124,7 +125,10 @@ namespace LibationFileManager
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
// verify from live file. no try/catch. want failures to be visible
var jObjFinal = JObject.Parse(File.ReadAllText(AppsettingsJsonFile));
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
if (jObjFinal[LIBATION_FILES_KEY]?.Value<string>() is not string valueFinal)
throw new InvalidDataException($"{LIBATION_FILES_KEY} not found in {AppsettingsJsonFile}");
return valueFinal;
}

View File

@@ -1,19 +1,18 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using Dinah.Core;
using Dinah.Core.Logging;
using FileManager;
using Microsoft.Extensions.Configuration;
using Serilog;
using Serilog.Events;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
{
private IConfigurationRoot configuration;
private IConfigurationRoot? configuration;
public void ConfigureLogging()
{
@@ -31,20 +30,20 @@ namespace LibationFileManager
{
get
{
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
var logLevelStr = Settings.GetStringFromJsonPath("Serilog", "MinimumLevel");
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
}
set
{
OnPropertyChanging(nameof(LogLevel), LogLevel, value);
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
var valueWasChanged = Settings.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Debug("LogLevel.set attempt. No change");
return;
}
configuration.Reload();
configuration?.Reload();
OnPropertyChanged(nameof(LogLevel), value);

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Linq;
using System.Runtime.CompilerServices;
@@ -8,6 +9,7 @@ using FileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
@@ -18,32 +20,52 @@ namespace LibationFileManager
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
private PersistentDictionary persistentDictionary;
private PersistentDictionary? persistentDictionary;
public T GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "") => persistentDictionary.GetNonString(propertyName, defaultValue);
public object GetObject([CallerMemberName] string propertyName = "") => persistentDictionary.GetObject(propertyName);
public string GetString(string defaultValue = null, [CallerMemberName] string propertyName = "") => persistentDictionary.GetString(propertyName, defaultValue);
public void SetNonString(object newValue, [CallerMemberName] string propertyName = "")
private PersistentDictionary Settings
{
get
{
if (persistentDictionary is null)
throw new InvalidOperationException($"{nameof(persistentDictionary)} must first be set by accessing {nameof(LibationFiles)} or calling {nameof(SettingsFileIsValid)}");
return persistentDictionary;
}
}
public bool RemoveProperty(string propertyName) => Settings.RemoveProperty(propertyName);
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "")
=> Settings.GetNonString(propertyName, defaultValue);
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string? defaultValue = null, [CallerMemberName] string propertyName = "")
=> Settings.GetString(propertyName, defaultValue);
public object? GetObject([CallerMemberName] string propertyName = "") => Settings.GetObject(propertyName);
public void SetNonString(object? newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
if (existing?.Equals(newValue) is true) return;
OnPropertyChanging(propertyName, existing, newValue);
persistentDictionary.SetNonString(propertyName, newValue);
Settings.SetNonString(propertyName, newValue);
OnPropertyChanged(propertyName, newValue);
}
public void SetString(string newValue, [CallerMemberName] string propertyName = "")
public void SetString(string? newValue, [CallerMemberName] string propertyName = "")
{
var existing = getExistingValue(propertyName);
if (existing?.Equals(newValue) is true) return;
OnPropertyChanging(propertyName, existing, newValue);
persistentDictionary.SetString(propertyName, newValue);
Settings.SetString(propertyName, newValue);
OnPropertyChanged(propertyName, newValue);
}
private object getExistingValue(string propertyName)
private object? getExistingValue(string propertyName)
{
var property = GetType().GetProperty(propertyName);
if (property is not null) return property.GetValue(this);
@@ -51,16 +73,16 @@ namespace LibationFileManager
}
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
public void SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
var settingWasChanged = Settings.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public static string GetDescription(string propertyName)
public static string? GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
@@ -71,16 +93,25 @@ namespace LibationFileManager
return attribute?.Description;
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
public bool Exists(string propertyName) => Settings.Exists(propertyName);
[Description("Set cover art as the folder's icon. (Windows and macOS only)")]
[Description("Set cover art as the folder's icon.")]
public bool UseCoverAsFolderIcon { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Save audiobook metadata to metadata.json")]
public bool SaveMetadataToFile { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Book display grid size")]
public float GridScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); }
[Description("Book display font size")]
public float GridFontScaleFactor { get => float.Min(2, float.Max(0.5f, GetNonString(defaultValue: 1f))); set => SetNonString(value); }
[Description("Use the beta version of Libation\r\nNew and experimental features, but probably buggy.\r\n(requires restart to take effect)")]
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")]
public LongPath Books { get => GetString(); set => SetString(value); }
public LongPath? Books { get => GetString(); set => SetString(value); }
[Description("Overwrite existing files if they already exist?")]
public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); }
@@ -164,6 +195,9 @@ namespace LibationFileManager
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Combine nested chapter titles")]
public bool CombineNestedChapterTitles { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Download clips and bookmarks?")]
public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); }
@@ -205,6 +239,16 @@ namespace LibationFileManager
Added
}
[JsonConverter(typeof(StringEnumConverter))]
public enum DownloadQuality
{
High,
Normal
}
[Description("Audio quality to request from Audible:")]
public DownloadQuality FileDownloadQuality { get => GetNonString(defaultValue: DownloadQuality.High); set => SetNonString(value); }
[Description("Set file \"created\" timestamp to:")]
public DateTimeSource CreationTime { get => GetNonString(defaultValue: DateTimeSource.File); set => SetNonString(value); }

View File

@@ -1,5 +1,6 @@
using System.Collections.Generic;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration
@@ -9,17 +10,17 @@ namespace LibationFileManager
* and be sure to clone it before returning. This allows Configuration to
* accurately detect if any of the Dictionary's elements have changed.
*/
private class EquatableDictionary<TKey, TValue> : Dictionary<TKey, TValue>
private class EquatableDictionary<TKey, TValue> : Dictionary<TKey, TValue> where TKey : notnull
{
public EquatableDictionary() { }
public EquatableDictionary(IEnumerable<KeyValuePair<TKey, TValue>> keyValuePairs) : base(keyValuePairs) { }
public EquatableDictionary<TKey, TValue> Clone() => new(this);
public override bool Equals(object obj)
public override bool Equals(object? obj)
{
if (obj is Dictionary<TKey, TValue> dic && Count == dic.Count)
{
foreach (var pair in this)
if (!dic.TryGetValue(pair.Key, out var value) || !pair.Value.Equals(value))
if (!dic.TryGetValue(pair.Key, out var value) || pair.Value?.Equals(value) is not true)
return false;
return true;

View File

@@ -4,7 +4,7 @@ using System.Linq;
using Dinah.Core;
using FileManager;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
@@ -24,9 +24,12 @@ namespace LibationFileManager
if (!Directory.Exists(booksDir))
{
if (Path.GetDirectoryName(settingsFile) is not string dir)
throw new DirectoryNotFoundException(settingsFile);
//"Books" is not null, so setup has already been run.
//Since Books can't be found, try to create it in Libation settings folder
booksDir = Path.Combine(Path.GetDirectoryName(settingsFile), nameof(Books));
booksDir = Path.Combine(dir, nameof(Books));
try
{
Directory.CreateDirectory(booksDir);

View File

@@ -6,6 +6,7 @@ using Dinah.Core.Collections.Immutable;
using FileManager;
using Newtonsoft.Json;
#nullable enable
namespace LibationFileManager
{
public static class FilePathCache
@@ -14,8 +15,8 @@ namespace LibationFileManager
private const string FILENAME = "FileLocations.json";
public static event EventHandler<CacheEntry> Inserted;
public static event EventHandler<CacheEntry> Removed;
public static event EventHandler<CacheEntry>? Inserted;
public static event EventHandler<CacheEntry>? Removed;
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
@@ -51,16 +52,16 @@ namespace LibationFileManager
.Select(entry => (entry.FileType, entry.Path))
.ToList();
public static LongPath GetFirstPath(string id, FileType type)
public static LongPath? GetFirstPath(string id, FileType type)
=> getEntries(entry => entry.Id == id && entry.FileType == type)
?.FirstOrDefault()
?.Path;
private static List<CacheEntry> getEntries(Func<CacheEntry, bool> predicate)
private static IEnumerable<CacheEntry> getEntries(Func<CacheEntry, bool> predicate)
{
var entries = cache.Where(predicate).ToList();
if (entries is null || !entries.Any())
return null;
return Enumerable.Empty<CacheEntry>();
remove(entries.Where(e => !File.Exists(e.Path)).ToList());

View File

@@ -2,9 +2,9 @@
using System.Diagnostics;
using System.Threading.Tasks;
#nullable enable
namespace LibationFileManager
{
#nullable enable
public interface IInteropFunctions
{
/// <summary>

Some files were not shown because too many files have changed in this diff Show More