mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 10:28:21 -05:00
Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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 |
@@ -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)
|
||||
|
||||
|
||||
@@ -32,22 +32,23 @@ These tags will be replaced in the template with the audiobook's values.
|
||||
|\<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
|
||||
|
||||
@@ -77,7 +78,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|
|
||||
@@ -88,15 +89,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.0.4" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -47,7 +47,7 @@ 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("©", "©");
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>10.3.5.1</Version>
|
||||
<Version>10.4.2.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="6.0.0" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
@@ -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
|
||||
{
|
||||
@@ -343,8 +364,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 +380,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 +401,7 @@ namespace FileLiberator
|
||||
|
||||
private static void downloadCoverArt(LibraryBook libraryBook)
|
||||
{
|
||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||
if (!Configuration.Instance.DownloadCoverArt) return;
|
||||
|
||||
var coverPath = "[null]";
|
||||
|
||||
@@ -385,7 +416,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)
|
||||
{
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -20,34 +21,44 @@ namespace FileLiberator
|
||||
|
||||
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 ?? "",
|
||||
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,
|
||||
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
|
||||
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
|
||||
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;
|
||||
}
|
||||
|
||||
@@ -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,11 +1,10 @@
|
||||
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
|
||||
{
|
||||
protected override Type StyleKeyOverride => typeof(ComboBox);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -390,7 +390,7 @@ $@" 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}");
|
||||
|
||||
|
||||
@@ -223,7 +223,7 @@ 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(@$"
|
||||
|
||||
@@ -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(
|
||||
@@ -71,7 +63,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;
|
||||
}
|
||||
|
||||
@@ -98,7 +90,7 @@ 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.ClipBookmarkFormat> ClipBookmarkFormats { get; } = new(Enum<Configuration.ClipBookmarkFormat>.GetValues());
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -18,11 +18,11 @@ namespace LibationCli
|
||||
{
|
||||
Environment.ExitCode = (int)ExitCode.RunTimeError;
|
||||
|
||||
Console.WriteLine("ERROR");
|
||||
Console.WriteLine("=====");
|
||||
Console.WriteLine(ex.Message);
|
||||
Console.WriteLine();
|
||||
Console.WriteLine(ex.StackTrace);
|
||||
Console.Error.WriteLine("ERROR");
|
||||
Console.Error.WriteLine("=====");
|
||||
Console.Error.WriteLine(ex.Message);
|
||||
Console.Error.WriteLine();
|
||||
Console.Error.WriteLine(ex.StackTrace);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -27,6 +27,7 @@ namespace LibationCli
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Console.Error.WriteLine("CLI error. See log for more details.");
|
||||
Serilog.Log.Logger.Error(ex, "CLI error");
|
||||
}
|
||||
};
|
||||
@@ -54,12 +55,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() });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +70,7 @@ namespace LibationCli
|
||||
|
||||
if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError)))
|
||||
{
|
||||
Console.WriteLine("No verb selected");
|
||||
Console.Error.WriteLine("No verb selected");
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -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,23 @@ 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
|
||||
}
|
||||
|
||||
[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); }
|
||||
|
||||
|
||||
@@ -20,7 +20,7 @@ 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; }
|
||||
@@ -37,5 +37,6 @@ namespace LibationFileManager
|
||||
{
|
||||
public DateTime? DateAdded { get; set; }
|
||||
public string Account { get; set; }
|
||||
public string AccountNickname { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,7 +52,8 @@ 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",
|
||||
|
||||
@@ -37,6 +37,7 @@ namespace LibationFileManager
|
||||
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");
|
||||
|
||||
@@ -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,17 +187,63 @@ 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 },
|
||||
@@ -215,6 +262,7 @@ namespace LibationFileManager
|
||||
{ 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 },
|
||||
@@ -278,10 +326,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)
|
||||
|
||||
@@ -1,81 +0,0 @@
|
||||
using Lucene.Net.Analysis.Tokenattributes;
|
||||
using Lucene.Net.Analysis;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationSearchEngine
|
||||
{
|
||||
internal class AsinAnalyzer : Analyzer
|
||||
{
|
||||
public override TokenStream TokenStream(string fieldName, System.IO.TextReader reader)
|
||||
{
|
||||
return new AsinFilter(reader);
|
||||
}
|
||||
/// <summary>
|
||||
/// Emits the entire input as a single token and removes
|
||||
/// trailing .00 from strings that parsed to numbers
|
||||
///
|
||||
/// Based on Lucene.Net.Analysis.KeywordTokenizer
|
||||
/// </summary>
|
||||
private class AsinFilter : Tokenizer
|
||||
{
|
||||
private bool done;
|
||||
private int finalOffset;
|
||||
private readonly ITermAttribute termAtt;
|
||||
private readonly IOffsetAttribute offsetAtt;
|
||||
private const int DEFAULT_BUFFER_SIZE = 256;
|
||||
|
||||
public AsinFilter(System.IO.TextReader input) : base(input)
|
||||
{
|
||||
offsetAtt = AddAttribute<IOffsetAttribute>();
|
||||
termAtt = AddAttribute<ITermAttribute>();
|
||||
termAtt.ResizeTermBuffer(DEFAULT_BUFFER_SIZE);
|
||||
}
|
||||
public override bool IncrementToken()
|
||||
{
|
||||
var charReader = input as CharReader;
|
||||
if (!done)
|
||||
{
|
||||
ClearAttributes();
|
||||
done = true;
|
||||
int upto = 0;
|
||||
char[] buffer = termAtt.TermBuffer();
|
||||
|
||||
while (true)
|
||||
{
|
||||
int length = charReader.Read(buffer, upto, buffer.Length - upto);
|
||||
if (length == 0)
|
||||
break;
|
||||
upto += length;
|
||||
if (upto == buffer.Length)
|
||||
buffer = termAtt.ResizeTermBuffer(1 + buffer.Length);
|
||||
}
|
||||
|
||||
var termStr = new string(buffer, 0, upto);
|
||||
if (termStr.EndsWith(".00"))
|
||||
upto -= 3;
|
||||
|
||||
termAtt.SetTermLength(upto);
|
||||
finalOffset = CorrectOffset(upto);
|
||||
offsetAtt.SetOffset(CorrectOffset(0), finalOffset);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
public override void End()
|
||||
{
|
||||
// set final offset
|
||||
offsetAtt.SetOffset(finalOffset, finalOffset);
|
||||
}
|
||||
|
||||
public override void Reset(System.IO.TextReader input)
|
||||
{
|
||||
base.Reset(input);
|
||||
this.done = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
151
Source/LibationSearchEngine/QuerySanitizer.cs
Normal file
151
Source/LibationSearchEngine/QuerySanitizer.cs
Normal file
@@ -0,0 +1,151 @@
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Lucene.Net.Analysis.Tokenattributes;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationSearchEngine
|
||||
{
|
||||
internal static 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();
|
||||
|
||||
internal static string Sanitize(string searchString, StandardAnalyzer analyzer)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(searchString))
|
||||
return SearchEngine.ALL_QUERY;
|
||||
|
||||
// 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 (tryParseBlockTag(offset, partList, searchString, out var tagName))
|
||||
{
|
||||
//The term is a block tag. add it to the part list
|
||||
partList.Add($"{SearchEngine.TAGS}:{tagName}");
|
||||
}
|
||||
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));
|
||||
}
|
||||
|
||||
private static bool tryParseBlockTag(IOffsetAttribute offset, List<string> partList, string searchString, out string tagName)
|
||||
{
|
||||
tagName = null;
|
||||
if (partList.Count == 0) return false;
|
||||
|
||||
var previous = partList[^1].TrimEnd();
|
||||
|
||||
//cannot be preceeded by an escaping \
|
||||
if (previous.Length == 0) return false;
|
||||
if (previous[^1] != '[' || (previous.Length > 1 && previous[^2] == '\\')) return false;
|
||||
|
||||
var next = searchString.Substring(offset.EndOffset);
|
||||
if (next.Length == 0 || !next.TrimStart().StartsWith(']')) return false;
|
||||
|
||||
tagName = searchString.Substring(offset.StartOffset, offset.EndOffset - offset.StartOffset);
|
||||
|
||||
//Only legal tag characters are letters, numbers and underscores
|
||||
//Per DataLayer.UserDefinedItem.IllegalCharacterRegex()
|
||||
foreach (var c in tagName)
|
||||
{
|
||||
if (!char.IsLetterOrDigit(c) && c != '_')
|
||||
return false;
|
||||
}
|
||||
|
||||
//Remove the leading '['
|
||||
partList[^1] = previous[..^1];
|
||||
//Ignore the trailing ']'
|
||||
offset.SetOffset(offset.StartOffset, searchString.IndexOf(']', offset.EndOffset) + 1);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,9 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using Lucene.Net.Analysis;
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Lucene.Net.Documents;
|
||||
using Lucene.Net.Index;
|
||||
@@ -25,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.Title, nameof(Book.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
|
||||
@@ -224,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;
|
||||
}
|
||||
@@ -267,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)
|
||||
{
|
||||
@@ -335,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);
|
||||
@@ -353,112 +192,27 @@ 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);
|
||||
using var asinAnalyzer = new AsinAnalyzer();
|
||||
|
||||
var dic = idIndexRules.Keys.Select(k => new KeyValuePair<string, Analyzer>(k.ToLowerInvariant(), asinAnalyzer));
|
||||
using var perFieldAnalyzer = new PerFieldAnalyzerWrapper(analyzer, dic);
|
||||
|
||||
var query = perFieldAnalyzer.GetQuery(defaultField, searchString);
|
||||
var query = analyzer.GetQuery(defaultField, searchString);
|
||||
|
||||
// lucene doesn't allow only negations. eg this returns nothing:
|
||||
// -tags:hidden
|
||||
@@ -497,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
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
using System;
|
||||
using LibationSearchEngine;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Windows.Forms;
|
||||
|
||||
@@ -10,10 +11,10 @@ namespace LibationWinForms.Dialogs
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
label2.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchStringFields());
|
||||
label3.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchNumberFields());
|
||||
label4.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchBoolFields());
|
||||
label5.Text += "\r\n\r\n" + string.Join("\r\n", LibationSearchEngine.SearchEngine.GetSearchIdFields());
|
||||
label2.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.StringFieldNames);
|
||||
label3.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.NumberFieldNames);
|
||||
label4.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.BoolFieldNames);
|
||||
label5.Text += "\r\n\r\n" + string.Join("\r\n", SearchEngine.FieldIndexRules.IdFieldNames);
|
||||
this.SetLibationIcon();
|
||||
}
|
||||
|
||||
|
||||
@@ -28,15 +28,9 @@ namespace LibationWinForms.Dialogs
|
||||
});
|
||||
|
||||
maxSampleRateCb.Items.AddRange(
|
||||
new object[]
|
||||
{
|
||||
new SampleRateSelection(AAXClean.SampleRate.Hz_44100),
|
||||
new SampleRateSelection(AAXClean.SampleRate.Hz_32000),
|
||||
new SampleRateSelection(AAXClean.SampleRate.Hz_24000),
|
||||
new SampleRateSelection(AAXClean.SampleRate.Hz_22050),
|
||||
new SampleRateSelection(AAXClean.SampleRate.Hz_16000),
|
||||
new SampleRateSelection(AAXClean.SampleRate.Hz_12000)
|
||||
});
|
||||
Enum.GetValues<AAXClean.SampleRate>()
|
||||
.Select(v => new EnumDiaplay<AAXClean.SampleRate>(v, $"{(int)v} Hz"))
|
||||
.ToArray());
|
||||
|
||||
encoderQualityCb.Items.AddRange(
|
||||
new object[]
|
||||
@@ -62,7 +56,13 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
lameTargetBitrateRb.Checked = config.LameTargetBitrate;
|
||||
lameTargetQualityRb.Checked = !config.LameTargetBitrate;
|
||||
maxSampleRateCb.SelectedItem = maxSampleRateCb.Items.Cast<SampleRateSelection>().Single(s => s.SampleRate == config.MaxSampleRate);
|
||||
|
||||
maxSampleRateCb.SelectedItem
|
||||
= maxSampleRateCb.Items
|
||||
.Cast<EnumDiaplay<AAXClean.SampleRate>>()
|
||||
.SingleOrDefault(v => v.Value == config.MaxSampleRate)
|
||||
?? maxSampleRateCb.Items[0];
|
||||
|
||||
encoderQualityCb.SelectedItem = config.LameEncoderQuality;
|
||||
lameDownsampleMonoCbox.Checked = config.LameDownsampleMono;
|
||||
lameBitrateTb.Value = config.LameBitrate;
|
||||
@@ -95,9 +95,8 @@ namespace LibationWinForms.Dialogs
|
||||
config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked;
|
||||
config.DecryptToLossy = convertLossyRb.Checked;
|
||||
config.MoveMoovToBeginning = moveMoovAtomCbox.Checked;
|
||||
|
||||
config.LameTargetBitrate = lameTargetBitrateRb.Checked;
|
||||
config.MaxSampleRate = ((SampleRateSelection)maxSampleRateCb.SelectedItem).SampleRate;
|
||||
config.MaxSampleRate = ((EnumDiaplay<AAXClean.SampleRate>)maxSampleRateCb.SelectedItem).Value;
|
||||
config.LameEncoderQuality = (NAudio.Lame.EncoderQuality)encoderQualityCb.SelectedItem;
|
||||
encoderQualityCb.SelectedItem = config.LameEncoderQuality;
|
||||
config.LameDownsampleMono = lameDownsampleMonoCbox.Checked;
|
||||
|
||||
2066
Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs
generated
2066
Source/LibationWinForms/Dialogs/SettingsDialog.Designer.cs
generated
File diff suppressed because it is too large
Load Diff
@@ -27,13 +27,13 @@ namespace LibationWinForms.Dialogs
|
||||
editCharreplacementBtn.Text = desc(nameof(config.ReplacementCharacters));
|
||||
|
||||
badBookGb.Text = desc(nameof(config.BadBook));
|
||||
badBookAskRb.Text = Configuration.BadBookAction.Ask.GetDescription();
|
||||
badBookAskRb.Text = Configuration.BadBookAction.Ask.GetDescription();
|
||||
badBookAbortRb.Text = Configuration.BadBookAction.Abort.GetDescription();
|
||||
badBookRetryRb.Text = Configuration.BadBookAction.Retry.GetDescription();
|
||||
badBookIgnoreRb.Text = Configuration.BadBookAction.Ignore.GetDescription();
|
||||
useCoverAsFolderIconCb.Text = desc(nameof(config.UseCoverAsFolderIcon));
|
||||
useCoverAsFolderIconCb.Text = desc(nameof(config.UseCoverAsFolderIcon));
|
||||
|
||||
inProgressSelectControl.SetDirectoryItems(new()
|
||||
inProgressSelectControl.SetDirectoryItems(new()
|
||||
{
|
||||
Configuration.KnownDirectories.WinTemp,
|
||||
Configuration.KnownDirectories.UserProfile,
|
||||
@@ -60,7 +60,7 @@ namespace LibationWinForms.Dialogs
|
||||
fileTemplateTb.Text = config.FileTemplate;
|
||||
chapterFileTemplateTb.Text = config.ChapterFileTemplate;
|
||||
useCoverAsFolderIconCb.Checked = config.UseCoverAsFolderIcon;
|
||||
}
|
||||
}
|
||||
|
||||
private void Save_DownloadDecrypt(Configuration config)
|
||||
{
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
@@ -13,6 +14,15 @@ namespace LibationWinForms.Dialogs
|
||||
this.importEpisodesCb.Text = desc(nameof(config.ImportEpisodes));
|
||||
this.downloadEpisodesCb.Text = desc(nameof(config.DownloadEpisodes));
|
||||
this.autoDownloadEpisodesCb.Text = desc(nameof(config.AutoDownloadEpisodes));
|
||||
creationTimeLbl.Text = desc(nameof(config.CreationTime));
|
||||
lastWriteTimeLbl.Text = desc(nameof(config.LastWriteTime));
|
||||
|
||||
var dateTimeSources = Enum.GetValues<Configuration.DateTimeSource>().Select(v => new EnumDiaplay<Configuration.DateTimeSource>(v)).ToArray();
|
||||
creationTimeCb.Items.AddRange(dateTimeSources);
|
||||
lastWriteTimeCb.Items.AddRange(dateTimeSources);
|
||||
|
||||
creationTimeCb.SelectedItem = dateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? dateTimeSources[0];
|
||||
lastWriteTimeCb.SelectedItem = dateTimeSources.SingleOrDefault(v => v.Value == config.LastWriteTime) ?? dateTimeSources[0];
|
||||
|
||||
autoScanCb.Checked = config.AutoScan;
|
||||
showImportedStatsCb.Checked = config.ShowImportedStats;
|
||||
@@ -22,6 +32,9 @@ namespace LibationWinForms.Dialogs
|
||||
}
|
||||
private void Save_ImportLibrary(Configuration config)
|
||||
{
|
||||
config.CreationTime = ((EnumDiaplay<Configuration.DateTimeSource>)creationTimeCb.SelectedItem).Value;
|
||||
config.LastWriteTime = ((EnumDiaplay<Configuration.DateTimeSource>)lastWriteTimeCb.SelectedItem).Value;
|
||||
|
||||
config.AutoScan = autoScanCb.Checked;
|
||||
config.ShowImportedStats = showImportedStatsCb.Checked;
|
||||
config.ImportEpisodes = importEpisodesCb.Checked;
|
||||
|
||||
@@ -23,7 +23,8 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
booksLocationDescLbl.Text = desc(nameof(config.Books));
|
||||
betaOptInCbox.Text = desc(nameof(config.BetaOptIn));
|
||||
this.saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder));
|
||||
saveEpisodesToSeriesFolderCbox.Text = desc(nameof(config.SavePodcastsToParentFolder));
|
||||
overwriteExistingCbox.Text = desc(nameof(config.OverwriteExisting));
|
||||
|
||||
booksSelectControl.SetSearchTitle("books location");
|
||||
booksSelectControl.SetDirectoryItems(
|
||||
@@ -38,6 +39,8 @@ namespace LibationWinForms.Dialogs
|
||||
booksSelectControl.SelectDirectory(config.Books.PathWithoutPrefix);
|
||||
|
||||
saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder;
|
||||
overwriteExistingCbox.Checked = config.OverwriteExisting;
|
||||
|
||||
betaOptInCbox.Checked = config.BetaOptIn;
|
||||
|
||||
if (!betaOptInCbox.Checked)
|
||||
@@ -76,6 +79,7 @@ namespace LibationWinForms.Dialogs
|
||||
}
|
||||
|
||||
config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked;
|
||||
config.OverwriteExisting = overwriteExistingCbox.Checked;
|
||||
|
||||
config.BetaOptIn = betaOptInCbox.Checked;
|
||||
}
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -93,7 +93,7 @@ namespace LibationWinForms.GridView
|
||||
{
|
||||
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
|
||||
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
|
||||
liveGridEntry.Book.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
|
||||
liveGridEntry.LibraryBook.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
|
||||
}
|
||||
|
||||
#endregion
|
||||
@@ -131,7 +131,7 @@ namespace LibationWinForms.GridView
|
||||
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
|
||||
@@ -147,7 +147,7 @@ namespace LibationWinForms.GridView
|
||||
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
|
||||
|
||||
@@ -377,7 +377,7 @@ $@" 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}");
|
||||
|
||||
|
||||
@@ -184,7 +184,7 @@ namespace LibationWinForms.ProcessQueue
|
||||
else if (result == ProcessBookResult.FailedAbort)
|
||||
Queue.ClearQueue();
|
||||
else if (result == ProcessBookResult.FailedSkip)
|
||||
nextBook.LibraryBook.Book.UpdateBookStatus(DataLayer.LiberatedStatus.Error);
|
||||
nextBook.LibraryBook.UpdateBookStatus(DataLayer.LiberatedStatus.Error);
|
||||
else if (result == ProcessBookResult.LicenseDeniedPossibleOutage && !shownServiceOutageMessage)
|
||||
{
|
||||
MessageBox.Show(@$"
|
||||
|
||||
@@ -26,7 +26,8 @@ namespace TemplatesTests
|
||||
public static LibraryBookDto GetLibraryBook(string seriesName = "Sherlock Holmes")
|
||||
=> 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),
|
||||
FileDate = new DateTime(2023, 1, 28, 0, 0, 0),
|
||||
@@ -106,6 +107,42 @@ namespace TemplatesTests
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<samplerate>", "", "", "100")]
|
||||
[DataRow(" <samplerate> ", "", "", "100")]
|
||||
[DataRow("4<samplerate>4", "", "", "100")]
|
||||
[DataRow("<bitrate> - <bitrate>", "", "", "1 8 - 1 8")]
|
||||
[DataRow("<bitrate> 42 <bitrate>", "", "", "1 8 1 8")]
|
||||
[DataRow(" <bitrate> - <bitrate> ", "", "", "1 8 - 1 8")]
|
||||
[DataRow("4<bitrate> - <bitrate> 4", "", "", "1 8 - 1 8")]
|
||||
[DataRow("4<bitrate> - <bitrate> 4", "", "", "1 8 - 1 8")]
|
||||
[DataRow("<channels><channels><samplerate><channels><channels>", "", "", "100")]
|
||||
[DataRow(" <channels> <channels> <samplerate> <channels> <channels>", "", "", "100")]
|
||||
[DataRow(" <channels> - <channels> <samplerate> <channels> - <channels>", "", "", "- 100 -")]
|
||||
|
||||
public void Tests_removeSpaces(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
{
|
||||
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/');
|
||||
expected = expected.Replace("C:", "").Replace('\\', '/');
|
||||
}
|
||||
var replacements
|
||||
= new ReplacementCharacters
|
||||
{
|
||||
Replacements = Replacements.Replacements
|
||||
.Append(new Replacement('4', " ", ""))
|
||||
.Append(new Replacement('2', " ", ""))
|
||||
.ToArray() };
|
||||
|
||||
Templates.TryGetTemplate<Templates.FileTemplate>(template, out var fileTemplate).Should().BeTrue();
|
||||
|
||||
fileTemplate
|
||||
.GetFilename(GetLibraryBook(), dirFullPath, extension, replacements)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<bitrate>Kbps <samplerate>Hz", "128Kbps 44100Hz")]
|
||||
[DataRow("<bitrate>Kbps <samplerate[6]>Hz", "128Kbps 044100Hz")]
|
||||
|
||||
@@ -10,6 +10,7 @@ using Dinah.Core;
|
||||
using FluentAssertions;
|
||||
using FluentAssertions.Common;
|
||||
using LibationSearchEngine;
|
||||
using Lucene.Net.Analysis.Standard;
|
||||
using Microsoft.VisualStudio.TestPlatform.Common.Filtering;
|
||||
using Microsoft.VisualStudio.TestTools.UnitTesting;
|
||||
using Moq;
|
||||
@@ -31,6 +32,7 @@ namespace SearchEngineTests
|
||||
// tag surrounded by spaces
|
||||
[DataRow("[foo]", "tags:foo")]
|
||||
[DataRow(" [foo]", " tags:foo")]
|
||||
[DataRow(" [ foo ]", " tags:foo")]
|
||||
[DataRow("[foo] ", "tags:foo ")]
|
||||
[DataRow(" [foo] ", " tags:foo ")]
|
||||
[DataRow("-[foo]", "-tags:foo")]
|
||||
@@ -51,15 +53,25 @@ namespace SearchEngineTests
|
||||
[DataRow("-israted ", "-israted:True ")]
|
||||
[DataRow(" -israted ", " -israted:True ")]
|
||||
|
||||
//ID Tags to lowercase and not parsed as numbers
|
||||
[DataRow("id:0000000123", "id:0000000123")]
|
||||
[DataRow("id:B000000123", "id:b000000123")]
|
||||
[DataRow("ASIN:B000000123", "asin:b000000123")]
|
||||
[DataRow("AudibleProductId:B000000123", "audibleproductid:b000000123")]
|
||||
[DataRow("ProductId:B000000123", "productid:b000000123")]
|
||||
|
||||
// bool keyword. Append :True
|
||||
[DataRow("israted", "israted:True")]
|
||||
|
||||
// bool keyword with [:bool]. Do not add :True
|
||||
[DataRow("israted:True", "israted:True")]
|
||||
[DataRow("isRated:false", "israted:false")]
|
||||
[DataRow("liberated AND isRated:false", "liberated:True AND israted:false")]
|
||||
|
||||
// tag which happens to be a bool keyword >> parse as tag
|
||||
[DataRow("[israted]", "tags:israted")]
|
||||
[DataRow("[tags] [israted] [tags] [tags] [isliberated] [israted] ", "tags:tags tags:israted tags:tags tags:tags tags:isliberated tags:israted ")]
|
||||
[DataRow("[tags][israted]", "tags:tagstags:israted")]
|
||||
|
||||
// numbers with "to". TO all caps, numbers [8.2] format
|
||||
[DataRow("1 to 10", "00000001.00 TO 00000010.00")]
|
||||
@@ -72,6 +84,10 @@ namespace SearchEngineTests
|
||||
[DataRow("-isRATED", "-israted:True")]
|
||||
|
||||
public void FormattingTest(string input, string output)
|
||||
=> SearchEngine.FormatSearchQuery(input).Should().Be(output);
|
||||
{
|
||||
using var analyzer = new StandardAnalyzer(SearchEngine.Version);
|
||||
|
||||
QuerySanitizer.Sanitize(input, analyzer).Should().Be(output);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user