mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 02:18:06 -05:00
Compare commits
72 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9720a573c7 | ||
|
|
1cf01aa92a | ||
|
|
4df9e5abbf | ||
|
|
9243aa47e7 | ||
|
|
c69f41a2a6 | ||
|
|
27c74e52ca | ||
|
|
bfa7f5cca9 | ||
|
|
22a3dcbc1f | ||
|
|
ec9d11cf52 | ||
|
|
fbc29dfb0a | ||
|
|
03d30ff6af | ||
|
|
ecfe0dc033 | ||
|
|
f2d475a9b0 | ||
|
|
86124fc609 | ||
|
|
db2b10d2a4 | ||
|
|
83402028fd | ||
|
|
423b5312f7 | ||
|
|
3be7d8e825 | ||
|
|
29803c6ba0 | ||
|
|
bb05847b25 | ||
|
|
5219ad53e1 | ||
|
|
30aa691aae | ||
|
|
83fa73cef5 | ||
|
|
2195574422 | ||
|
|
74ce408c8b | ||
|
|
85be15b843 | ||
|
|
b4b85cd485 | ||
|
|
0093968537 | ||
|
|
1b09b1fd48 | ||
|
|
ac87d70613 | ||
|
|
a5d98364fa | ||
|
|
ca0e639a19 | ||
|
|
b0e3022988 | ||
|
|
6765c2bfa7 | ||
|
|
94d3742317 | ||
|
|
bd3e833dc1 | ||
|
|
a386ace0e6 | ||
|
|
8221d7e202 | ||
|
|
fa92946d20 | ||
|
|
6d13325c4f | ||
|
|
7a9c6720c7 | ||
|
|
697f797509 | ||
|
|
ec9854212a | ||
|
|
46f6ba1710 | ||
|
|
7347244f0a | ||
|
|
c29c4c470c | ||
|
|
ee51fd9da6 | ||
|
|
2c4705de6e | ||
|
|
b4aa220051 | ||
|
|
4ab6da132b | ||
|
|
b006429a53 | ||
|
|
54d157d244 | ||
|
|
a4dfdf80e4 | ||
|
|
d8c90bc745 | ||
|
|
46accddd2d | ||
|
|
f40ecbc07e | ||
|
|
536982cb5f | ||
|
|
ea3d96329b | ||
|
|
e87fcbb16f | ||
|
|
541cf79b6f | ||
|
|
55fa82f92e | ||
|
|
4a0c2b2180 | ||
|
|
c77fe5d561 | ||
|
|
359d082ffd | ||
|
|
017bdba404 | ||
|
|
d4bf13b3fd | ||
|
|
87b695b2de | ||
|
|
222b16113e | ||
|
|
75c07c3209 | ||
|
|
e640edee7f | ||
|
|
6c48fc1f5e | ||
|
|
e5708a382b |
1
.github/workflows/build-windows.yml
vendored
1
.github/workflows/build-windows.yml
vendored
@@ -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 `
|
||||
|
||||
@@ -28,6 +28,15 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
|
||||
|
||||
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
|
||||
|
||||
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
|
||||
|
||||
* Adds the `TCOM` (`@wrt` in M4B files) metadata tag for the narrators.
|
||||
* Sets the `©gen` metadata tag for the genres.
|
||||
* Unescapes the copyright symbol (replace `©` with `©`)
|
||||
* Replaces the recording copyright `(P)` string with `℗`
|
||||
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
|
||||
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
|
||||
|
||||
### Command Line Interface
|
||||
|
||||
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
|
||||
|
||||
@@ -11,7 +11,7 @@ These templates apply to both GUI and CLI.
|
||||
- [Tag Formatters](#tag-formatters)
|
||||
- [Text Formatters](#text-formatters)
|
||||
- [Name List Formatters](#name-list-formatters)
|
||||
- [Integer Formatters](#integer-formatters)
|
||||
- [Number Formatters](#number-formatters)
|
||||
- [Date Formatters](#date-formatters)
|
||||
|
||||
|
||||
@@ -25,29 +25,32 @@ These tags will be replaced in the template with the audiobook's values.
|
||||
|Tag|Description|Type|
|
||||
|-|-|-|
|
||||
|\<id\> **†**|Audible book ID (ASIN)|Text|
|
||||
|\<title\>|Full title|Text|
|
||||
|\<title\>|Full title with subtitle|Text|
|
||||
|\<title short\>|Title. Stop at first colon|Text|
|
||||
|\<audible title\>|Audible's title (does not include subtitle)|Text|
|
||||
|\<audible subtitle\>|Audible's subtitle|Text|
|
||||
|\<author\>|Author(s)|Name List|
|
||||
|\<first author\>|First author|Text|
|
||||
|\<narrator\>|Narrator(s)|Name List|
|
||||
|\<first narrator\>|First narrator|Text|
|
||||
|\<series\>|Name of series|Text|
|
||||
|\<series#\>|Number order in series|Text|
|
||||
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|
||||
|\<samplerate\>|File's original audio sample rate|Integer|
|
||||
|\<channels\>|Number of audio channels|Integer|
|
||||
|\<series#\>|Number order in series|Number|
|
||||
|\<bitrate\>|File's original bitrate (Kbps)|Number|
|
||||
|\<samplerate\>|File's original audio sample rate|Number|
|
||||
|\<channels\>|Number of audio channels|Number|
|
||||
|\<account\>|Audible account of this book|Text|
|
||||
|\<account nickname\>|Audible account nickname of this book|Text|
|
||||
|\<locale\>|Region/country|Text|
|
||||
|\<year\>|Year published|Integer|
|
||||
|\<year\>|Year published|Number|
|
||||
|\<language\>|Book's language|Text|
|
||||
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|
||||
|\<file date\>|File creation date/time.|DateTime|
|
||||
|\<pub date\>|Audiobook publication date|DateTime|
|
||||
|\<date added\>|Date the book added to your Audible account|DateTime|
|
||||
|\<ch count\> **‡**|Number of chapters|Integer|
|
||||
|\<ch count\> **‡**|Number of chapters|Number|
|
||||
|\<ch title\> **‡**|Chapter title|Text|
|
||||
|\<ch#\> **‡**|Chapter number|Integer|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
|
||||
|\<ch#\> **‡**|Chapter number|Number|
|
||||
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number|
|
||||
|
||||
**†** Does not support custom formatting
|
||||
|
||||
@@ -63,6 +66,9 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
|
||||
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|
||||
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|
||||
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|
||||
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|
||||
|
||||
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
|
||||
|
||||
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
|
||||
|
||||
@@ -74,7 +80,7 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
||||
|
||||
|
||||
# Tag Formatters
|
||||
**Text**, **Name List**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
|
||||
|
||||
## Text Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
@@ -85,15 +91,18 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|
||||
## Name List Formatters
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||
|format(\{T \| F \| M \| L \| S\})|Formats the human name using the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
||||
|sort(F \| M \| L)|Sorts the names by first, middle, or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|
||||
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<author[format({L}, {F})`<br>`separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|
||||
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|
||||
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
|
||||
|
||||
## Integer Formatters
|
||||
## Number Formatters
|
||||
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|
||||
|Formatter|Description|Example Usage|Example Result|
|
||||
|-|-|-|-|
|
||||
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|
||||
|0|Replaces the zero with the corresponding digit if one<br>is present; otherwise, zero appears in the result string.|\<series#\[000.0\]\>|001.0|
|
||||
|#|Replaces the "#" symbol with the corresponding digit if one<br> is present; otherwise, no digit appears in the result string|\<series#\[00.##\]\>|01|
|
||||
|
||||
## Date Formatters
|
||||
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="1.0.3" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="1.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -47,10 +47,38 @@ namespace AaxDecrypter
|
||||
if (DownloadOptions.FixupFile)
|
||||
{
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
|
||||
AaxFile.AppleTags.AppleListBox.EditOrAddTag("©wrt", AaxFile.AppleTags.Narrator);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
|
||||
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("©", "©");
|
||||
|
||||
//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.
|
||||
|
||||
@@ -190,7 +190,11 @@ namespace AaxDecrypter
|
||||
//Write aax decryption key
|
||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||
FileUtility.SaferDelete(keyPath);
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
|
||||
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV))
|
||||
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}");
|
||||
else
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
|
||||
@@ -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; }
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
|
||||
@@ -6,6 +7,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
public static class MpegUtil
|
||||
{
|
||||
private const string TagDomain = "com.pilabor.tone";
|
||||
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
|
||||
{
|
||||
double bitrateMultiple = 1;
|
||||
@@ -36,6 +38,21 @@ 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -128,7 +129,7 @@ namespace AaxDecrypter
|
||||
|
||||
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 +142,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
if (ContentLength != 0 && WritePosition == ContentLength)
|
||||
{
|
||||
_backgroundDownloadTask = Task.CompletedTask;
|
||||
DownloadTask = Task.CompletedTask;
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -167,7 +168,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 +236,6 @@ namespace AaxDecrypter
|
||||
_writeFile.Close();
|
||||
_downloadedPiece.Set();
|
||||
OnUpdate();
|
||||
DownloadCompleted?.Invoke(this, null);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -256,7 +257,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 +281,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 +307,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 +327,7 @@ namespace AaxDecrypter
|
||||
if (disposing && !disposed)
|
||||
{
|
||||
_cancellationSource.Cancel();
|
||||
_backgroundDownloadTask?.GetAwaiter().GetResult();
|
||||
DownloadTask?.GetAwaiter().GetResult();
|
||||
_downloadedPiece?.Dispose();
|
||||
_cancellationSource?.Dispose();
|
||||
_readFile.Dispose();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>10.3.2.1</Version>
|
||||
<Version>10.5.2.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="6.0.0" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -10,7 +10,7 @@ namespace ApplicationServices
|
||||
{
|
||||
public class BulkSetDownloadStatus
|
||||
{
|
||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
|
||||
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
|
||||
|
||||
public int Count => actionSets.Count;
|
||||
|
||||
@@ -33,7 +33,7 @@ namespace ApplicationServices
|
||||
var bookExistsList = _libraryBooks
|
||||
.Select(libraryBook => new
|
||||
{
|
||||
libraryBook.Book,
|
||||
LibraryBook = libraryBook,
|
||||
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
|
||||
})
|
||||
.ToList();
|
||||
@@ -41,8 +41,8 @@ namespace ApplicationServices
|
||||
if (_setDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||
.Select(a => a.Book)
|
||||
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
|
||||
.Select(a => a.LibraryBook)
|
||||
.ToList();
|
||||
|
||||
if (books2change.Any())
|
||||
@@ -55,8 +55,8 @@ namespace ApplicationServices
|
||||
if (_setNotDownloaded)
|
||||
{
|
||||
var books2change = bookExistsList
|
||||
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||
.Select(a => a.Book)
|
||||
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
|
||||
.Select(a => a.LibraryBook)
|
||||
.ToList();
|
||||
|
||||
if (books2change.Any())
|
||||
@@ -72,7 +72,7 @@ namespace ApplicationServices
|
||||
public void Execute()
|
||||
{
|
||||
foreach (var a in actionSets)
|
||||
a.Books.UpdateBookStatus(a.newStatus);
|
||||
a.LibraryBooks.UpdateBookStatus(a.newStatus);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -446,25 +446,25 @@ namespace ApplicationServices
|
||||
/// <summary>
|
||||
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
|
||||
/// </summary>
|
||||
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
|
||||
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
|
||||
|
||||
#region Update book details
|
||||
public static int UpdateUserDefinedItem(
|
||||
this Book book,
|
||||
this LibraryBook lb,
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
Rating rating = null)
|
||||
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
|
||||
|
||||
public static int UpdateUserDefinedItem(
|
||||
this IEnumerable<Book> books,
|
||||
this IEnumerable<LibraryBook> lb,
|
||||
string tags = null,
|
||||
LiberatedStatus? bookStatus = null,
|
||||
LiberatedStatus? pdfStatus = null,
|
||||
Rating rating = null)
|
||||
=> updateUserDefinedItem(
|
||||
books,
|
||||
lb,
|
||||
udi => {
|
||||
// blank tags are expected. null tags are not
|
||||
if (tags is not null)
|
||||
@@ -480,66 +480,52 @@ namespace ApplicationServices
|
||||
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
|
||||
});
|
||||
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus, Version libationVersion)
|
||||
=> book.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
|
||||
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
|
||||
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion)
|
||||
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
|
||||
|
||||
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
|
||||
|
||||
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
|
||||
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
|
||||
|
||||
public static int UpdateTags(this Book book, string tags)
|
||||
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this IEnumerable<Book> books, string tags)
|
||||
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this LibraryBook libraryBook, string tags)
|
||||
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
|
||||
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
|
||||
|
||||
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
|
||||
=> libraryBook.Book.updateUserDefinedItem(action);
|
||||
=> libraryBook.updateUserDefinedItem(action);
|
||||
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
|
||||
=> libraryBooks.updateUserDefinedItem(action);
|
||||
|
||||
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
|
||||
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
|
||||
|
||||
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
|
||||
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
|
||||
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action);
|
||||
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
|
||||
{
|
||||
try
|
||||
{
|
||||
if (books is null || !books.Any())
|
||||
if (libraryBooks is null || !libraryBooks.Any())
|
||||
return 0;
|
||||
|
||||
foreach (var book in books)
|
||||
action?.Invoke(book.UserDefinedItem);
|
||||
foreach (var book in libraryBooks)
|
||||
action?.Invoke(book.Book.UserDefinedItem);
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
|
||||
// Attach() NoTracking entities before SaveChanges()
|
||||
foreach (var book in books)
|
||||
foreach (var book in libraryBooks)
|
||||
{
|
||||
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
|
||||
}
|
||||
|
||||
var qtyChanges = context.SaveChanges();
|
||||
if (qtyChanges > 0)
|
||||
BookUserDefinedItemCommitted?.Invoke(null, books);
|
||||
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
|
||||
|
||||
return qtyChanges;
|
||||
}
|
||||
|
||||
@@ -35,6 +35,9 @@ namespace ApplicationServices
|
||||
[Name("Title")]
|
||||
public string Title { get; set; }
|
||||
|
||||
[Name("Subtitle")]
|
||||
public string Subtitle { get; set; }
|
||||
|
||||
[Name("Authors")]
|
||||
public string AuthorNames { get; set; }
|
||||
|
||||
@@ -123,6 +126,7 @@ namespace ApplicationServices
|
||||
AudibleProductId = a.Book.AudibleProductId,
|
||||
Locale = a.Book.Locale,
|
||||
Title = a.Book.Title,
|
||||
Subtitle = a.Book.Subtitle,
|
||||
AuthorNames = a.Book.AuthorNames(),
|
||||
NarratorNames = a.Book.NarratorNames(),
|
||||
LengthInMinutes = a.Book.LengthInMinutes,
|
||||
@@ -198,6 +202,7 @@ namespace ApplicationServices
|
||||
nameof(ExportDto.AudibleProductId),
|
||||
nameof(ExportDto.Locale),
|
||||
nameof(ExportDto.Title),
|
||||
nameof(ExportDto.Subtitle),
|
||||
nameof(ExportDto.AuthorNames),
|
||||
nameof(ExportDto.NarratorNames),
|
||||
nameof(ExportDto.LengthInMinutes),
|
||||
@@ -256,6 +261,7 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
|
||||
row.CreateCell(col++).SetCellValue(dto.Locale);
|
||||
row.CreateCell(col++).SetCellValue(dto.Title);
|
||||
row.CreateCell(col++).SetCellValue(dto.Subtitle);
|
||||
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
|
||||
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
|
||||
|
||||
@@ -108,7 +108,7 @@ namespace ApplicationServices
|
||||
|
||||
var recordsObj = new JObject
|
||||
{
|
||||
{ "title", libraryBook.Book.Title},
|
||||
{ "title", libraryBook.Book.TitleWithSubtitle},
|
||||
{ "asin", libraryBook.Book.AudibleProductId},
|
||||
{ "exportTime", DateTime.Now},
|
||||
{ "records", JArray.FromObject(recordsEx) }
|
||||
|
||||
@@ -34,7 +34,7 @@ namespace ApplicationServices
|
||||
#region Update
|
||||
private static bool isUpdating;
|
||||
|
||||
public static void UpdateBooks(IEnumerable<Book> books)
|
||||
public static void UpdateBooks(IEnumerable<LibraryBook> books)
|
||||
{
|
||||
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
|
||||
// I did not benchmark before choosing the number here
|
||||
@@ -49,10 +49,10 @@ namespace ApplicationServices
|
||||
|
||||
public static void FullReIndex() => performSafeCommand(fullReIndex);
|
||||
|
||||
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
|
||||
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
|
||||
{
|
||||
e.UpdateLiberatedStatus(book);
|
||||
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
|
||||
e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags);
|
||||
e.UpdateUserRatings(book);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -20,6 +20,7 @@ namespace DataLayer.Configurations
|
||||
entity.Ignore(nameof(Book.Authors));
|
||||
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));
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -34,7 +34,10 @@ namespace DataLayer
|
||||
// immutable
|
||||
public string AudibleProductId { get; private set; }
|
||||
public string Title { get; private set; }
|
||||
public string Description { get; private set; }
|
||||
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 int LengthInMinutes { get; private set; }
|
||||
public ContentType ContentType { get; private set; }
|
||||
public string Locale { get; private set; }
|
||||
@@ -70,6 +73,7 @@ namespace DataLayer
|
||||
public Book(
|
||||
AudibleProductId audibleProductId,
|
||||
string title,
|
||||
string subtitle,
|
||||
string description,
|
||||
int lengthInMinutes,
|
||||
ContentType contentType,
|
||||
@@ -98,8 +102,8 @@ namespace DataLayer
|
||||
Category = category;
|
||||
|
||||
// simple assigns
|
||||
Title = title.Trim() ?? "";
|
||||
Description = description?.Trim() ?? "";
|
||||
UpdateTitle(title, subtitle);
|
||||
Description = description?.Trim() ?? "";
|
||||
LengthInMinutes = lengthInMinutes;
|
||||
ContentType = contentType;
|
||||
|
||||
@@ -107,10 +111,16 @@ namespace DataLayer
|
||||
ReplaceAuthors(authors);
|
||||
ReplaceNarrators(narrators);
|
||||
}
|
||||
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;
|
||||
#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
|
||||
@@ -233,6 +243,6 @@ namespace DataLayer
|
||||
Category = category;
|
||||
}
|
||||
|
||||
public override string ToString() => $"[{AudibleProductId}] {Title}";
|
||||
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -8,7 +8,7 @@ namespace DataLayer
|
||||
{
|
||||
public static class EntityExtensions
|
||||
{
|
||||
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title);
|
||||
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));
|
||||
@@ -62,7 +62,7 @@ namespace DataLayer
|
||||
|
||||
max = Math.Max(max, 1);
|
||||
|
||||
var titles = libraryBooks.Select(lb => "- " + lb.Book.Title).ToList();
|
||||
var titles = libraryBooks.Select(lb => "- " + lb.Book.TitleWithSubtitle).ToList();
|
||||
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
|
||||
if (titles.Count == max + 1)
|
||||
titlesAgg += $"\r\n\r\nand 1 other";
|
||||
|
||||
416
Source/DataLayer/Migrations/20230626171442_AddBookSubtitle.Designer.cs
generated
Normal file
416
Source/DataLayer/Migrations/20230626171442_AddBookSubtitle.Designer.cs
generated
Normal file
@@ -0,0 +1,416 @@
|
||||
// <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("20230626171442_AddBookSubtitle")]
|
||||
partial class AddBookSubtitle
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
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.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.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("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
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("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
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.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
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("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookSubtitle : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Subtitle",
|
||||
table: "Books",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Subtitle",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.3");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -56,6 +56,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<string>("PictureLarge")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Subtitle")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<string>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
|
||||
@@ -118,7 +118,8 @@ namespace DtoImporterService
|
||||
{
|
||||
book = DbContext.Books.Add(new Book(
|
||||
new AudibleProductId(item.ProductId),
|
||||
item.TitleWithSubtitle,
|
||||
item.Title,
|
||||
item.Subtitle,
|
||||
item.Description,
|
||||
item.LengthInMinutes,
|
||||
contentType,
|
||||
@@ -164,6 +165,9 @@ namespace DtoImporterService
|
||||
{
|
||||
var item = importItem.DtoItem;
|
||||
|
||||
// Update the book titles, since formatting can change
|
||||
book.UpdateTitle(item.Title, item.Subtitle);
|
||||
|
||||
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
|
||||
book.AudioFormat = codec;
|
||||
|
||||
|
||||
@@ -25,8 +25,7 @@ namespace FileLiberator
|
||||
|
||||
if (seriesParent is not null)
|
||||
{
|
||||
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
|
||||
return Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -73,7 +73,17 @@ namespace FileLiberator
|
||||
}
|
||||
else
|
||||
{
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
|
||||
var realMp3Path
|
||||
= FileUtility.SaferMoveToValidPath(
|
||||
mp3File.Name,
|
||||
proposedMp3Path,
|
||||
Configuration.Instance.ReplacementCharacters,
|
||||
extension: "mp3",
|
||||
Configuration.Instance.OverwriteExisting);
|
||||
|
||||
SetFileTime(libraryBook, realMp3Path);
|
||||
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
|
||||
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ namespace FileLiberator
|
||||
|
||||
OnBegin(libraryBook);
|
||||
|
||||
try
|
||||
try
|
||||
{
|
||||
if (libraryBook.Book.Audio_Exists())
|
||||
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
|
||||
@@ -76,17 +76,38 @@ namespace FileLiberator
|
||||
|
||||
var finalStorageDir = getDestinationDirectory(libraryBook);
|
||||
|
||||
Task[] finalTasks = new[]
|
||||
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
|
||||
Task[] finalTasks = new[]
|
||||
{
|
||||
Task.Run(() => downloadCoverArt(libraryBook)),
|
||||
Task.Run(() => moveFilesToBooksDir(libraryBook, entries)),
|
||||
Task.Run(() => libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)),
|
||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||
};
|
||||
Task.Run(() => downloadCoverArt(libraryBook)),
|
||||
moveFilesTask,
|
||||
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir))
|
||||
};
|
||||
|
||||
await Task.WhenAll(finalTasks);
|
||||
try
|
||||
{
|
||||
await Task.WhenAll(finalTasks);
|
||||
}
|
||||
catch
|
||||
{
|
||||
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
|
||||
//Only fail if the downloaded audio files failed to move to Books directory
|
||||
if (moveFilesTask.IsFaulted)
|
||||
{
|
||||
throw;
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (moveFilesTask.IsCompletedSuccessfully)
|
||||
{
|
||||
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
|
||||
|
||||
return new StatusHandler();
|
||||
SetDirectoryTime(libraryBook, finalStorageDir);
|
||||
}
|
||||
}
|
||||
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
@@ -100,8 +121,9 @@ namespace FileLiberator
|
||||
|
||||
downloadValidation(libraryBook);
|
||||
|
||||
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
|
||||
var api = await libraryBook.GetApiAsync();
|
||||
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
|
||||
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());
|
||||
@@ -148,7 +170,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,
|
||||
@@ -310,9 +335,9 @@ namespace FileLiberator
|
||||
string errorTitle()
|
||||
{
|
||||
var title
|
||||
= (libraryBook.Book.Title.Length > 53)
|
||||
? $"{libraryBook.Book.Title.Truncate(50)}..."
|
||||
: libraryBook.Book.Title;
|
||||
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
|
||||
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
|
||||
: libraryBook.Book.TitleWithSubtitle;
|
||||
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
|
||||
return errorBookTitle;
|
||||
};
|
||||
@@ -343,8 +368,15 @@ namespace FileLiberator
|
||||
{
|
||||
var entry = entries[i];
|
||||
|
||||
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
var realDest
|
||||
= FileUtility.SaferMoveToValidPath(
|
||||
entry.Path,
|
||||
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
|
||||
Configuration.Instance.ReplacementCharacters,
|
||||
overwrite: Configuration.Instance.OverwriteExisting);
|
||||
|
||||
SetFileTime(libraryBook, realDest);
|
||||
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
|
||||
|
||||
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
|
||||
entries[i] = entry with { Path = realDest };
|
||||
@@ -352,7 +384,10 @@ namespace FileLiberator
|
||||
|
||||
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
|
||||
if (cue != default)
|
||||
{
|
||||
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
|
||||
SetFileTime(libraryBook, cue.Path);
|
||||
}
|
||||
|
||||
AudibleFileStorage.Audio.Refresh();
|
||||
}
|
||||
@@ -370,7 +405,7 @@ namespace FileLiberator
|
||||
|
||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||
|
||||
var coverPath = "[null]";
|
||||
|
||||
@@ -385,7 +420,10 @@ namespace FileLiberator
|
||||
|
||||
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
|
||||
if (picBytes.Length > 0)
|
||||
{
|
||||
File.WriteAllBytes(coverPath, picBytes);
|
||||
SetFileTime(libraryBook, coverPath);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -30,7 +30,12 @@ namespace FileLiberator
|
||||
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
|
||||
var result = verifyDownload(actualDownloadedFilePath);
|
||||
|
||||
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
if (result.IsSuccess)
|
||||
{
|
||||
SetFileTime(libraryBook, actualDownloadedFilePath);
|
||||
SetDirectoryTime(libraryBook, Path.GetDirectoryName(actualDownloadedFilePath));
|
||||
}
|
||||
libraryBook.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using DataLayer;
|
||||
@@ -44,7 +45,7 @@ namespace FileLiberator
|
||||
|
||||
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
|
||||
{
|
||||
libraryBook.Book.Title,
|
||||
libraryBook.Book.TitleWithSubtitle,
|
||||
libraryBook.Book.AudibleProductId,
|
||||
libraryBook.Book.Locale,
|
||||
Account = libraryBook.Account?.ToMask() ?? "[empty]"
|
||||
@@ -98,5 +99,26 @@ namespace FileLiberator
|
||||
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
|
||||
Completed?.Invoke(this, libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
protected static void SetFileTime(LibraryBook libraryBook, string file)
|
||||
=> setFileSystemTime(libraryBook, new FileInfo(file));
|
||||
protected static void SetDirectoryTime(LibraryBook libraryBook, string file)
|
||||
=> setFileSystemTime(libraryBook, new DirectoryInfo(file));
|
||||
|
||||
private static void setFileSystemTime(LibraryBook libraryBook, FileSystemInfo fileInfo)
|
||||
{
|
||||
if (!fileInfo.Exists) return;
|
||||
|
||||
fileInfo.CreationTimeUtc = getTimeValue(Configuration.Instance.CreationTime) ?? fileInfo.CreationTimeUtc;
|
||||
fileInfo.LastWriteTimeUtc = getTimeValue(Configuration.Instance.LastWriteTime) ?? fileInfo.LastWriteTimeUtc;
|
||||
|
||||
DateTime? getTimeValue(Configuration.DateTimeSource source) => source switch
|
||||
{
|
||||
Configuration.DateTimeSource.Added => libraryBook.DateAdded,
|
||||
Configuration.DateTimeSource.Published => libraryBook.Book.DatePublished,
|
||||
_ => null,
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AudibleUtilities;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
@@ -13,40 +14,53 @@ namespace FileLiberator
|
||||
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
|
||||
=> (
|
||||
id: libraryBook.Book.AudibleProductId,
|
||||
title: libraryBook.Book.Title,
|
||||
title: libraryBook.Book.TitleWithSubtitle,
|
||||
locale: libraryBook.Book.Locale,
|
||||
account: libraryBook.Account.ToMask()
|
||||
);
|
||||
|
||||
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
|
||||
{
|
||||
var apiExtended = await AudibleUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
var apiExtended = await ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
|
||||
return apiExtended.Api;
|
||||
}
|
||||
|
||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook)
|
||||
{
|
||||
Account = libraryBook.Account,
|
||||
DateAdded = libraryBook.DateAdded,
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var nickname
|
||||
= persister.AccountsSettings.Accounts
|
||||
.FirstOrDefault(a => a.AccountId == libraryBook.Account)
|
||||
?.AccountName;
|
||||
|
||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||
Title = libraryBook.Book.Title ?? "",
|
||||
Locale = libraryBook.Book.Locale,
|
||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||
DatePublished = libraryBook.Book.DatePublished,
|
||||
return new()
|
||||
{
|
||||
Account = libraryBook.Account,
|
||||
AccountNickname = nickname,
|
||||
DateAdded = libraryBook.DateAdded,
|
||||
|
||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||
Title = libraryBook.Book.Title,
|
||||
Subtitle = libraryBook.Book.Subtitle,
|
||||
TitleWithSubtitle = libraryBook.Book.TitleWithSubtitle,
|
||||
Locale = libraryBook.Book.Locale,
|
||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||
DatePublished = libraryBook.Book.DatePublished,
|
||||
|
||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||
|
||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||
SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
||||
IsPodcast = libraryBook.Book.IsEpisodeChild(),
|
||||
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
|
||||
|
||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||
Language = libraryBook.Book.Language
|
||||
};
|
||||
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
|
||||
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
|
||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||
|
||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||
Language = libraryBook.Book.Language
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -6,7 +6,6 @@ using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using Polly;
|
||||
using Polly.Retry;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
|
||||
namespace FileManager
|
||||
{
|
||||
@@ -147,14 +146,24 @@ namespace FileManager
|
||||
|
||||
/// <summary>
|
||||
/// Move file.
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
|
||||
/// <br/>- Ensure valid file name path: remove invalid chars, enforce max file length
|
||||
/// <br/>- Perform <see cref="SaferMove"/>
|
||||
/// <br/>- Return valid path
|
||||
/// </summary>
|
||||
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null)
|
||||
/// <param name="source">Name of the file to move</param>
|
||||
/// <param name="destination">The new path and name for the file.</param>
|
||||
/// <param name="replacements">Rules for replacing illegal file path characters</param>
|
||||
/// <param name="extension">File extension override to use for <paramref name="destination"/></param>
|
||||
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
|
||||
/// <returns>The actual destination filename</returns>
|
||||
public static string SaferMoveToValidPath(
|
||||
LongPath source,
|
||||
LongPath destination,
|
||||
ReplacementCharacters replacements,
|
||||
string extension = null,
|
||||
bool overwrite = false)
|
||||
{
|
||||
extension = extension ?? Path.GetExtension(source);
|
||||
destination = GetValidFilename(destination, replacements, extension);
|
||||
extension ??= Path.GetExtension(source);
|
||||
destination = GetValidFilename(destination, replacements, extension, overwrite);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
}
|
||||
|
||||
@@ -163,6 +163,11 @@ namespace FileManager
|
||||
|
||||
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);
|
||||
public static bool operator !=(LongPath path1, LongPath path2) => !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);
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<PropertyGroup>
|
||||
<OutputType>WinExe</OutputType>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
|
||||
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
|
||||
<TrimMode>copyused</TrimMode>
|
||||
<BuiltInComInteropSupport>true</BuiltInComInteropSupport>
|
||||
@@ -66,13 +67,13 @@
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
<PackageReference Include="Avalonia" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
|
||||
<!--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-preview8" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview8" />
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
|
||||
|
||||
@@ -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()
|
||||
|
||||
4
Source/HangoverWinForms/Form1.Designer.cs
generated
4
Source/HangoverWinForms/Form1.Designer.cs
generated
@@ -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")));
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -28,8 +28,6 @@ namespace LibationAvalonia
|
||||
public static IBrush ProcessQueueBookDefaultBrush { get; private set; }
|
||||
public static IBrush SeriesEntryGridBackgroundBrush { get; private set; }
|
||||
|
||||
public static IAssetLoader AssetLoader { get; private set; }
|
||||
|
||||
public static readonly Uri AssetUriBase = new("avares://Libation/Assets/");
|
||||
public static Stream OpenAsset(string assetRelativePath)
|
||||
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
|
||||
@@ -37,7 +35,6 @@ namespace LibationAvalonia
|
||||
public override void Initialize()
|
||||
{
|
||||
AvaloniaXamlLoader.Load(this);
|
||||
AssetLoader = AvaloniaLocator.Current.GetService<IAssetLoader>();
|
||||
}
|
||||
|
||||
public static Task<List<DataLayer.LibraryBook>> LibraryTask;
|
||||
@@ -144,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)
|
||||
|
||||
@@ -10,9 +10,9 @@ using System.Windows.Input;
|
||||
|
||||
namespace LibationAvalonia.Controls
|
||||
{
|
||||
public partial class LinkLabel : TextBlock, IStyleable, ICommandSource
|
||||
public partial class LinkLabel : TextBlock, ICommandSource
|
||||
{
|
||||
Type IStyleable.StyleKey => typeof(LinkLabel);
|
||||
protected override Type StyleKeyOverride => typeof(LinkLabel);
|
||||
|
||||
public static readonly StyledProperty<ICommand> CommandProperty =
|
||||
AvaloniaProperty.Register<LinkLabel, ICommand>(nameof(Command), enableDataValidation: true);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
Label="Books Location">
|
||||
|
||||
|
||||
<StackPanel>
|
||||
<TextBlock
|
||||
Margin="5"
|
||||
@@ -28,6 +28,44 @@
|
||||
<TextBlock Text="{CompiledBinding SavePodcastsToParentFolderText}" />
|
||||
</CheckBox>
|
||||
|
||||
<CheckBox IsChecked="{CompiledBinding OverwriteExisting, Mode=TwoWay}">
|
||||
<TextBlock Text="{CompiledBinding OverwriteExistingText}" />
|
||||
</CheckBox>
|
||||
|
||||
<Grid
|
||||
RowDefinitions="Auto,Auto"
|
||||
ColumnDefinitions="Auto,*">
|
||||
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0"
|
||||
Text="{CompiledBinding CreationTimeText}" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Height="25"
|
||||
Grid.Column="1"
|
||||
Margin="0,5"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
SelectedItem="{CompiledBinding CreationTime, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding DateTimeSources}" />
|
||||
|
||||
<TextBlock
|
||||
VerticalAlignment="Center"
|
||||
Grid.Row="1"
|
||||
Margin="0,0,10,0"
|
||||
Text="{CompiledBinding LastWriteTimeText}" />
|
||||
|
||||
<controls:WheelComboBox
|
||||
Height="25"
|
||||
Grid.Row="1"
|
||||
Grid.Column="1"
|
||||
Margin="0,5"
|
||||
HorizontalContentAlignment="Stretch"
|
||||
SelectedItem="{CompiledBinding LastWriteTime, Mode=TwoWay}"
|
||||
ItemsSource="{CompiledBinding DateTimeSources}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</StackPanel>
|
||||
|
||||
</controls:GroupBox>
|
||||
|
||||
@@ -1,13 +1,13 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Input;
|
||||
using Avalonia.Styling;
|
||||
using System;
|
||||
|
||||
namespace LibationAvalonia.Controls
|
||||
{
|
||||
public partial class WheelComboBox : ComboBox, IStyleable
|
||||
public partial class WheelComboBox : ComboBox
|
||||
{
|
||||
Type IStyleable.StyleKey => typeof(ComboBox);
|
||||
protected override Type StyleKeyOverride => typeof(ComboBox);
|
||||
|
||||
public WheelComboBox()
|
||||
{
|
||||
InitializeComponent();
|
||||
@@ -16,9 +16,15 @@ namespace LibationAvalonia.Controls
|
||||
{
|
||||
var dir = Math.Sign(e.Delta.Y);
|
||||
if (dir == 1 && SelectedIndex > 0)
|
||||
{
|
||||
SelectedIndex--;
|
||||
e.Handled = true;
|
||||
}
|
||||
else if (dir == -1 && SelectedIndex < ItemCount - 1)
|
||||
{
|
||||
SelectedIndex++;
|
||||
e.Handled = true;
|
||||
}
|
||||
|
||||
base.OnPointerWheelChanged(e);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@
|
||||
CanUserSortColumns="False"
|
||||
AutoGenerateColumns="False"
|
||||
IsReadOnly="False"
|
||||
Name="accountsGrid"
|
||||
ItemsSource="{Binding Accounts}"
|
||||
GridLinesVisibility="All">
|
||||
|
||||
|
||||
@@ -157,6 +157,8 @@ namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
try
|
||||
{
|
||||
accountsGrid.CommitEdit();
|
||||
|
||||
if (!await inputIsValid())
|
||||
return;
|
||||
|
||||
|
||||
@@ -21,7 +21,7 @@ namespace LibationAvalonia.Dialogs
|
||||
set
|
||||
{
|
||||
_libraryBook = value;
|
||||
Title = _libraryBook.Book.Title;
|
||||
Title = _libraryBook.Book.TitleWithSubtitle;
|
||||
DataContext = _viewModel = new BookDetailsDialogViewModel(_libraryBook);
|
||||
}
|
||||
}
|
||||
@@ -48,7 +48,7 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
protected override void SaveAndClose()
|
||||
{
|
||||
LibraryBook.Book.UpdateUserDefinedItem(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
|
||||
LibraryBook.UpdateUserDefinedItem(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
|
||||
base.SaveAndClose();
|
||||
}
|
||||
|
||||
@@ -106,9 +106,11 @@ namespace LibationAvalonia.Dialogs
|
||||
var picture = PictureStorage.GetPictureSynchronously(new PictureDefinition(libraryBook.Book.PictureId, PictureSize._80x80));
|
||||
Cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80);
|
||||
|
||||
var title = string.IsNullOrEmpty(Book.Subtitle) ? Book.Title : $"{Book.Title}\r\n {Book.Subtitle}";
|
||||
|
||||
//init book details
|
||||
DetailsText = @$"
|
||||
Title: {Book.Title}
|
||||
Title: {title}
|
||||
Author(s): {Book.AuthorNames()}
|
||||
Narrator(s): {Book.NarratorNames()}
|
||||
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
|
||||
|
||||
@@ -37,7 +37,7 @@ namespace LibationAvalonia.Dialogs
|
||||
public BookRecordsDialog(LibraryBook libraryBook) : this()
|
||||
{
|
||||
this.libraryBook = libraryBook;
|
||||
Title = $"{libraryBook.Book.Title} - Clips and Bookmarks";
|
||||
Title = $"{libraryBook.Book.TitleWithSubtitle} - Clips and Bookmarks";
|
||||
|
||||
Loaded += BookRecordsDialog_Loaded;
|
||||
}
|
||||
@@ -148,7 +148,7 @@ namespace LibationAvalonia.Dialogs
|
||||
await Dispatcher.UIThread.InvokeAsync(() => new FilePickerSaveOptions
|
||||
{
|
||||
Title = "Where to export book records",
|
||||
SuggestedFileName = $"{libraryBook.Book.Title} - Records",
|
||||
SuggestedFileName = $"{libraryBook.Book.TitleWithSubtitle} - Records",
|
||||
DefaultExtension = "xlsx",
|
||||
ShowOverwritePrompt = true,
|
||||
FileTypeChoices = new FilePickerFileType[]
|
||||
|
||||
@@ -41,6 +41,7 @@ namespace LibationAvalonia.Dialogs
|
||||
_accounts.Add(new listItem
|
||||
{
|
||||
Account = account,
|
||||
IsChecked = account.LibraryScan,
|
||||
Text = $"{account.AccountName} ({account.AccountId} - {account.Locale.Name})"
|
||||
});
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
using LibationSearchEngine;
|
||||
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class SearchSyntaxDialog : DialogWindow
|
||||
@@ -18,7 +20,7 @@ Search for wizard of oz:
|
||||
title:""wizard of oz""
|
||||
|
||||
|
||||
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchStringFields());
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.StringFieldNames);
|
||||
|
||||
NumberFields = @"
|
||||
Find books between 1-100 minutes long
|
||||
@@ -30,14 +32,14 @@ Find books published from 2020-1-1 to
|
||||
datepublished:[20200101 TO 20231231]
|
||||
|
||||
|
||||
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchNumberFields());
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.NumberFieldNames);
|
||||
|
||||
BoolFields = @"
|
||||
Find books that you haven't rated:
|
||||
-IsRated
|
||||
|
||||
|
||||
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchBoolFields());
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.BoolFieldNames);
|
||||
|
||||
IdFields = @"
|
||||
Alice's Adventures in
|
||||
@@ -49,7 +51,7 @@ All of these are synonyms
|
||||
for the ID field
|
||||
|
||||
|
||||
" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchIdFields());
|
||||
" + string.Join("\r\n", SearchEngine.FieldIndexRules.IdFieldNames);
|
||||
|
||||
|
||||
DataContext = this;
|
||||
|
||||
@@ -70,13 +70,13 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0-preview8" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0-preview8" />
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,44 +0,0 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Input;
|
||||
|
||||
namespace LibationAvalonia
|
||||
{
|
||||
internal class MacAccessKeyHandler : AccessKeyHandler
|
||||
{
|
||||
protected override void OnPreviewKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.LWin or Key.RWin)
|
||||
{
|
||||
var newArgs = new KeyEventArgs { Key = Key.LeftAlt, Handled = e.Handled };
|
||||
base.OnPreviewKeyDown(sender, newArgs);
|
||||
e.Handled = newArgs.Handled;
|
||||
}
|
||||
else if (e.Key is not Key.LeftAlt and not Key.RightAlt)
|
||||
base.OnPreviewKeyDown(sender, e);
|
||||
}
|
||||
|
||||
protected override void OnPreviewKeyUp(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.Key is Key.LWin or Key.RWin)
|
||||
{
|
||||
var newArgs = new KeyEventArgs { Key = Key.LeftAlt, Handled = e.Handled };
|
||||
base.OnPreviewKeyUp(sender, newArgs);
|
||||
e.Handled = newArgs.Handled;
|
||||
}
|
||||
else if (e.Key is not Key.LeftAlt and not Key.RightAlt)
|
||||
base.OnPreviewKeyDown(sender, e);
|
||||
}
|
||||
|
||||
protected override void OnKeyDown(object sender, KeyEventArgs e)
|
||||
{
|
||||
if (e.KeyModifiers.HasAllFlags(KeyModifiers.Meta))
|
||||
{
|
||||
var newArgs = new KeyEventArgs { Key = e.Key, Handled = e.Handled, KeyModifiers = KeyModifiers.Alt };
|
||||
base.OnKeyDown(sender, newArgs);
|
||||
e.Handled = newArgs.Handled;
|
||||
}
|
||||
else if (!e.KeyModifiers.HasFlag(KeyModifiers.Alt))
|
||||
base.OnPreviewKeyDown(sender, e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
using System;
|
||||
using System.Diagnostics;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
@@ -35,6 +34,7 @@ namespace LibationAvalonia
|
||||
$"\"{Configuration.ProcessDirectory}\"");
|
||||
return;
|
||||
}
|
||||
AppDomain.CurrentDomain.UnhandledException += (o, e) => LogError(e.ExceptionObject);
|
||||
|
||||
//***********************************************//
|
||||
// //
|
||||
@@ -42,29 +42,32 @@ namespace LibationAvalonia
|
||||
// //
|
||||
//***********************************************//
|
||||
// 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);
|
||||
|
||||
//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 e)
|
||||
{
|
||||
LogError(e);
|
||||
}
|
||||
|
||||
appBuilderTask.GetAwaiter().GetResult().SetupWithLifetime(classicLifetimeTask.GetAwaiter().GetResult());
|
||||
|
||||
classicLifetimeTask.Result.Start(null);
|
||||
}
|
||||
|
||||
public static AppBuilder BuildAvaloniaApp()
|
||||
@@ -73,20 +76,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();
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Threading;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -52,6 +53,10 @@ namespace LibationAvalonia.ViewModels
|
||||
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts());
|
||||
var stats = await updateCountsTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => LibraryStats = stats);
|
||||
|
||||
if (Configuration.Instance.AutoDownloadEpisodes
|
||||
&& stats.booksNoProgress + stats.pdfsNotDownloaded > 0)
|
||||
await Dispatcher.UIThread.InvokeAsync(BackupAllBooks);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -61,12 +61,12 @@ namespace LibationAvalonia.ViewModels
|
||||
#region Properties exposed to the view
|
||||
public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } }
|
||||
public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(BackgroundColor)); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } }
|
||||
public string Narrator { get => _narrator; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
|
||||
public string Author { get => _author; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _author, value)); }
|
||||
public string Title { get => _title; set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _title, value)); }
|
||||
public int Progress { get => _progress; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
|
||||
public string ETA { get => _eta; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
|
||||
public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Post(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
|
||||
public string Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
|
||||
public string Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); }
|
||||
public string Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); }
|
||||
public int Progress { get => _progress; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
|
||||
public string ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
|
||||
public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
|
||||
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
|
||||
public bool IsDownloading => Status is ProcessBookStatus.Working;
|
||||
public bool Queued => Status is ProcessBookStatus.Queued;
|
||||
@@ -105,7 +105,7 @@ namespace LibationAvalonia.ViewModels
|
||||
LibraryBook = libraryBook;
|
||||
Logger = logme;
|
||||
|
||||
_title = LibraryBook.Book.Title;
|
||||
_title = LibraryBook.Book.TitleWithSubtitle;
|
||||
_author = LibraryBook.Book.AuthorNames();
|
||||
_narrator = LibraryBook.Book.NarratorNames();
|
||||
|
||||
@@ -305,7 +305,7 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}");
|
||||
|
||||
Title = libraryBook.Book.Title;
|
||||
Title = libraryBook.Book.TitleWithSubtitle;
|
||||
Author = libraryBook.Book.AuthorNames();
|
||||
Narrator = libraryBook.Book.NarratorNames();
|
||||
}
|
||||
@@ -372,7 +372,7 @@ namespace LibationAvalonia.ViewModels
|
||||
: str;
|
||||
|
||||
details =
|
||||
$@" Title: {libraryBook.Book.Title}
|
||||
$@" Title: {libraryBook.Book.TitleWithSubtitle}
|
||||
ID: {libraryBook.Book.AudibleProductId}
|
||||
Author: {trunc(libraryBook.Book.AuthorNames())}
|
||||
Narr: {trunc(libraryBook.Book.NarratorNames())}";
|
||||
@@ -390,9 +390,9 @@ $@" Title: {libraryBook.Book.Title}
|
||||
|
||||
if (dialogResult == SkipResult)
|
||||
{
|
||||
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Error);
|
||||
libraryBook.UpdateBookStatus(LiberatedStatus.Error);
|
||||
|
||||
Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}");
|
||||
Logger.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.TitleWithSubtitle}");
|
||||
|
||||
return ProcessBookResult.FailedSkip;
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ namespace LibationAvalonia.ViewModels
|
||||
private bool _progressBarVisible;
|
||||
private decimal _speedLimit;
|
||||
|
||||
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
|
||||
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
|
||||
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
|
||||
public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
|
||||
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Post(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
|
||||
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
|
||||
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
|
||||
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
|
||||
public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
|
||||
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
|
||||
public bool AnyCompleted => CompletedCount > 0;
|
||||
public bool AnyQueued => QueuedCount > 0;
|
||||
public bool AnyErrors => ErrorCount > 0;
|
||||
@@ -79,7 +79,7 @@ namespace LibationAvalonia.ViewModels
|
||||
: _speedLimit > 1 ? 0.1m
|
||||
: 0.01m;
|
||||
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
{
|
||||
this.RaisePropertyChanged(nameof(SpeedLimitIncrement));
|
||||
this.RaisePropertyChanged();
|
||||
@@ -106,7 +106,7 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public void WriteLine(string text)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
LogEntries.Add(new()
|
||||
{
|
||||
LogDate = DateTime.Now,
|
||||
@@ -183,7 +183,7 @@ namespace LibationAvalonia.ViewModels
|
||||
|
||||
public void AddToQueue(IEnumerable<ProcessBookViewModel> pbook)
|
||||
{
|
||||
Dispatcher.UIThread.Post(() =>
|
||||
Dispatcher.UIThread.Invoke(() =>
|
||||
{
|
||||
Queue.Enqueue(pbook);
|
||||
if (!Running)
|
||||
@@ -223,11 +223,11 @@ namespace LibationAvalonia.ViewModels
|
||||
else if (result == ProcessBookResult.FailedAbort)
|
||||
Queue.ClearQueue();
|
||||
else if (result == ProcessBookResult.FailedSkip)
|
||||
nextBook.LibraryBook.Book.UpdateBookStatus(LiberatedStatus.Error);
|
||||
nextBook.LibraryBook.UpdateBookStatus(LiberatedStatus.Error);
|
||||
else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage)
|
||||
{
|
||||
await MessageBox.Show(@$"
|
||||
You were denied a content license for {nextBook.LibraryBook.Book.Title}
|
||||
You were denied a content license for {nextBook.LibraryBook.Book.TitleWithSubtitle}
|
||||
|
||||
This error appears to be caused by a temporary interruption of service that sometimes affects Libation's users. This type of error usually resolves itself in 1 to 2 days, and in the meantime you should still be able to access your books through Audible's website or app.
|
||||
",
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Avalonia.Collections;
|
||||
using AAXClean;
|
||||
using Avalonia.Collections;
|
||||
using Avalonia.Controls;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.ViewModels.Settings
|
||||
@@ -19,21 +21,11 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
private int _lameBitrate;
|
||||
private int _lameVBRQuality;
|
||||
private string _chapterTitleTemplate;
|
||||
public SampleRateSelection SelectedSampleRate { get; set; }
|
||||
public EnumDiaplay<SampleRate> SelectedSampleRate { get; set; }
|
||||
public NAudio.Lame.EncoderQuality SelectedEncoderQuality { get; set; }
|
||||
|
||||
public AvaloniaList<SampleRateSelection> SampleRates { get; }
|
||||
= new(
|
||||
new[]
|
||||
{
|
||||
AAXClean.SampleRate.Hz_44100,
|
||||
AAXClean.SampleRate.Hz_32000,
|
||||
AAXClean.SampleRate.Hz_24000,
|
||||
AAXClean.SampleRate.Hz_22050,
|
||||
AAXClean.SampleRate.Hz_16000,
|
||||
AAXClean.SampleRate.Hz_12000,
|
||||
}
|
||||
.Select(s => new SampleRateSelection(s)));
|
||||
public AvaloniaList<EnumDiaplay<SampleRate>> SampleRates { get; }
|
||||
= new(Enum.GetValues<SampleRate>().Select(v => new EnumDiaplay<SampleRate>(v, $"{(int)v} Hz")));
|
||||
|
||||
public AvaloniaList<NAudio.Lame.EncoderQuality> EncoderQualities { get; }
|
||||
= new(
|
||||
@@ -56,6 +48,7 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
DownloadCoverArt = config.DownloadCoverArt;
|
||||
RetainAaxFile = config.RetainAaxFile;
|
||||
DownloadClipsBookmarks = config.DownloadClipsBookmarks;
|
||||
FileDownloadQuality = config.FileDownloadQuality;
|
||||
ClipBookmarkFormat = config.ClipsBookmarksFileFormat;
|
||||
SplitFilesByChapter = config.SplitFilesByChapter;
|
||||
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
|
||||
@@ -71,7 +64,7 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
LameBitrate = config.LameBitrate;
|
||||
LameVBRQuality = config.LameVBRQuality;
|
||||
|
||||
SelectedSampleRate = SampleRates.FirstOrDefault(s => s.SampleRate == config.MaxSampleRate);
|
||||
SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate);
|
||||
SelectedEncoderQuality = config.LameEncoderQuality;
|
||||
}
|
||||
|
||||
@@ -82,6 +75,7 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
config.DownloadCoverArt = DownloadCoverArt;
|
||||
config.RetainAaxFile = RetainAaxFile;
|
||||
config.DownloadClipsBookmarks = DownloadClipsBookmarks;
|
||||
config.FileDownloadQuality = FileDownloadQuality;
|
||||
config.ClipsBookmarksFileFormat = ClipBookmarkFormat;
|
||||
config.SplitFilesByChapter = SplitFilesByChapter;
|
||||
config.MergeOpeningAndEndCredits = MergeOpeningAndEndCredits;
|
||||
@@ -98,10 +92,12 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
config.LameVBRQuality = LameVBRQuality;
|
||||
|
||||
config.LameEncoderQuality = SelectedEncoderQuality;
|
||||
config.MaxSampleRate = SelectedSampleRate?.SampleRate ?? config.MaxSampleRate;
|
||||
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 AllowLibationFixupText { get; } = Configuration.GetDescription(nameof(Configuration.AllowLibationFixup));
|
||||
public string DownloadCoverArtText { get; } = Configuration.GetDescription(nameof(Configuration.DownloadCoverArt));
|
||||
@@ -117,6 +113,7 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
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; }
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.ViewModels.Settings
|
||||
{
|
||||
@@ -21,6 +23,9 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
{
|
||||
BooksDirectory = config.Books.PathWithoutPrefix;
|
||||
SavePodcastsToParentFolder = config.SavePodcastsToParentFolder;
|
||||
OverwriteExisting = config.OverwriteExisting;
|
||||
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
|
||||
LastWriteTime = DateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? DateTimeSources[0];
|
||||
LoggingLevel = config.LogLevel;
|
||||
ThemeVariant = initialThemeVariant
|
||||
= Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) is nameof(Avalonia.Styling.ThemeVariant.Dark)
|
||||
@@ -35,6 +40,9 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
System.IO.Directory.CreateDirectory(lonNewBooks);
|
||||
config.Books = lonNewBooks;
|
||||
config.SavePodcastsToParentFolder = SavePodcastsToParentFolder;
|
||||
config.OverwriteExisting = OverwriteExisting;
|
||||
config.CreationTime = CreationTime.Value;
|
||||
config.LastWriteTime = LastWriteTime.Value;
|
||||
config.LogLevel = LoggingLevel;
|
||||
Configuration.Instance.SetString(ThemeVariant, nameof(ThemeVariant));
|
||||
}
|
||||
@@ -50,12 +58,22 @@ namespace LibationAvalonia.ViewModels.Settings
|
||||
|
||||
public string BooksText { get; } = Configuration.GetDescription(nameof(Configuration.Books));
|
||||
public string SavePodcastsToParentFolderText { get; } = Configuration.GetDescription(nameof(Configuration.SavePodcastsToParentFolder));
|
||||
public string OverwriteExistingText { get; } = Configuration.GetDescription(nameof(Configuration.OverwriteExisting));
|
||||
public string CreationTimeText { get; } = Configuration.GetDescription(nameof(Configuration.CreationTime));
|
||||
public string LastWriteTimeText { get; } = Configuration.GetDescription(nameof(Configuration.LastWriteTime));
|
||||
public EnumDiaplay<Configuration.DateTimeSource>[] DateTimeSources { get; }
|
||||
= Enum.GetValues<Configuration.DateTimeSource>()
|
||||
.Select(v => new EnumDiaplay<Configuration.DateTimeSource>(v))
|
||||
.ToArray();
|
||||
public Serilog.Events.LogEventLevel[] LoggingLevels { get; } = Enum.GetValues<Serilog.Events.LogEventLevel>();
|
||||
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 EnumDiaplay<Configuration.DateTimeSource> CreationTime { get; set; }
|
||||
public EnumDiaplay<Configuration.DateTimeSource> LastWriteTime { get; set; }
|
||||
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }
|
||||
|
||||
public string ThemeVariant
|
||||
|
||||
@@ -112,7 +112,7 @@ namespace LibationAvalonia.Views
|
||||
if (entry.Liberate.IsSeries)
|
||||
setDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
else
|
||||
setDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
setDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated);
|
||||
|
||||
#endregion
|
||||
#region Set Download status to Not Downloaded
|
||||
@@ -128,7 +128,7 @@ namespace LibationAvalonia.Views
|
||||
if (entry.Liberate.IsSeries)
|
||||
setNotDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||
else
|
||||
setNotDownloadMenuItem.Click += (_, __) => entry.Book.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||
setNotDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated);
|
||||
|
||||
#endregion
|
||||
#region Remove from library
|
||||
@@ -159,7 +159,7 @@ namespace LibationAvalonia.Views
|
||||
|
||||
var openFileDialogOptions = new FilePickerOpenOptions
|
||||
{
|
||||
Title = $"Locate the audio file for '{entry.Book.Title}'",
|
||||
Title = $"Locate the audio file for '{entry.Book.TitleWithSubtitle}'",
|
||||
AllowMultiple = false,
|
||||
SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix),
|
||||
FileTypeFilter = new FilePickerFileType[]
|
||||
@@ -252,8 +252,8 @@ namespace LibationAvalonia.Views
|
||||
var displayIndices = config.GridColumnsDisplayIndices;
|
||||
|
||||
var contextMenu = new ContextMenu();
|
||||
contextMenu.MenuClosed += ContextMenu_MenuClosed;
|
||||
contextMenu.ContextMenuOpening += ContextMenu_ContextMenuOpening;
|
||||
contextMenu.Closed += ContextMenu_MenuClosed;
|
||||
contextMenu.Opening += ContextMenu_ContextMenuOpening;
|
||||
List<Control> menuItems = new();
|
||||
contextMenu.ItemsSource = menuItems;
|
||||
|
||||
|
||||
@@ -248,7 +248,7 @@ namespace LibationAvalonia
|
||||
private async Task displayControlAsync(TemplatedControl control)
|
||||
{
|
||||
await UIThread.InvokeAsync(() => control.IsEnabled = false);
|
||||
await UIThread.InvokeAsync(MainForm.productsDisplay.Focus);
|
||||
await UIThread.InvokeAsync(() => MainForm.productsDisplay.Focus());
|
||||
await UIThread.InvokeAsync(() => flashControlAsync(control));
|
||||
if (control is MenuItem menuItem) await UIThread.InvokeAsync(menuItem.Open);
|
||||
await Task.Delay(500);
|
||||
|
||||
76
Source/LibationCli/ConsoleProgressBar.cs
Normal file
76
Source/LibationCli/ConsoleProgressBar.cs
Normal 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));
|
||||
}
|
||||
48
Source/LibationCli/HelpVerb.cs
Normal file
48
Source/LibationCli/HelpVerb.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using CommandLine;
|
||||
using System.Threading.Tasks;
|
||||
using CommandLine;
|
||||
|
||||
namespace LibationCli
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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())
|
||||
|
||||
56
Source/LibationCli/Options/SearchOptions.cs
Normal file
56
Source/LibationCli/Options/SearchOptions.cs
Normal 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}";
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
59
Source/LibationCli/Options/VersionOptions.cs
Normal file
59
Source/LibationCli/Options/VersionOptions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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.WriteLine("ERROR");
|
||||
Console.WriteLine("=====");
|
||||
Console.WriteLine(ex.Message);
|
||||
Console.WriteLine();
|
||||
Console.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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) =>
|
||||
{
|
||||
@@ -27,17 +38,33 @@ namespace LibationCli
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("CLI error. See log for more details.");
|
||||
Serilog.Log.Logger.Error(ex, "CLI error");
|
||||
}
|
||||
};
|
||||
|
||||
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);
|
||||
@@ -54,12 +81,15 @@ namespace LibationCli
|
||||
return;
|
||||
|
||||
foreach (var errorMessage in statusHandler.Errors)
|
||||
{
|
||||
Console.Error.WriteLine(errorMessage);
|
||||
Serilog.Log.Logger.Error(errorMessage);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app.";
|
||||
Console.WriteLine(msg + ". See log for more details.");
|
||||
Console.Error.WriteLine(msg + ". See log for more details.");
|
||||
Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -126,7 +126,16 @@ namespace LibationFileManager
|
||||
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
|
||||
|
||||
var regex = GetBookSearchRegex(productId);
|
||||
return BookDirectoryFiles.FindFiles(regex);
|
||||
|
||||
//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();
|
||||
|
||||
@@ -82,6 +82,9 @@ namespace LibationFileManager
|
||||
[Description("Location for book storage. Includes destination of newly liberated books")]
|
||||
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); }
|
||||
|
||||
// temp/working dir(s) should be outside of dropbox
|
||||
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
|
||||
public string InProgress { get
|
||||
@@ -191,6 +194,33 @@ namespace LibationFileManager
|
||||
Ignore = 3
|
||||
}
|
||||
|
||||
[JsonConverter(typeof(StringEnumConverter))]
|
||||
public enum DateTimeSource
|
||||
{
|
||||
[Description("File creation date/time")]
|
||||
File,
|
||||
[Description("Audiobook publication date")]
|
||||
Published,
|
||||
[Description("Date book was added to your Audible account")]
|
||||
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); }
|
||||
|
||||
[Description("Set file \"modified\" timestamp to:")]
|
||||
public DateTimeSource LastWriteTime { get => GetNonString(defaultValue: DateTimeSource.File); set => SetNonString(value); }
|
||||
|
||||
[Description("Indicates that this is the first time Libation has been run")]
|
||||
public bool FirstLaunch { get => GetNonString(defaultValue: true); set => SetNonString(value); }
|
||||
|
||||
|
||||
@@ -8,7 +8,9 @@ namespace LibationFileManager
|
||||
{
|
||||
public string AudibleProductId { get; set; }
|
||||
public string Title { get; set; }
|
||||
public string Locale { get; set; }
|
||||
public string Subtitle { get; set; }
|
||||
public string TitleWithSubtitle { get; set; }
|
||||
public string Locale { get; set; }
|
||||
public int? YearPublished { get; set; }
|
||||
|
||||
public IEnumerable<string> Authors { get; set; }
|
||||
@@ -20,8 +22,9 @@ namespace LibationFileManager
|
||||
public string FirstNarrator => Narrators.FirstOrDefault();
|
||||
|
||||
public string SeriesName { get; set; }
|
||||
public int? SeriesNumber { get; set; }
|
||||
public float? SeriesNumber { get; set; }
|
||||
public bool IsSeries => !string.IsNullOrEmpty(SeriesName);
|
||||
public bool IsPodcastParent { get; set; }
|
||||
public bool IsPodcast { get; set; }
|
||||
|
||||
public int BitRate { get; set; }
|
||||
@@ -36,5 +39,6 @@ namespace LibationFileManager
|
||||
{
|
||||
public DateTime? DateAdded { get; set; }
|
||||
public string Account { get; set; }
|
||||
public string AccountNickname { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,11 +52,13 @@ namespace LibationFileManager
|
||||
private static readonly LibraryBookDto libraryBookDto
|
||||
= new()
|
||||
{
|
||||
Account = "my account",
|
||||
Account = "myaccount@example.co",
|
||||
AccountNickname = "my account",
|
||||
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
|
||||
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
|
||||
AudibleProductId = "123456789",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Title = "A Study in Scarlet",
|
||||
Subtitle = "A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
YearPublished = 2017,
|
||||
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
|
||||
|
||||
@@ -2,13 +2,13 @@
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public sealed class TemplateTags : ITemplateTag
|
||||
public sealed class TemplateTags : ITemplateTag
|
||||
{
|
||||
public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
|
||||
public string TagName { get; }
|
||||
public string DefaultValue { get; }
|
||||
public string Description { get; }
|
||||
public string Display { get; }
|
||||
public string Description { get; }
|
||||
public string Display { get; }
|
||||
|
||||
private TemplateTags(string tagName, string description, string defaultValue = null, string display = null)
|
||||
{
|
||||
@@ -19,34 +19,38 @@ namespace LibationFileManager
|
||||
}
|
||||
|
||||
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters");
|
||||
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
|
||||
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
|
||||
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
|
||||
public static TemplateTags ChTitle { get; } = new TemplateTags("ch title", "Chapter title");
|
||||
public static TemplateTags ChNumber { get; } = new TemplateTags("ch#", "Chapter #");
|
||||
public static TemplateTags ChNumber0 { get; } = new TemplateTags("ch# 0", "Chapter # with leading zeros");
|
||||
|
||||
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
|
||||
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title");
|
||||
public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon");
|
||||
public static TemplateTags Author { get; } = new TemplateTags("author", "Author(s)");
|
||||
public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author");
|
||||
public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)");
|
||||
public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator");
|
||||
public static TemplateTags Series { get; } = new TemplateTags("series", "Name of series");
|
||||
// can't also have a leading zeros version. Too many weird edge cases. Eg: "1-4"
|
||||
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series");
|
||||
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "File's orig. bitrate");
|
||||
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
|
||||
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
|
||||
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
|
||||
public static TemplateTags Locale { get; } = new ("locale", "Region/country");
|
||||
public static TemplateTags YearPublished { get; } = new("year", "Year published");
|
||||
public static TemplateTags Id { get; } = new TemplateTags("id", "Audible ID");
|
||||
public static TemplateTags Title { get; } = new TemplateTags("title", "Full title with subtitle");
|
||||
public static TemplateTags TitleShort { get; } = new TemplateTags("title short", "Title. Stop at first colon");
|
||||
public static TemplateTags AudibleTitle { get; } = new TemplateTags("audible title", "Audible's title (does not include subtitle)");
|
||||
public static TemplateTags AudibleSubtitle { get; } = new TemplateTags("audible subtitle", "Audible's subtitle");
|
||||
public static TemplateTags Author { get; } = new TemplateTags("author", "Author(s)");
|
||||
public static TemplateTags FirstAuthor { get; } = new TemplateTags("first author", "First author");
|
||||
public static TemplateTags Narrator { get; } = new TemplateTags("narrator", "Narrator(s)");
|
||||
public static TemplateTags FirstNarrator { get; } = new TemplateTags("first narrator", "First narrator");
|
||||
public static TemplateTags Series { get; } = new TemplateTags("series", "Name of series");
|
||||
// can't also have a leading zeros version. Too many weird edge cases. Eg: "1-4"
|
||||
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series");
|
||||
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "File's orig. bitrate");
|
||||
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
|
||||
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
|
||||
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
|
||||
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
|
||||
public static TemplateTags Locale { get; } = new ("locale", "Region/country");
|
||||
public static TemplateTags YearPublished { get; } = new("year", "Year published");
|
||||
public static TemplateTags Language { get; } = new("language", "Book's language");
|
||||
public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
|
||||
|
||||
public static TemplateTags FileDate { get; } = new TemplateTags("file date", "File date/time. e.g. yyyy-MM-dd HH-mm", $"<file date [{DEFAULT_DATE_FORMAT}]>", "<file date [...]>");
|
||||
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>");
|
||||
public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>");
|
||||
public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>");
|
||||
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
|
||||
public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
|
||||
}
|
||||
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date", "Publication date. e.g. yyyy-MM-dd", $"<pub date [{DEFAULT_DATE_FORMAT}]>", "<pub date [...]>");
|
||||
public static TemplateTags DateAdded { get; } = new TemplateTags("date added", "Date added to your Audible account. e.g. yyyy-MM-dd", $"<date added [{DEFAULT_DATE_FORMAT}]>", "<date added [...]>");
|
||||
public static TemplateTags IfSeries { get; } = new TemplateTags("if series", "Only include if part of a book series or podcast", "<if series-><-if series>", "<if series->...<-if series>");
|
||||
public static TemplateTags IfPodcast { get; } = new TemplateTags("if podcast", "Only include if part of a podcast", "<if podcast-><-if podcast>", "<if podcast->...<-if podcast>");
|
||||
public static TemplateTags IfPodcastParent { get; } = new TemplateTags("if podcastparent", "Only include if item is a podcast series parent", "<if podcastparent-><-if podcastparent>", "<if podcastparent->...<-if podcastparent>");
|
||||
public static TemplateTags IfBookseries { get; } = new TemplateTags("if bookseries", "Only include if part of a book series", "<if bookseries-><-if bookseries>", "<if bookseries->...<-if bookseries>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +179,7 @@ namespace LibationFileManager
|
||||
while((slashIndex = part.IndexOf(Path.DirectorySeparatorChar, lastIndex)) > -1)
|
||||
{
|
||||
dir.Add(part[lastIndex..slashIndex]);
|
||||
RemoveSpaces(dir);
|
||||
directories.Add(dir);
|
||||
dir = new();
|
||||
|
||||
@@ -186,35 +187,84 @@ namespace LibationFileManager
|
||||
}
|
||||
dir.Add(part[lastIndex..]);
|
||||
}
|
||||
RemoveSpaces(dir);
|
||||
directories.Add(dir);
|
||||
|
||||
return directories;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Remove spaces from the filename parts to ensure that after concatenation
|
||||
/// <br>-</br> There is no leading or trailing white space
|
||||
/// <br>-</br> There are no multispace instances
|
||||
/// </summary>
|
||||
private static void RemoveSpaces(List<string> parts)
|
||||
{
|
||||
while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[0]))
|
||||
parts.RemoveAt(0);
|
||||
|
||||
while (parts.Count > 0 && string.IsNullOrWhiteSpace(parts[^1]))
|
||||
parts.RemoveAt(parts.Count - 1);
|
||||
|
||||
if (parts.Count == 0) return;
|
||||
|
||||
parts[0] = parts[0].TrimStart();
|
||||
parts[^1] = parts[^1].TrimEnd();
|
||||
|
||||
//Replace all multispace substrings with single space
|
||||
for (int i = 0; i < parts.Count; i++)
|
||||
{
|
||||
string original;
|
||||
do
|
||||
{
|
||||
original = parts[i];
|
||||
parts[i] = original.Replace(" ", " ");
|
||||
}while(original.Length != parts[i].Length);
|
||||
}
|
||||
|
||||
//Remove instances of double spaces at part boundaries
|
||||
for (int i = 1; i < parts.Count; i++)
|
||||
{
|
||||
if (parts[i - 1].EndsWith(' ') && parts[i].StartsWith(' '))
|
||||
{
|
||||
parts[i] = parts[i].Substring(1);
|
||||
|
||||
if (parts[i].Length == 0)
|
||||
{
|
||||
parts.RemoveAt(i);
|
||||
i--;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Registered Template Properties
|
||||
|
||||
private static readonly PropertyTagCollection<LibraryBookDto> filePropertyTags =
|
||||
new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter)
|
||||
new(caseSensative: true, StringFormatter, DateTimeFormatter, IntegerFormatter, FloatFormatter)
|
||||
{
|
||||
//Don't allow formatting of Id
|
||||
{ TemplateTags.Id, lb => lb.AudibleProductId, v => v },
|
||||
{ TemplateTags.Title, lb => lb.Title },
|
||||
{ TemplateTags.Title, lb => lb.TitleWithSubtitle },
|
||||
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
|
||||
{ TemplateTags.AudibleTitle, lb => lb.Title },
|
||||
{ TemplateTags.AudibleSubtitle, lb => lb.Subtitle },
|
||||
{ TemplateTags.Author, lb => lb.Authors, NameListFormat.Formatter },
|
||||
{ TemplateTags.FirstAuthor, lb => lb.FirstAuthor },
|
||||
{ TemplateTags.Narrator, lb => lb.Narrators, NameListFormat.Formatter },
|
||||
{ TemplateTags.FirstNarrator, lb => lb.FirstNarrator },
|
||||
{ TemplateTags.Series, lb => lb.SeriesName },
|
||||
{ TemplateTags.SeriesNumber, lb => lb.SeriesNumber },
|
||||
{ TemplateTags.SeriesNumber, lb => lb.IsPodcastParent ? null : lb.SeriesNumber },
|
||||
{ TemplateTags.Language, lb => lb.Language },
|
||||
//Don't allow formatting of LanguageShort
|
||||
{ TemplateTags.LanguageShort, lb =>lb.Language, getLanguageShort },
|
||||
{ TemplateTags.Bitrate, lb => lb.BitRate },
|
||||
{ TemplateTags.SampleRate, lb => lb.SampleRate },
|
||||
{ TemplateTags.Channels, lb => lb.Channels },
|
||||
{ TemplateTags.Bitrate, lb => (int?)(lb.IsPodcastParent ? null : lb.BitRate) },
|
||||
{ TemplateTags.SampleRate, lb => (int?)(lb.IsPodcastParent ? null : lb.SampleRate) },
|
||||
{ TemplateTags.Channels, lb => (int?)(lb.IsPodcastParent ? null : lb.Channels) },
|
||||
{ TemplateTags.Account, lb => lb.Account },
|
||||
{ TemplateTags.AccountNickname, lb => lb.AccountNickname },
|
||||
{ TemplateTags.Locale, lb => lb.Locale },
|
||||
{ TemplateTags.YearPublished, lb => lb.YearPublished },
|
||||
{ TemplateTags.DatePublished, lb => lb.DatePublished },
|
||||
@@ -226,8 +276,10 @@ namespace LibationFileManager
|
||||
{
|
||||
new PropertyTagCollection<LibraryBookDto>(caseSensative: true, StringFormatter)
|
||||
{
|
||||
{ TemplateTags.Title, lb => lb.Title },
|
||||
{ TemplateTags.Title, lb => lb.TitleWithSubtitle },
|
||||
{ TemplateTags.TitleShort, lb => getTitleShort(lb.Title) },
|
||||
{ TemplateTags.AudibleTitle, lb => lb.Title },
|
||||
{ TemplateTags.AudibleSubtitle, lb => lb.Subtitle },
|
||||
{ TemplateTags.Series, lb => lb.SeriesName },
|
||||
},
|
||||
new PropertyTagCollection<MultiConvertFileProperties>(caseSensative: true, StringFormatter, IntegerFormatter, DateTimeFormatter)
|
||||
@@ -242,9 +294,14 @@ namespace LibationFileManager
|
||||
|
||||
private static readonly ConditionalTagCollection<LibraryBookDto> conditionalTags = new()
|
||||
{
|
||||
{ TemplateTags.IfSeries, lb => lb.IsSeries },
|
||||
{ TemplateTags.IfPodcast, lb => lb.IsPodcast },
|
||||
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast },
|
||||
{ TemplateTags.IfSeries, lb => lb.IsSeries || lb.IsPodcastParent },
|
||||
{ TemplateTags.IfPodcast, lb => lb.IsPodcast || lb.IsPodcastParent },
|
||||
{ TemplateTags.IfBookseries, lb => lb.IsSeries && !lb.IsPodcast && !lb.IsPodcastParent },
|
||||
};
|
||||
|
||||
private static readonly ConditionalTagCollection<LibraryBookDto> folderConditionalTags = new()
|
||||
{
|
||||
{ TemplateTags.IfPodcastParent, lb => lb.IsPodcastParent }
|
||||
};
|
||||
|
||||
#endregion
|
||||
@@ -273,10 +330,20 @@ namespace LibationFileManager
|
||||
}
|
||||
|
||||
private static string IntegerFormatter(ITemplateTag templateTag, int value, string formatString)
|
||||
=> FloatFormatter(templateTag, value, formatString);
|
||||
|
||||
private static string FloatFormatter(ITemplateTag templateTag, float value, string formatString)
|
||||
{
|
||||
if (int.TryParse(formatString, out var numDigits))
|
||||
return value.ToString($"D{numDigits}");
|
||||
return value.ToString();
|
||||
if (int.TryParse(formatString, out var numDigits) && numDigits > 0)
|
||||
{
|
||||
//Zero-pad the integer part
|
||||
var strValue = value.ToString();
|
||||
var decIndex = strValue.IndexOf(System.Globalization.NumberFormatInfo.CurrentInfo.NumberDecimalSeparator);
|
||||
var zeroPad = decIndex == -1 ? int.Max(0, numDigits - strValue.Length) : int.Max(0, numDigits - decIndex);
|
||||
|
||||
return new string('0', zeroPad) + strValue;
|
||||
}
|
||||
return value.ToString(formatString);
|
||||
}
|
||||
|
||||
private static string DateTimeFormatter(ITemplateTag templateTag, DateTime value, string formatString)
|
||||
@@ -293,7 +360,8 @@ namespace LibationFileManager
|
||||
public static string Name { get; }= "Folder Template";
|
||||
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate));
|
||||
public static string DefaultTemplate { get; } = "<title short> [<id>]";
|
||||
public static IEnumerable<TagCollection> TagCollections => new TagCollection[] { filePropertyTags, conditionalTags };
|
||||
public static IEnumerable<TagCollection> TagCollections
|
||||
=> new TagCollection[] { filePropertyTags, conditionalTags, folderConditionalTags };
|
||||
|
||||
public override IEnumerable<string> Errors
|
||||
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;
|
||||
|
||||
42
Source/LibationSearchEngine/IndexRule.cs
Normal file
42
Source/LibationSearchEngine/IndexRule.cs
Normal file
@@ -0,0 +1,42 @@
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationSearchEngine;
|
||||
|
||||
public enum FieldType
|
||||
{
|
||||
Bool,
|
||||
String,
|
||||
Number,
|
||||
ID,
|
||||
Raw
|
||||
}
|
||||
|
||||
public class IndexRule
|
||||
{
|
||||
public FieldType FieldType { get; }
|
||||
public Func<LibraryBook, string> GetValue { get; }
|
||||
public ReadOnlyCollection<string> FieldNames { get; }
|
||||
|
||||
public IndexRule(FieldType fieldType, Func<LibraryBook, string> valueGetter, params string[] fieldNames)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(valueGetter, nameof(valueGetter));
|
||||
ArgumentValidator.EnsureNotNull(fieldNames, nameof(fieldNames));
|
||||
ArgumentValidator.EnsureGreaterThan(fieldNames.Length, $"{nameof(fieldNames)}.{nameof(fieldNames.Length)}", 0);
|
||||
var fieldNamesValidated
|
||||
= fieldNames
|
||||
.Select((n, i) => ArgumentValidator.EnsureNotNullOrWhiteSpace(n, $"{nameof(fieldNames)}[{i}]")
|
||||
.Trim());
|
||||
|
||||
GetValue = valueGetter;
|
||||
FieldType = fieldType;
|
||||
FieldNames = new ReadOnlyCollection<string>(fieldNamesValidated.ToList());
|
||||
}
|
||||
public override string ToString()
|
||||
=> FieldNames.Count == 1
|
||||
? $"{FieldNames.First()}"
|
||||
: $"{FieldNames.First()} ({string.Join(", ", FieldNames.Skip(1))})";
|
||||
}
|
||||
27
Source/LibationSearchEngine/IndexRuleCollection.cs
Normal file
27
Source/LibationSearchEngine/IndexRuleCollection.cs
Normal file
@@ -0,0 +1,27 @@
|
||||
using DataLayer;
|
||||
using System;
|
||||
using System.Collections;
|
||||
using System.Collections.Generic;
|
||||
using System.Diagnostics;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationSearchEngine;
|
||||
|
||||
[DebuggerDisplay("Count = {rules.Count,nq}")]
|
||||
public class IndexRuleCollection : IEnumerable<IndexRule>
|
||||
{
|
||||
private readonly List<IndexRule> rules = new();
|
||||
public IEnumerable<string> IdFieldNames => rules.Where(x => x.FieldType is FieldType.ID).SelectMany(r => r.FieldNames);
|
||||
public IEnumerable<string> BoolFieldNames => rules.Where(x => x.FieldType is FieldType.Bool).SelectMany(r => r.FieldNames);
|
||||
public IEnumerable<string> StringFieldNames => rules.Where(x => x.FieldType is FieldType.String).SelectMany(r => r.FieldNames);
|
||||
public IEnumerable<string> NumberFieldNames => rules.Where(x => x.FieldType is FieldType.Number).SelectMany(r => r.FieldNames);
|
||||
|
||||
public void Add(FieldType fieldType, Func<LibraryBook, string> getter, params string[] fieldNames)
|
||||
=> rules.Add(new IndexRule(fieldType, getter, fieldNames));
|
||||
|
||||
public IndexRule GetRuleByFieldName(string fieldName)
|
||||
=> rules.SingleOrDefault(r => r.FieldNames.Any(n => n.Equals(fieldName, StringComparison.OrdinalIgnoreCase)));
|
||||
|
||||
public IEnumerator<IndexRule> GetEnumerator() => rules.GetEnumerator();
|
||||
IEnumerator IEnumerable.GetEnumerator() => GetEnumerator();
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using DataLayer;
|
||||
using Lucene.Net.Analysis;
|
||||
using Lucene.Net.Documents;
|
||||
using Lucene.Net.QueryParsers;
|
||||
@@ -10,21 +10,47 @@ namespace LibationSearchEngine
|
||||
{
|
||||
// field names are case specific and, due to StandardAnalyzer, content is case INspecific
|
||||
internal static class LuceneExtensions
|
||||
{
|
||||
internal static void AddRaw(this Document document, string name, string value)
|
||||
=> document.Add(new Field(name, value, Field.Store.YES, Field.Index.NOT_ANALYZED));
|
||||
{
|
||||
internal static void AddAnalyzed(this Document document, string name, string value)
|
||||
{
|
||||
if (value is not null)
|
||||
document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.ANALYZED));
|
||||
}
|
||||
|
||||
internal static void AddAnalyzed(this Document document, string name, string value)
|
||||
{
|
||||
if (value is not null)
|
||||
document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.ANALYZED));
|
||||
}
|
||||
internal static void RemoveRule(this Document document, IndexRule rule)
|
||||
{
|
||||
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
|
||||
// ie: must remove old before adding new else will create unwanted duplicates.
|
||||
foreach (var name in rule.FieldNames)
|
||||
document.RemoveFields(name.ToLowerInvariant());
|
||||
}
|
||||
|
||||
internal static void AddNotAnalyzed(this Document document, string name, string value)
|
||||
=> document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.NOT_ANALYZED));
|
||||
internal static void AddIndexRule(this Document document, IndexRule rule, LibraryBook libraryBook)
|
||||
{
|
||||
string value = rule.GetValue(libraryBook);
|
||||
if (value is null) return;
|
||||
|
||||
internal static void AddBool(this Document document, string name, bool value)
|
||||
=> document.Add(new Field(name.ToLowerInvariant(), value.ToString(), Field.Store.YES, Field.Index.ANALYZED_NO_NORMS));
|
||||
foreach (var name in rule.FieldNames)
|
||||
{
|
||||
// fields are key value pairs and MULTIPLE FIELDS CAN HAVE THE SAME KEY.
|
||||
// splitting authors and narrators and/or tags into multiple fields could be interesting research.
|
||||
// it could allow for more advanced searches, or maybe it could break broad searches.
|
||||
|
||||
// all searching should be lowercase
|
||||
// external callers have the reasonable expectation that product id will be returned CASE SPECIFIC
|
||||
var field = rule.FieldType switch
|
||||
{
|
||||
FieldType.Bool => new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.ANALYZED_NO_NORMS),
|
||||
FieldType.String => new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.ANALYZED),
|
||||
FieldType.Number => new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.NOT_ANALYZED),
|
||||
FieldType.ID => new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.NOT_ANALYZED),
|
||||
FieldType.Raw => new Field(name, value, Field.Store.YES, Field.Index.NOT_ANALYZED),
|
||||
_ => throw new KeyNotFoundException(),
|
||||
};
|
||||
|
||||
document.Add(field);
|
||||
}
|
||||
}
|
||||
|
||||
internal static Query GetQuery(this Analyzer analyzer, string defaultField, string searchString)
|
||||
=> new QueryParser(SearchEngine.Version, defaultField.ToLowerInvariant(), analyzer).Parse(searchString);
|
||||
|
||||
@@ -1,103 +0,0 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LibationSearchEngine
|
||||
{
|
||||
internal static partial class LuceneRegex
|
||||
{
|
||||
#region pattern pieces
|
||||
// negative lookbehind: cannot be preceeded by an escaping \
|
||||
const string NOT_ESCAPED = @"(?<!\\)";
|
||||
|
||||
// disallow spaces and lucene reserved characters
|
||||
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
|
||||
// define chars
|
||||
// escape and concat
|
||||
// create regex. also disallow spaces
|
||||
private static char[] disallowedChars { get; } = new[] {
|
||||
'+', '-', '&', '|', '!', '(', ')', '{', '}', '[', ']', '^', '"', '~', '*', '?', ':', '\\' };
|
||||
private static string disallowedCharsEscaped { get; } = disallowedChars.Select(c => $@"\{c}").Aggregate((a, b) => a + b);
|
||||
private static string WORD_CAPTURE { get; } = $@"([^\s{disallowedCharsEscaped}]+)";
|
||||
|
||||
// : with optional preceeding spaces. capture these so i don't accidentally replace a non-field name
|
||||
const string FIELD_END = @"(\s*:)";
|
||||
|
||||
const string BEGIN_TAG = @"\[";
|
||||
const string END_TAG = @"\]";
|
||||
|
||||
// space is forgiven at beginning and end of tag but not in the middle
|
||||
// literal space character only. do NOT allow new lines, tabs, ...
|
||||
const string OPTIONAL_SPACE_LITERAL = @"\u0020*";
|
||||
#endregion
|
||||
|
||||
private static string tagPattern { get; } = NOT_ESCAPED + BEGIN_TAG + OPTIONAL_SPACE_LITERAL + WORD_CAPTURE + OPTIONAL_SPACE_LITERAL + END_TAG;
|
||||
public static Regex TagRegex { get; } = new Regex(tagPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
|
||||
|
||||
private static string fieldPattern { get; } = NOT_ESCAPED + WORD_CAPTURE + FIELD_END;
|
||||
public static Regex FieldRegex { get; } = new Regex(fieldPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.Compiled);
|
||||
|
||||
/// <summary>
|
||||
/// auto-pad numbers to 8 char.s. This will match int.s and dates (yyyyMMdd)
|
||||
/// positive look behind: beginning space { [ :
|
||||
/// positive look ahead: end space ] }
|
||||
/// </summary>
|
||||
|
||||
[GeneratedRegex(@"(?<=^|\s|\{|\[|:)(\d+\.?\d*)(?=$|\s|\]|\})", RegexOptions.Compiled)]
|
||||
public static partial Regex NumbersRegex();
|
||||
|
||||
/// <summary>
|
||||
/// proper bools are single keywords which are turned into keyword:True
|
||||
/// if bordered by colons or inside brackets, they are not stand-alone bool keywords
|
||||
/// the negative lookbehind and lookahead patterns prevent bugs where a bool keyword is also a user-defined tag:
|
||||
/// [israted]
|
||||
/// parseTag => tags:israted
|
||||
/// replaceBools => tags:israted:True
|
||||
/// or
|
||||
/// [israted]
|
||||
/// replaceBools => israted:True
|
||||
/// parseTag => [israted:True]
|
||||
/// also don't want to apply :True where the value already exists:
|
||||
/// israted:false => israted:false:True
|
||||
///
|
||||
/// despite using parans, lookahead and lookbehind are zero-length assertions which do not capture. therefore the bool search keyword is still $1 since it's the first and only capture
|
||||
/// </summary>
|
||||
private static string boolPattern_parameterized { get; }
|
||||
= @"
|
||||
### IMPORTANT: 'ignore whitespace' is only partially honored in character sets
|
||||
### - new lines are ok
|
||||
### - ANY leading whitespace is treated like actual matching spaces :(
|
||||
|
||||
### can't begin with colon. incorrect syntax
|
||||
### can't begin with open bracket: this signals the start of a tag
|
||||
(?<! # begin negative lookbehind
|
||||
[:\[] # char set: colon and open bracket, escaped
|
||||
\s* # optional space
|
||||
) # end negative lookbehind
|
||||
|
||||
\b # word boundary
|
||||
({0}) # captured bool search keyword. this is the $1 reference used in regex.Replace
|
||||
\b # word boundary
|
||||
|
||||
### can't end with colon. this signals that the bool's value already exists
|
||||
### can't begin with close bracket: this signals the end of a tag
|
||||
(?! # begin negative lookahead
|
||||
\s* # optional space
|
||||
[:\]] # char set: colon and close bracket, escaped
|
||||
) # end negative lookahead
|
||||
";
|
||||
private static Dictionary<string, Regex> boolRegexDic { get; } = new Dictionary<string, Regex>();
|
||||
public static Regex GetBoolRegex(string boolSearch)
|
||||
{
|
||||
if (boolRegexDic.TryGetValue(boolSearch, out var regex))
|
||||
return regex;
|
||||
|
||||
var boolPattern = string.Format(boolPattern_parameterized, boolSearch);
|
||||
regex = new Regex(boolPattern, RegexOptions.IgnorePatternWhitespace | RegexOptions.IgnoreCase | RegexOptions.Compiled);
|
||||
boolRegexDic.Add(boolSearch, regex);
|
||||
|
||||
return regex;
|
||||
}
|
||||
}
|
||||
}
|
||||
125
Source/LibationSearchEngine/QuerySanitizer.cs
Normal file
125
Source/LibationSearchEngine/QuerySanitizer.cs
Normal file
@@ -0,0 +1,125 @@
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Lucene.Net.Analysis.Tokenattributes;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
|
||||
namespace LibationSearchEngine
|
||||
{
|
||||
internal static partial class QuerySanitizer
|
||||
{
|
||||
private static readonly HashSet<string> idTerms
|
||||
= SearchEngine.FieldIndexRules.IdFieldNames
|
||||
.Select(n => n.ToLowerInvariant())
|
||||
.ToHashSet();
|
||||
|
||||
private static readonly HashSet<string> boolTerms
|
||||
= SearchEngine.FieldIndexRules.BoolFieldNames
|
||||
.Select(n => n.ToLowerInvariant())
|
||||
.ToHashSet();
|
||||
|
||||
private static readonly HashSet<string> fieldTerms
|
||||
= SearchEngine.FieldIndexRules
|
||||
.SelectMany(r => r.FieldNames)
|
||||
.Select(n => n.ToLowerInvariant())
|
||||
.ToHashSet();
|
||||
|
||||
private static readonly Regex tagRegex = TagRegex();
|
||||
|
||||
internal static string Sanitize(string searchString, StandardAnalyzer analyzer)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
return SearchEngine.ALL_QUERY;
|
||||
|
||||
//Replace a block tags with tags with proper tag query syntax
|
||||
//eg: [foo] -> tags:foo
|
||||
searchString = tagRegex.Replace(searchString, $"{SearchEngine.TAGS}:$1 ");
|
||||
|
||||
// range operator " TO " and bool operators " AND " and " OR " must be uppercase
|
||||
searchString
|
||||
= searchString
|
||||
.Replace(" to ", " TO ", System.StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(" and ", " AND ", System.StringComparison.OrdinalIgnoreCase)
|
||||
.Replace(" or ", " OR ", System.StringComparison.OrdinalIgnoreCase);
|
||||
|
||||
using var tokenStream = analyzer.TokenStream(SearchEngine.ALL, new System.IO.StringReader(searchString));
|
||||
|
||||
var partList = new List<string>();
|
||||
int previousEndOffset = 0;
|
||||
bool previousIsBool = false, previousIsTags = false, previousIsAsin = false;
|
||||
|
||||
while (tokenStream.IncrementToken())
|
||||
{
|
||||
var term = tokenStream.GetAttribute<ITermAttribute>().Term;
|
||||
var offset = tokenStream.GetAttribute<IOffsetAttribute>();
|
||||
|
||||
if (previousIsBool && !bool.TryParse(term, out _))
|
||||
{
|
||||
//The previous term was a boolean tag and this term is NOT a bool value
|
||||
//Add the default ":True" bool and continue parsing the current term
|
||||
partList.Add(":True");
|
||||
previousIsBool = false;
|
||||
}
|
||||
|
||||
//Add all text between the current token and the previous token
|
||||
partList.Add(searchString.Substring(previousEndOffset, offset.StartOffset - previousEndOffset));
|
||||
|
||||
if (previousIsBool)
|
||||
{
|
||||
//The previous term was a boolean tag and this term is a bool value
|
||||
addUnalteredToken(offset);
|
||||
previousIsBool = false;
|
||||
}
|
||||
else if (previousIsAsin)
|
||||
{
|
||||
//The previous term was an ASIN field ID, so this term is an ASIN
|
||||
partList.Add(term);
|
||||
previousIsAsin = false;
|
||||
}
|
||||
else if (previousIsTags)
|
||||
{
|
||||
//This term is a tag. Do this check before checking if term is a defined field
|
||||
//so that "tags:israted" does not parse as a bool
|
||||
addUnalteredToken(offset);
|
||||
previousIsTags = false;
|
||||
}
|
||||
else if (double.TryParse(term, out var num))
|
||||
{
|
||||
//Term is a number so pad it with zeros
|
||||
partList.Add(num.ToLuceneString());
|
||||
}
|
||||
else if (fieldTerms.Contains(term))
|
||||
{
|
||||
//Term is a defined search field, add it.
|
||||
//The StandardAnalyzer already converts all terms to lowercase
|
||||
partList.Add(term);
|
||||
previousIsBool = boolTerms.Contains(term);
|
||||
previousIsAsin = idTerms.Contains(term);
|
||||
previousIsTags = term == SearchEngine.TAGS;
|
||||
}
|
||||
else
|
||||
{
|
||||
//Term is any other user-defined constant value
|
||||
addUnalteredToken(offset);
|
||||
}
|
||||
|
||||
previousEndOffset = offset.EndOffset;
|
||||
}
|
||||
|
||||
if (previousIsBool)
|
||||
partList.Add(":True");
|
||||
|
||||
//Add ending non-token text
|
||||
partList.Add(searchString.Substring(previousEndOffset, searchString.Length - previousEndOffset));
|
||||
|
||||
return string.Concat(partList);
|
||||
|
||||
//Add the full, unaltered token as well as all inter-token text
|
||||
void addUnalteredToken(IOffsetAttribute offset) =>
|
||||
partList.Add(searchString.Substring(offset.StartOffset, offset.EndOffset - offset.StartOffset));
|
||||
}
|
||||
|
||||
[GeneratedRegex(@"(?<!\\)\[\u0020*(\w+)\u0020*\]", RegexOptions.Compiled)]
|
||||
private static partial Regex TagRegex();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,6 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
@@ -24,164 +22,47 @@ namespace LibationSearchEngine
|
||||
public const string ALL = "all";
|
||||
|
||||
#region index rules
|
||||
// common fields used in the "all" default search field
|
||||
public const string ALL_AUDIBLE_PRODUCT_ID = nameof(Book.AudibleProductId);
|
||||
public const string ALL_TITLE = nameof(Book.Title);
|
||||
public const string ALL_AUTHOR_NAMES = "AuthorNames";
|
||||
public const string ALL_NARRATOR_NAMES = "NarratorNames";
|
||||
public const string ALL_SERIES_NAMES = "SeriesNames";
|
||||
|
||||
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
|
||||
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
|
||||
new Dictionary<string, Func<LibraryBook, string>>
|
||||
{
|
||||
[nameof(Book.AudibleProductId)] = lb => lb.Book.AudibleProductId,
|
||||
["ProductId"] = lb => lb.Book.AudibleProductId,
|
||||
["Id"] = lb => lb.Book.AudibleProductId,
|
||||
["ASIN"] = lb => lb.Book.AudibleProductId
|
||||
}
|
||||
);
|
||||
|
||||
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> stringIndexRules { get; }
|
||||
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
|
||||
new Dictionary<string, Func<LibraryBook, string>>
|
||||
{
|
||||
[nameof(Book.Title)] = lb => lb.Book.Title,
|
||||
[ALL_AUTHOR_NAMES] = lb => lb.Book.AuthorNames(),
|
||||
["Author"] = lb => lb.Book.AuthorNames(),
|
||||
["Authors"] = lb => lb.Book.AuthorNames(),
|
||||
[ALL_NARRATOR_NAMES] = lb => lb.Book.NarratorNames(),
|
||||
["Narrator"] = lb => lb.Book.NarratorNames(),
|
||||
["Narrators"] = lb => lb.Book.NarratorNames(),
|
||||
[nameof(Book.Publisher)] = lb => lb.Book.Publisher,
|
||||
|
||||
[ALL_SERIES_NAMES] = lb => lb.Book.SeriesNames(),
|
||||
["Series"] = lb => lb.Book.SeriesNames(),
|
||||
["SeriesId"] = lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)),
|
||||
|
||||
["CategoriesNames"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
|
||||
[nameof(Book.Category)] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
|
||||
["Categories"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
|
||||
["CategoriesId"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
|
||||
["CategoryId"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
|
||||
|
||||
[TAGS.FirstCharToUpper()] = lb => lb.Book.UserDefinedItem.Tags,
|
||||
|
||||
["Locale"] = lb => lb.Book.Locale,
|
||||
["Region"] = lb => lb.Book.Locale,
|
||||
["Account"] = lb => lb.Account,
|
||||
["Email"] = lb => lb.Account
|
||||
}
|
||||
);
|
||||
|
||||
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> numberIndexRules { get; }
|
||||
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
|
||||
new Dictionary<string, Func<LibraryBook, string>>
|
||||
{
|
||||
// for now, all numbers are padded to 8 char.s
|
||||
// This will allow a single method to auto-pad numbers. The method will match these as well as date: yyyymmdd
|
||||
[nameof(Book.LengthInMinutes)] = lb => lb.Book.LengthInMinutes.ToLuceneString(),
|
||||
["Length"] = lb => lb.Book.LengthInMinutes.ToLuceneString(),
|
||||
["Minutes"] = lb => lb.Book.LengthInMinutes.ToLuceneString(),
|
||||
["Hours"] = lb => (lb.Book.LengthInMinutes / 60).ToLuceneString(),
|
||||
|
||||
["ProductRating"] = lb => lb.Book.Rating.OverallRating.ToLuceneString(),
|
||||
["Rating"] = lb => lb.Book.Rating.OverallRating.ToLuceneString(),
|
||||
["UserRating"] = lb => userOverallRating(lb.Book),
|
||||
["MyRating"] = lb => userOverallRating(lb.Book),
|
||||
|
||||
[nameof(LibraryBook.DateAdded)] = lb => lb.DateAdded.ToLuceneString(),
|
||||
[nameof(Book.DatePublished)] = lb => lb.Book.DatePublished?.ToLuceneString() ?? "",
|
||||
|
||||
["LastDownload"] = lb => lb.Book.UserDefinedItem.LastDownloaded.ToLuceneString(),
|
||||
["LastDownloaded"] = lb => lb.Book.UserDefinedItem.LastDownloaded.ToLuceneString()
|
||||
}
|
||||
);
|
||||
|
||||
private static ReadOnlyDictionary<string, Func<LibraryBook, bool>> boolIndexRules { get; }
|
||||
= new ReadOnlyDictionary<string, Func<LibraryBook, bool>>(
|
||||
new Dictionary<string, Func<LibraryBook, bool>>
|
||||
{
|
||||
["HasDownloads"] = lb => lb.Book.HasPdf(),
|
||||
["HasDownload"] = lb => lb.Book.HasPdf(),
|
||||
["Downloads"] = lb => lb.Book.HasPdf(),
|
||||
["Download"] = lb => lb.Book.HasPdf(),
|
||||
["HasPDFs"] = lb => lb.Book.HasPdf(),
|
||||
["HasPDF"] = lb => lb.Book.HasPdf(),
|
||||
["PDFs"] = lb => lb.Book.HasPdf(),
|
||||
["PDF"] = lb => lb.Book.HasPdf(),
|
||||
|
||||
["IsRated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f,
|
||||
["Rated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f,
|
||||
|
||||
["IsAuthorNarrated"] = isAuthorNarrated,
|
||||
["AuthorNarrated"] = isAuthorNarrated,
|
||||
|
||||
[nameof(Book.IsAbridged)] = lb => lb.Book.IsAbridged,
|
||||
["Abridged"] = lb => lb.Book.IsAbridged,
|
||||
|
||||
["IsLiberated"] = lb => isLiberated(lb.Book),
|
||||
["Liberated"] = lb => isLiberated(lb.Book),
|
||||
["LiberatedError"] = lb => liberatedError(lb.Book),
|
||||
|
||||
["Podcast"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["Podcasts"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["IsPodcast"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["Episode"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["Episodes"] = lb => lb.Book.IsEpisodeChild(),
|
||||
["IsEpisode"] = lb => lb.Book.IsEpisodeChild(),
|
||||
|
||||
["Absent"] = lb => lb.AbsentFromLastScan,
|
||||
["AbsentFromLastScan"] = lb => lb.AbsentFromLastScan,
|
||||
}
|
||||
);
|
||||
|
||||
private static bool isAuthorNarrated(LibraryBook lb)
|
||||
private static bool isAuthorNarrated(Book book)
|
||||
{
|
||||
var authors = lb.Book.Authors.Select(a => a.Name).ToArray();
|
||||
var narrators = lb.Book.Narrators.Select(a => a.Name).ToArray();
|
||||
var authors = book.Authors.Select(a => a.Name).ToArray();
|
||||
var narrators = book.Narrators.Select(a => a.Name).ToArray();
|
||||
return authors.Intersect(narrators).Any();
|
||||
}
|
||||
private static string userOverallRating(Book book) => book.UserDefinedItem.Rating.OverallRating.ToLuceneString();
|
||||
private static bool isLiberated(Book book) => book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated;
|
||||
private static bool liberatedError(Book book) => book.UserDefinedItem.BookStatus == LiberatedStatus.Error;
|
||||
|
||||
// use these common fields in the "all" default search field
|
||||
private static IEnumerable<Func<LibraryBook, string>> allFieldIndexRules { get; }
|
||||
= new List<Func<LibraryBook, string>>
|
||||
{
|
||||
idIndexRules[ALL_AUDIBLE_PRODUCT_ID],
|
||||
stringIndexRules[ALL_TITLE],
|
||||
stringIndexRules[ALL_AUTHOR_NAMES],
|
||||
stringIndexRules[ALL_NARRATOR_NAMES],
|
||||
stringIndexRules[ALL_SERIES_NAMES]
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region get search fields. used for display in help
|
||||
public static IEnumerable<string> GetSearchIdFields()
|
||||
// use these common fields in the "all" default search field
|
||||
public static IndexRuleCollection FieldIndexRules { get; } = new IndexRuleCollection
|
||||
{
|
||||
foreach (var key in idIndexRules.Keys)
|
||||
yield return key;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetSearchStringFields()
|
||||
{
|
||||
foreach (var key in stringIndexRules.Keys)
|
||||
yield return key;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetSearchBoolFields()
|
||||
{
|
||||
foreach (var key in boolIndexRules.Keys)
|
||||
yield return key;
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetSearchNumberFields()
|
||||
{
|
||||
foreach (var key in numberIndexRules.Keys)
|
||||
yield return key;
|
||||
}
|
||||
{ FieldType.ID, lb => lb.Book.AudibleProductId.ToLowerInvariant(), nameof(Book.AudibleProductId), "ProductId", "Id", "ASIN" },
|
||||
{ FieldType.Raw, lb => lb.Book.AudibleProductId, _ID_ },
|
||||
{ FieldType.String, lb => lb.Book.TitleWithSubtitle, "Title", "ProductId", "Id", "ASIN" },
|
||||
{ FieldType.String, lb => lb.Book.AuthorNames(), "AuthorNames", "Author", "Authors" },
|
||||
{ FieldType.String, lb => lb.Book.NarratorNames(), "NarratorNames", "Narrator", "Narrators" },
|
||||
{ FieldType.String, lb => lb.Book.Publisher, nameof(Book.Publisher) },
|
||||
{ FieldType.String, lb => lb.Book.SeriesNames(), "SeriesNames", "Narrator", "Series" },
|
||||
{ FieldType.String, lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)), "SeriesId" },
|
||||
{ FieldType.String, lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()), nameof(Book.Category), "Categories", "CategoriesId", "CategoryId", "CategoriesNames" },
|
||||
{ FieldType.String, lb => lb.Book.UserDefinedItem.Tags, TAGS.FirstCharToUpper() },
|
||||
{ FieldType.String, lb => lb.Book.Locale, "Locale", "Region" },
|
||||
{ FieldType.String, lb => lb.Account, "Account", "Email" },
|
||||
{ FieldType.Bool, lb => lb.Book.HasPdf().ToString(), "HasDownloads", "HasDownload", "Downloads" , "Download", "HasPDFs", "HasPDF" , "PDFs", "PDF" },
|
||||
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" },
|
||||
{ FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" },
|
||||
{ FieldType.Bool, lb => lb.Book.IsAbridged.ToString(), nameof(Book.IsAbridged), "Abridged" },
|
||||
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated).ToString(), "IsLiberated", "Liberated" },
|
||||
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" },
|
||||
{ FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" },
|
||||
{ FieldType.Bool, lb => lb.AbsentFromLastScan.ToString(), "AbsentFromLastScan", "Absent" },
|
||||
// all numbers are padded to 8 char.s
|
||||
// This will allow a single method to auto-pad numbers. The method will match these as well as date: yyyymmdd
|
||||
{ FieldType.Number, lb => lb.Book.LengthInMinutes.ToLuceneString(), nameof(Book.LengthInMinutes), "Length", "Minutes" },
|
||||
{ FieldType.Number, lb => (lb.Book.LengthInMinutes / 60).ToLuceneString(), "Hours" },
|
||||
{ FieldType.Number, lb => lb.Book.Rating.OverallRating.ToLuceneString(), "ProductRating", "Rating" },
|
||||
{ FieldType.Number, lb => lb.Book.UserDefinedItem.Rating.OverallRating.ToLuceneString(), "UserRating", "MyRating" },
|
||||
{ FieldType.Number, lb => lb.Book.DatePublished?.ToLuceneString() ?? "", nameof(Book.DatePublished) },
|
||||
{ FieldType.Number, lb => lb.Book.UserDefinedItem.LastDownloaded.ToLuceneString(), nameof(UserDefinedItem.LastDownloaded), "LastDownload" },
|
||||
{ FieldType.Number, lb => lb.DateAdded.ToLuceneString(), nameof(LibraryBook.DateAdded) }
|
||||
};
|
||||
#endregion
|
||||
|
||||
#region create and update index
|
||||
@@ -223,35 +104,15 @@ namespace LibationSearchEngine
|
||||
{
|
||||
var doc = new Document();
|
||||
|
||||
// refine with
|
||||
// http://codeclimber.net.nz/archive/2009/09/10/how-subtext-lucenenet-index-is-structured/
|
||||
|
||||
// fields are key value pairs and MULTIPLE FIELDS CAN HAVE THE SAME KEY.
|
||||
// splitting authors and narrators and/or tags into multiple fields could be interesting research.
|
||||
// it could allow for more advanced searches, or maybe it could break broad searches.
|
||||
|
||||
// all searching should be lowercase
|
||||
// external callers have the reasonable expectation that product id will be returned CASE SPECIFIC
|
||||
doc.AddRaw(_ID_, libraryBook.Book.AudibleProductId);
|
||||
|
||||
// concat all common fields for the default 'all' field
|
||||
var allConcat =
|
||||
allFieldIndexRules
|
||||
.Select(rule => rule(libraryBook))
|
||||
FieldIndexRules
|
||||
.Select(rule => rule.GetValue(libraryBook))
|
||||
.Aggregate((a, b) => $"{a} {b}");
|
||||
doc.AddAnalyzed(ALL, allConcat);
|
||||
|
||||
foreach (var kvp in idIndexRules)
|
||||
doc.AddNotAnalyzed(kvp.Key, kvp.Value(libraryBook));
|
||||
|
||||
foreach (var kvp in stringIndexRules)
|
||||
doc.AddAnalyzed(kvp.Key, kvp.Value(libraryBook));
|
||||
|
||||
foreach (var kvp in boolIndexRules)
|
||||
doc.AddBool(kvp.Key, kvp.Value(libraryBook));
|
||||
|
||||
foreach (var kvp in numberIndexRules)
|
||||
doc.AddNotAnalyzed(kvp.Key, kvp.Value(libraryBook));
|
||||
foreach (var rule in FieldIndexRules)
|
||||
doc.AddIndexRule(rule, libraryBook);
|
||||
|
||||
return doc;
|
||||
}
|
||||
@@ -266,58 +127,39 @@ namespace LibationSearchEngine
|
||||
productId,
|
||||
d =>
|
||||
{
|
||||
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
|
||||
// ie: must remove old before adding new else will create unwanted duplicates.
|
||||
d.RemoveField(fieldName.ToLower());
|
||||
d.RemoveField(fieldName.ToLower());
|
||||
d.AddAnalyzed(fieldName, newValue);
|
||||
});
|
||||
|
||||
// update single document entry
|
||||
public void UpdateLiberatedStatus(Book book)
|
||||
=> updateDocument(
|
||||
book.AudibleProductId,
|
||||
d =>
|
||||
{
|
||||
//
|
||||
// TODO: better synonym handling. This is too easy to mess up
|
||||
//
|
||||
|
||||
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
|
||||
// ie: must remove old before adding new else will create unwanted duplicates.
|
||||
var v1 = isLiberated(book);
|
||||
d.RemoveField("isliberated");
|
||||
d.AddBool("IsLiberated", v1);
|
||||
d.RemoveField("liberated");
|
||||
d.AddBool("Liberated", v1);
|
||||
|
||||
var v2 = liberatedError(book);
|
||||
d.RemoveField("liberatederror");
|
||||
d.AddBool("LiberatedError", v2);
|
||||
|
||||
var v3 = book.UserDefinedItem.LastDownloaded?.ToLuceneString() ?? "";
|
||||
d.RemoveField("LastDownload");
|
||||
d.AddNotAnalyzed("LastDownload", v3);
|
||||
d.RemoveField("LastDownloaded");
|
||||
d.AddNotAnalyzed("LastDownloaded", v3);
|
||||
});
|
||||
|
||||
public void UpdateUserRatings(Book book)
|
||||
=>updateDocument(
|
||||
book.AudibleProductId,
|
||||
// update single document entry
|
||||
public void UpdateLiberatedStatus(LibraryBook book)
|
||||
=> updateDocument(
|
||||
book.Book.AudibleProductId,
|
||||
d =>
|
||||
{
|
||||
//
|
||||
// TODO: better synonym handling. This is too easy to mess up
|
||||
//
|
||||
var lib = FieldIndexRules.GetRuleByFieldName("IsLiberated");
|
||||
var libError = FieldIndexRules.GetRuleByFieldName("LiberatedError");
|
||||
var lastDl = FieldIndexRules.GetRuleByFieldName(nameof(UserDefinedItem.LastDownloaded));
|
||||
|
||||
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
|
||||
// ie: must remove old before adding new else will create unwanted duplicates.
|
||||
var v1 = userOverallRating(book);
|
||||
d.RemoveField("userrating");
|
||||
d.AddNotAnalyzed("UserRating", v1);
|
||||
d.RemoveField("myrating");
|
||||
d.AddNotAnalyzed("MyRating", v1);
|
||||
});
|
||||
d.RemoveRule(lib);
|
||||
d.RemoveRule(libError);
|
||||
d.RemoveRule(lastDl);
|
||||
|
||||
d.AddIndexRule(lib, book);
|
||||
d.AddIndexRule(libError, book);
|
||||
d.AddIndexRule(lastDl, book);
|
||||
});
|
||||
|
||||
public void UpdateUserRatings(LibraryBook book)
|
||||
=>updateDocument(
|
||||
book.Book.AudibleProductId,
|
||||
d =>
|
||||
{
|
||||
var rating = FieldIndexRules.GetRuleByFieldName("UserRating");
|
||||
|
||||
d.RemoveRule(rating);
|
||||
d.AddIndexRule(rating, book);
|
||||
});
|
||||
|
||||
private static void updateDocument(string productId, Action<Document> action)
|
||||
{
|
||||
@@ -334,11 +176,9 @@ namespace LibationSearchEngine
|
||||
return;
|
||||
var document = searcher.Doc(scoreDoc.Doc);
|
||||
|
||||
|
||||
// perform update
|
||||
action(document);
|
||||
|
||||
|
||||
// update index
|
||||
var createNewIndex = false;
|
||||
using var analyzer = new StandardAnalyzer(Version);
|
||||
@@ -352,115 +192,34 @@ namespace LibationSearchEngine
|
||||
|
||||
#region search
|
||||
public SearchResultSet Search(string searchString)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("original search string: {@DebugInfo}", new { searchString });
|
||||
searchString = FormatSearchQuery(searchString);
|
||||
{
|
||||
using var analyzer = new StandardAnalyzer(Lucene.Net.Util.Version.LUCENE_30);
|
||||
|
||||
Serilog.Log.Logger.Debug("original search string: {@DebugInfo}", new { searchString });
|
||||
searchString = QuerySanitizer.Sanitize(searchString, analyzer);
|
||||
Serilog.Log.Logger.Debug("formatted search string: {@DebugInfo}", new { searchString });
|
||||
|
||||
var results = generalSearch(searchString);
|
||||
var results = generalSearch(searchString, analyzer);
|
||||
Serilog.Log.Logger.Debug("Hit(s): {@DebugInfo}", new { count = results.Docs.Count() });
|
||||
displayResults(results);
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
internal static string FormatSearchQuery(string searchString)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
return ALL_QUERY;
|
||||
|
||||
searchString = replaceBools(searchString);
|
||||
|
||||
searchString = parseTag(searchString);
|
||||
|
||||
// in ranges " TO " must be uppercase
|
||||
searchString = searchString.Replace(" to ", " TO ");
|
||||
|
||||
searchString = padNumbers(searchString);
|
||||
|
||||
searchString = lowerFieldNames(searchString);
|
||||
|
||||
return searchString;
|
||||
}
|
||||
|
||||
#region format query string
|
||||
private static string parseTag(string tagSearchString)
|
||||
{
|
||||
var allMatches = LuceneRegex
|
||||
.TagRegex
|
||||
.Matches(tagSearchString)
|
||||
.Cast<Match>()
|
||||
.Select(a => a.ToString())
|
||||
.ToList();
|
||||
foreach (var match in allMatches)
|
||||
tagSearchString = tagSearchString.Replace(
|
||||
match,
|
||||
TAGS + ":" + match.Trim('[', ']').Trim()
|
||||
);
|
||||
|
||||
return tagSearchString;
|
||||
}
|
||||
|
||||
private static string replaceBools(string searchString)
|
||||
{
|
||||
foreach (var boolSearch in boolIndexRules.Keys)
|
||||
searchString =
|
||||
LuceneRegex.GetBoolRegex(boolSearch)
|
||||
.Replace(searchString, @"$1:True");
|
||||
|
||||
return searchString;
|
||||
}
|
||||
|
||||
private static string padNumbers(string searchString)
|
||||
{
|
||||
var matches = LuceneRegex
|
||||
.NumbersRegex()
|
||||
.Matches(searchString)
|
||||
.Cast<Match>()
|
||||
.OrderByDescending(m => m.Index);
|
||||
|
||||
foreach (var m in matches)
|
||||
{
|
||||
var replaceString = double.Parse(m.ToString()).ToLuceneString();
|
||||
searchString = LuceneRegex.NumbersRegex().Replace(searchString, replaceString, 1, m.Index);
|
||||
}
|
||||
|
||||
return searchString;
|
||||
}
|
||||
|
||||
private static string lowerFieldNames(string searchString)
|
||||
{
|
||||
// fields are case specific
|
||||
var allMatches = LuceneRegex
|
||||
.FieldRegex
|
||||
.Matches(searchString)
|
||||
.Cast<Match>()
|
||||
.Select(a => a.ToString())
|
||||
.ToList();
|
||||
|
||||
foreach (var match in allMatches)
|
||||
searchString = searchString.Replace(match, match.ToLowerInvariant());
|
||||
|
||||
return searchString;
|
||||
}
|
||||
#endregion
|
||||
|
||||
private SearchResultSet generalSearch(string searchString)
|
||||
private SearchResultSet generalSearch(string searchString, StandardAnalyzer analyzer)
|
||||
{
|
||||
var defaultField = ALL;
|
||||
|
||||
using var index = getIndex();
|
||||
using var searcher = new IndexSearcher(index);
|
||||
using var analyzer = new StandardAnalyzer(Version);
|
||||
var query = analyzer.GetQuery(defaultField, searchString);
|
||||
var query = analyzer.GetQuery(defaultField, searchString);
|
||||
|
||||
|
||||
// lucene doesn't allow only negations. eg this returns nothing:
|
||||
// -tags:hidden
|
||||
// work arounds: https://kb.ucla.edu/articles/pure-negation-query-in-lucene
|
||||
// HOWEVER, doing this to any other type of query can cause EVERYTHING to be a match unless "Occur" is carefully set
|
||||
// this should really check that all leaf nodes are MUST_NOT
|
||||
if (query is BooleanQuery boolQuery)
|
||||
// lucene doesn't allow only negations. eg this returns nothing:
|
||||
// -tags:hidden
|
||||
// work arounds: https://kb.ucla.edu/articles/pure-negation-query-in-lucene
|
||||
// HOWEVER, doing this to any other type of query can cause EVERYTHING to be a match unless "Occur" is carefully set
|
||||
// this should really check that all leaf nodes are MUST_NOT
|
||||
if (query is BooleanQuery boolQuery)
|
||||
{
|
||||
var occurs = getOccurs_recurs(boolQuery);
|
||||
if (occurs.Any() && occurs.All(o => o == Occur.MUST_NOT))
|
||||
@@ -492,24 +251,24 @@ namespace LibationSearchEngine
|
||||
return returnList;
|
||||
}
|
||||
|
||||
private void displayResults(SearchResultSet docs)
|
||||
{
|
||||
//for (int i = 0; i < docs.Docs.Count(); i++)
|
||||
//{
|
||||
// var sde = docs.Docs.First();
|
||||
private void displayResults(SearchResultSet docs)
|
||||
{
|
||||
//for (int i = 0; i < docs.Docs.Count(); i++)
|
||||
//{
|
||||
// var sde = docs.Docs.First();
|
||||
|
||||
// Document doc = sde.Doc;
|
||||
// float score = sde.Score;
|
||||
// Document doc = sde.Doc;
|
||||
// float score = sde.Score;
|
||||
|
||||
// Serilog.Log.Logger.Debug($"{(i + 1)}) score={score}. Fields:");
|
||||
// var allFields = doc.GetFields();
|
||||
// foreach (var f in allFields)
|
||||
// Serilog.Log.Logger.Debug($" [{f.Name}]={f.StringValue}");
|
||||
//}
|
||||
}
|
||||
#endregion
|
||||
// Serilog.Log.Logger.Debug($"{(i + 1)}) score={score}. Fields:");
|
||||
// var allFields = doc.GetFields();
|
||||
// foreach (var f in allFields)
|
||||
// Serilog.Log.Logger.Debug($" [{f.Name}]={f.StringValue}");
|
||||
//}
|
||||
}
|
||||
#endregion
|
||||
|
||||
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
|
||||
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
|
||||
|
||||
// not customizable. don't move to config
|
||||
private static string SearchEngineDirectory { get; }
|
||||
|
||||
17
Source/LibationUiBase/EnumDiaplay[T].cs
Normal file
17
Source/LibationUiBase/EnumDiaplay[T].cs
Normal file
@@ -0,0 +1,17 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public record EnumDiaplay<T> where T : Enum
|
||||
{
|
||||
public T Value { get; }
|
||||
public string Description { get; }
|
||||
public EnumDiaplay(T value, string description = null)
|
||||
{
|
||||
Value = value;
|
||||
Description = description ?? value.GetDescription() ?? value.ToString();
|
||||
}
|
||||
public override string ToString() => Description;
|
||||
}
|
||||
}
|
||||
@@ -88,7 +88,7 @@ namespace LibationUiBase.GridView
|
||||
var api = await LibraryBook.GetApiAsync();
|
||||
|
||||
if (await api.ReviewAsync(Book.AudibleProductId, (int)rating.OverallRating, (int)rating.PerformanceRating, (int)rating.StoryRating))
|
||||
LibraryBook.Book.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
|
||||
LibraryBook.UpdateUserDefinedItem(Book.UserDefinedItem.Tags, Book.UserDefinedItem.BookStatus, Book.UserDefinedItem.PdfStatus, rating);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -105,7 +105,7 @@ namespace LibationUiBase.GridView
|
||||
Liberate = TStatus.Create(libraryBook);
|
||||
Liberate.Expanded = expanded;
|
||||
|
||||
Title = Book.Title;
|
||||
Title = Book.TitleWithSubtitle;
|
||||
Series = Book.SeriesNames(includeIndex: true);
|
||||
SeriesOrder = new SeriesOrder(Book.SeriesLink);
|
||||
Length = GetBookLengthString();
|
||||
|
||||
@@ -1,12 +0,0 @@
|
||||
namespace LibationUiBase
|
||||
{
|
||||
public class SampleRateSelection
|
||||
{
|
||||
public AAXClean.SampleRate SampleRate { get; }
|
||||
public SampleRateSelection(AAXClean.SampleRate sampleRate)
|
||||
{
|
||||
SampleRate = sampleRate;
|
||||
}
|
||||
public override string ToString() => $"{(int)SampleRate} Hz";
|
||||
}
|
||||
}
|
||||
@@ -108,7 +108,7 @@ namespace LibationUiBase.SeriesView
|
||||
{
|
||||
Asin = seriesParent.AudibleProductId,
|
||||
Sequence = item.Relationships.FirstOrDefault(r => r.Asin == seriesParent.AudibleProductId)?.Sort?.ToString() ?? "0",
|
||||
Title = seriesParent.Title
|
||||
Title = seriesParent.TitleWithSubtitle
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
@@ -286,8 +286,8 @@
|
||||
//
|
||||
// AboutDialog
|
||||
//
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
ClientSize = new System.Drawing.Size(434, 491);
|
||||
Controls.Add(groupBox1);
|
||||
Controls.Add(getLibationLbl);
|
||||
|
||||
@@ -143,8 +143,8 @@
|
||||
// AccountsDialog
|
||||
//
|
||||
this.AcceptButton = this.saveBtn;
|
||||
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.CancelButton = this.cancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(933, 519);
|
||||
this.Controls.Add(this.dataGridView1);
|
||||
|
||||
@@ -202,8 +202,8 @@
|
||||
// BookDetailsDialog
|
||||
//
|
||||
this.AcceptButton = this.saveBtn;
|
||||
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.CancelButton = this.cancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(594, 466);
|
||||
this.Controls.Add(this.audibleLink);
|
||||
|
||||
@@ -38,13 +38,14 @@ namespace LibationWinForms.Dialogs
|
||||
// 1st draft: lazily cribbed from GridEntry.ctor()
|
||||
private void initDetails()
|
||||
{
|
||||
this.Text = Book.Title;
|
||||
this.Text = Book.TitleWithSubtitle;
|
||||
|
||||
(_, var picture) = PictureStorage.GetPicture(new PictureDefinition(Book.PictureId, PictureSize._80x80));
|
||||
this.coverPb.Image = WinFormsUtil.TryLoadImageOrDefault(picture, PictureSize._80x80);
|
||||
|
||||
var title = string.IsNullOrEmpty(Book.Subtitle) ? Book.Title : $"{Book.Title}\r\n {Book.Subtitle}";
|
||||
var t = @$"
|
||||
Title: {Book.Title}
|
||||
Title: {title}
|
||||
Author(s): {Book.AuthorNames()}
|
||||
Narrator(s): {Book.NarratorNames()}
|
||||
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
|
||||
|
||||
@@ -201,8 +201,8 @@
|
||||
//
|
||||
// BookRecordsDialog
|
||||
//
|
||||
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(491, 361);
|
||||
this.Controls.Add(this.reloadAllBtn);
|
||||
this.Controls.Add(this.exportCheckedBtn);
|
||||
|
||||
@@ -45,7 +45,7 @@ namespace LibationWinForms.Dialogs
|
||||
{
|
||||
this.libraryBook = libraryBook;
|
||||
|
||||
Text = $"{libraryBook.Book.Title} - Clips and Bookmarks";
|
||||
Text = $"{libraryBook.Book.TitleWithSubtitle} - Clips and Bookmarks";
|
||||
}
|
||||
|
||||
private async void BookRecordsDialog_Shown(object sender, EventArgs e)
|
||||
@@ -182,7 +182,7 @@ namespace LibationWinForms.Dialogs
|
||||
{
|
||||
Title = "Where to export records",
|
||||
AddExtension = true,
|
||||
FileName = $"{libraryBook.Book.Title} - Records",
|
||||
FileName = $"{libraryBook.Book.TitleWithSubtitle} - Records",
|
||||
DefaultExt = "xlsx",
|
||||
Filter = "Excel Workbook (*.xlsx)|*.xlsx|CSV files (*.csv)|*.csv|JSON files (*.json)|*.json" // + "|All files (*.*)|*.*"
|
||||
});
|
||||
|
||||
@@ -29,77 +29,81 @@ namespace LibationWinForms.Dialogs
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.knownDirectoryRb = new System.Windows.Forms.RadioButton();
|
||||
this.customDirectoryRb = new System.Windows.Forms.RadioButton();
|
||||
this.customTb = new System.Windows.Forms.TextBox();
|
||||
this.customBtn = new System.Windows.Forms.Button();
|
||||
this.directorySelectControl = new LibationWinForms.Dialogs.DirectorySelectControl();
|
||||
this.SuspendLayout();
|
||||
knownDirectoryRb = new System.Windows.Forms.RadioButton();
|
||||
customDirectoryRb = new System.Windows.Forms.RadioButton();
|
||||
customTb = new System.Windows.Forms.TextBox();
|
||||
customBtn = new System.Windows.Forms.Button();
|
||||
directorySelectControl = new DirectorySelectControl();
|
||||
SuspendLayout();
|
||||
//
|
||||
// knownDirectoryRb
|
||||
//
|
||||
this.knownDirectoryRb.AutoSize = true;
|
||||
this.knownDirectoryRb.Location = new System.Drawing.Point(3, 3);
|
||||
this.knownDirectoryRb.Name = "knownDirectoryRb";
|
||||
this.knownDirectoryRb.Size = new System.Drawing.Size(14, 13);
|
||||
this.knownDirectoryRb.TabIndex = 0;
|
||||
this.knownDirectoryRb.UseVisualStyleBackColor = true;
|
||||
this.knownDirectoryRb.CheckedChanged += new System.EventHandler(this.radioButton_CheckedChanged);
|
||||
knownDirectoryRb.AutoSize = true;
|
||||
knownDirectoryRb.Location = new System.Drawing.Point(6, 6);
|
||||
knownDirectoryRb.Margin = new System.Windows.Forms.Padding(6);
|
||||
knownDirectoryRb.Name = "knownDirectoryRb";
|
||||
knownDirectoryRb.Size = new System.Drawing.Size(27, 26);
|
||||
knownDirectoryRb.TabIndex = 0;
|
||||
knownDirectoryRb.UseVisualStyleBackColor = true;
|
||||
knownDirectoryRb.CheckedChanged += radioButton_CheckedChanged;
|
||||
//
|
||||
// customDirectoryRb
|
||||
//
|
||||
this.customDirectoryRb.AutoSize = true;
|
||||
this.customDirectoryRb.Location = new System.Drawing.Point(2, 62);
|
||||
this.customDirectoryRb.Name = "customDirectoryRb";
|
||||
this.customDirectoryRb.Size = new System.Drawing.Size(14, 13);
|
||||
this.customDirectoryRb.TabIndex = 2;
|
||||
this.customDirectoryRb.UseVisualStyleBackColor = true;
|
||||
this.customDirectoryRb.CheckedChanged += new System.EventHandler(this.radioButton_CheckedChanged);
|
||||
customDirectoryRb.AutoSize = true;
|
||||
customDirectoryRb.Location = new System.Drawing.Point(4, 124);
|
||||
customDirectoryRb.Margin = new System.Windows.Forms.Padding(6);
|
||||
customDirectoryRb.Name = "customDirectoryRb";
|
||||
customDirectoryRb.Size = new System.Drawing.Size(27, 26);
|
||||
customDirectoryRb.TabIndex = 2;
|
||||
customDirectoryRb.UseVisualStyleBackColor = true;
|
||||
customDirectoryRb.CheckedChanged += radioButton_CheckedChanged;
|
||||
//
|
||||
// customTb
|
||||
//
|
||||
this.customTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.customTb.Location = new System.Drawing.Point(22, 58);
|
||||
this.customTb.Name = "customTb";
|
||||
this.customTb.Size = new System.Drawing.Size(588, 23);
|
||||
this.customTb.TabIndex = 3;
|
||||
customTb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||
customTb.Location = new System.Drawing.Point(44, 116);
|
||||
customTb.Margin = new System.Windows.Forms.Padding(6);
|
||||
customTb.Name = "customTb";
|
||||
customTb.Size = new System.Drawing.Size(1172, 39);
|
||||
customTb.TabIndex = 3;
|
||||
//
|
||||
// customBtn
|
||||
//
|
||||
this.customBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.customBtn.Location = new System.Drawing.Point(616, 58);
|
||||
this.customBtn.Name = "customBtn";
|
||||
this.customBtn.Size = new System.Drawing.Size(41, 27);
|
||||
this.customBtn.TabIndex = 4;
|
||||
this.customBtn.Text = "...";
|
||||
this.customBtn.UseVisualStyleBackColor = true;
|
||||
this.customBtn.Click += new System.EventHandler(this.customBtn_Click);
|
||||
customBtn.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
|
||||
customBtn.Location = new System.Drawing.Point(1232, 116);
|
||||
customBtn.Margin = new System.Windows.Forms.Padding(6);
|
||||
customBtn.Name = "customBtn";
|
||||
customBtn.Size = new System.Drawing.Size(82, 54);
|
||||
customBtn.TabIndex = 4;
|
||||
customBtn.Text = "...";
|
||||
customBtn.UseVisualStyleBackColor = true;
|
||||
customBtn.Click += customBtn_Click;
|
||||
//
|
||||
// directorySelectControl
|
||||
//
|
||||
this.directorySelectControl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.directorySelectControl.Location = new System.Drawing.Point(23, 0);
|
||||
this.directorySelectControl.Name = "directorySelectControl";
|
||||
this.directorySelectControl.Size = new System.Drawing.Size(635, 52);
|
||||
this.directorySelectControl.TabIndex = 5;
|
||||
directorySelectControl.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||
directorySelectControl.AutoSize = true;
|
||||
directorySelectControl.Location = new System.Drawing.Point(46, 0);
|
||||
directorySelectControl.Margin = new System.Windows.Forms.Padding(12);
|
||||
directorySelectControl.Name = "directorySelectControl";
|
||||
directorySelectControl.Size = new System.Drawing.Size(1270, 104);
|
||||
directorySelectControl.TabIndex = 5;
|
||||
//
|
||||
// DirectoryOrCustomSelectControl
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.Controls.Add(this.directorySelectControl);
|
||||
this.Controls.Add(this.customBtn);
|
||||
this.Controls.Add(this.customTb);
|
||||
this.Controls.Add(this.customDirectoryRb);
|
||||
this.Controls.Add(this.knownDirectoryRb);
|
||||
this.Name = "DirectoryOrCustomSelectControl";
|
||||
this.Size = new System.Drawing.Size(660, 87);
|
||||
this.Load += new System.EventHandler(this.DirectoryOrCustomSelectControl_Load);
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(192F, 192F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
Controls.Add(directorySelectControl);
|
||||
Controls.Add(customBtn);
|
||||
Controls.Add(customTb);
|
||||
Controls.Add(customDirectoryRb);
|
||||
Controls.Add(knownDirectoryRb);
|
||||
Margin = new System.Windows.Forms.Padding(6);
|
||||
Name = "DirectoryOrCustomSelectControl";
|
||||
Size = new System.Drawing.Size(1320, 176);
|
||||
Load += DirectoryOrCustomSelectControl_Load;
|
||||
ResumeLayout(false);
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,5 +1,64 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing"">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
@@ -29,44 +29,45 @@ namespace LibationWinForms.Dialogs
|
||||
/// </summary>
|
||||
private void InitializeComponent()
|
||||
{
|
||||
this.directoryComboBox = new System.Windows.Forms.ComboBox();
|
||||
this.textBox1 = new System.Windows.Forms.TextBox();
|
||||
this.SuspendLayout();
|
||||
directoryComboBox = new System.Windows.Forms.ComboBox();
|
||||
textBox1 = new System.Windows.Forms.TextBox();
|
||||
SuspendLayout();
|
||||
//
|
||||
// directoryComboBox
|
||||
//
|
||||
this.directoryComboBox.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.directoryComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
this.directoryComboBox.FormattingEnabled = true;
|
||||
this.directoryComboBox.Location = new System.Drawing.Point(0, 0);
|
||||
this.directoryComboBox.Name = "directoryComboBox";
|
||||
this.directoryComboBox.Size = new System.Drawing.Size(407, 23);
|
||||
this.directoryComboBox.TabIndex = 0;
|
||||
this.directoryComboBox.SelectedIndexChanged += new System.EventHandler(this.directoryComboBox_SelectedIndexChanged);
|
||||
directoryComboBox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||
directoryComboBox.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
|
||||
directoryComboBox.FormattingEnabled = true;
|
||||
directoryComboBox.Location = new System.Drawing.Point(0, 0);
|
||||
directoryComboBox.Margin = new System.Windows.Forms.Padding(6, 6, 6, 6);
|
||||
directoryComboBox.Name = "directoryComboBox";
|
||||
directoryComboBox.Size = new System.Drawing.Size(810, 40);
|
||||
directoryComboBox.TabIndex = 0;
|
||||
directoryComboBox.SelectedIndexChanged += directoryComboBox_SelectedIndexChanged;
|
||||
//
|
||||
// textBox1
|
||||
//
|
||||
this.textBox1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.textBox1.Location = new System.Drawing.Point(0, 29);
|
||||
this.textBox1.Name = "textBox1";
|
||||
this.textBox1.ReadOnly = true;
|
||||
this.textBox1.Size = new System.Drawing.Size(407, 23);
|
||||
this.textBox1.TabIndex = 1;
|
||||
textBox1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
|
||||
textBox1.Location = new System.Drawing.Point(0, 58);
|
||||
textBox1.Margin = new System.Windows.Forms.Padding(6, 6, 6, 6);
|
||||
textBox1.Name = "textBox1";
|
||||
textBox1.ReadOnly = true;
|
||||
textBox1.Size = new System.Drawing.Size(810, 39);
|
||||
textBox1.TabIndex = 1;
|
||||
//
|
||||
// DirectorySelectControl
|
||||
//
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.Controls.Add(this.textBox1);
|
||||
this.Controls.Add(this.directoryComboBox);
|
||||
this.Name = "DirectorySelectControl";
|
||||
this.Size = new System.Drawing.Size(407, 52);
|
||||
this.Load += new System.EventHandler(this.DirectorySelectControl_Load);
|
||||
this.ResumeLayout(false);
|
||||
this.PerformLayout();
|
||||
|
||||
AutoScaleDimensions = new System.Drawing.SizeF(192F, 192F);
|
||||
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
AutoSize = true;
|
||||
Controls.Add(textBox1);
|
||||
Controls.Add(directoryComboBox);
|
||||
Margin = new System.Windows.Forms.Padding(6, 6, 6, 6);
|
||||
Name = "DirectorySelectControl";
|
||||
Size = new System.Drawing.Size(814, 104);
|
||||
Load += DirectorySelectControl_Load;
|
||||
ResumeLayout(false);
|
||||
PerformLayout();
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
@@ -1,4 +1,64 @@
|
||||
<root>
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<root>
|
||||
<!--
|
||||
Microsoft ResX Schema
|
||||
|
||||
Version 2.0
|
||||
|
||||
The primary goals of this format is to allow a simple XML format
|
||||
that is mostly human readable. The generation and parsing of the
|
||||
various data types are done through the TypeConverter classes
|
||||
associated with the data types.
|
||||
|
||||
Example:
|
||||
|
||||
... ado.net/XML headers & schema ...
|
||||
<resheader name="resmimetype">text/microsoft-resx</resheader>
|
||||
<resheader name="version">2.0</resheader>
|
||||
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
|
||||
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
|
||||
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
|
||||
<data name="Color1" type="System.Drawing.Color, System.Drawing"">Blue</data>
|
||||
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
|
||||
<value>[base64 mime encoded serialized .NET Framework object]</value>
|
||||
</data>
|
||||
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
|
||||
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
|
||||
<comment>This is a comment</comment>
|
||||
</data>
|
||||
|
||||
There are any number of "resheader" rows that contain simple
|
||||
name/value pairs.
|
||||
|
||||
Each data row contains a name, and value. The row also contains a
|
||||
type or mimetype. Type corresponds to a .NET class that support
|
||||
text/value conversion through the TypeConverter architecture.
|
||||
Classes that don't support this are serialized and stored with the
|
||||
mimetype set.
|
||||
|
||||
The mimetype is used for serialized objects, and tells the
|
||||
ResXResourceReader how to depersist the object. This is currently not
|
||||
extensible. For a given mimetype the value must be set accordingly:
|
||||
|
||||
Note - application/x-microsoft.net.object.binary.base64 is the format
|
||||
that the ResXResourceWriter will generate, however the reader can
|
||||
read any of the formats listed below.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.binary.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.soap.base64
|
||||
value : The object must be serialized with
|
||||
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
|
||||
: and then encoded with base64 encoding.
|
||||
|
||||
mimetype: application/x-microsoft.net.object.bytearray.base64
|
||||
value : The object must be serialized into a byte array
|
||||
: using a System.ComponentModel.TypeConverter
|
||||
: and then encoded with base64 encoding.
|
||||
-->
|
||||
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
|
||||
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
|
||||
<xsd:element name="root" msdata:IsDataSet="true">
|
||||
|
||||
@@ -126,8 +126,8 @@
|
||||
// EditQuickFilters
|
||||
//
|
||||
this.AcceptButton = this.saveBtn;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
this.CancelButton = this.cancelBtn;
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
this.Controls.Add(this.dataGridView1);
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user