Compare commits

...

12 Commits

Author SHA1 Message Date
rmcrackan
8221d7e202 Merge pull request #631 from Mbucari/master
Add features #626 and #627 and Fix #628
2023-06-14 14:03:24 -04:00
Robert McRackan
fa92946d20 incr ver 2023-06-14 14:02:50 -04:00
Mbucari
6d13325c4f Add <account nickname> tag (#629) 2023-06-14 11:56:38 -06:00
Mbucari
7a9c6720c7 Fix Stupid 2023-06-14 11:35:11 -06:00
Mbucari
697f797509 Remove debug code 2023-06-14 11:16:53 -06:00
Mbucari
ec9854212a Write error info to StdErr (#626) 2023-06-14 10:58:37 -06:00
Mbucari
46f6ba1710 Add feature #627 and fix bug #628
- Feature: Option to overwrite existing audio files when moving to Books
- Bugfix: Do not set liberated status if moving files fails.
2023-06-14 10:51:43 -06:00
rmcrackan
7347244f0a Merge pull request #630 from CLHatch/patch-1
Update Advanced.md
2023-06-14 07:17:45 -04:00
CLHatch
c29c4c470c Update Advanced.md
M4B files use the `@wrt` instead of `TCOM` tag for "composer".
2023-06-14 02:33:49 -05:00
rmcrackan
ee51fd9da6 Merge pull request #625 from Mbucari/master
Refactor LibationSearchEngine
2023-06-13 12:39:42 -04:00
Mbucari
2c4705de6e Address #625 comments and refactor 2023-06-13 09:05:17 -06:00
Mbucari
b4aa220051 Refactor LibationSearchEngine 2023-06-12 14:02:55 -06:00
40 changed files with 1449 additions and 1394 deletions

View File

@@ -30,7 +30,7 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
* Adds the `TCOM` metadata tag for the narrators.
* 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 `&#169;` with `©`)
* Replaces the recording copyright `(P)` string with `℗`

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>10.3.8.1</Version>
<Version>10.4.0.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="6.0.0" />

View File

@@ -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);
}
}
}

View File

@@ -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;
}

View File

@@ -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);
}
);

View File

@@ -73,7 +73,14 @@ 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);
OnFileCreated(libraryBook, realMp3Path);
}
}

View File

@@ -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,34 @@ 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();
return new StatusHandler();
}
finally
{
@@ -343,7 +360,13 @@ namespace FileLiberator
{
var entry = entries[i];
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
var realDest
= FileUtility.SaferMoveToValidPath(
entry.Path,
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
Configuration.Instance.ReplacementCharacters,
overwrite: Configuration.Instance.OverwriteExisting);
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)
@@ -370,7 +393,7 @@ namespace FileLiberator
private static void downloadCoverArt(LibraryBook libraryBook)
{
if (!Configuration.Instance.DownloadCoverArt) return;
if (!Configuration.Instance.DownloadCoverArt) return;
var coverPath = "[null]";

View File

@@ -30,7 +30,7 @@ namespace FileLiberator
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
libraryBook.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
return result;
}

View File

@@ -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 = (int?)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
};
}
}
}

View File

@@ -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;
}

View File

@@ -28,6 +28,10 @@
<TextBlock Text="{CompiledBinding SavePodcastsToParentFolderText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding OverwriteExisting, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding OverwriteExistingText}" />
</CheckBox>
</StackPanel>
</controls:GroupBox>

View File

@@ -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();
}

View File

@@ -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;

View File

@@ -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}");

View File

@@ -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(@$"

View File

@@ -21,6 +21,7 @@ namespace LibationAvalonia.ViewModels.Settings
{
BooksDirectory = config.Books.PathWithoutPrefix;
SavePodcastsToParentFolder = config.SavePodcastsToParentFolder;
OverwriteExisting = config.OverwriteExisting;
LoggingLevel = config.LogLevel;
ThemeVariant = initialThemeVariant
= Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) is nameof(Avalonia.Styling.ThemeVariant.Dark)
@@ -35,6 +36,7 @@ namespace LibationAvalonia.ViewModels.Settings
System.IO.Directory.CreateDirectory(lonNewBooks);
config.Books = lonNewBooks;
config.SavePodcastsToParentFolder = SavePodcastsToParentFolder;
config.OverwriteExisting = OverwriteExisting;
config.LogLevel = LoggingLevel;
Configuration.Instance.SetString(ThemeVariant, nameof(ThemeVariant));
}
@@ -50,12 +52,14 @@ 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 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 Serilog.Events.LogEventLevel LoggingLevel { get; set; }
public string ThemeVariant

View File

@@ -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

View File

@@ -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);
}
}

View File

@@ -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() });
}
}

View File

@@ -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;
}

View File

@@ -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

View File

@@ -37,5 +37,6 @@ namespace LibationFileManager
{
public DateTime? DateAdded { get; set; }
public string Account { get; set; }
public string AccountNickname { get; set; }
}
}

View File

@@ -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",

View File

@@ -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");

View File

@@ -215,6 +215,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 },

View 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))})";
}

View 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();
}

View File

@@ -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);

View File

@@ -8,22 +8,20 @@ namespace LibationSearchEngine
internal static class QuerySanitizer
{
private static readonly HashSet<string> idTerms
= SearchEngine.idIndexRules.Keys
.Select(s => s.ToLowerInvariant())
.ToHashSet();
= SearchEngine.FieldIndexRules.IdFieldNames
.Select(n => n.ToLowerInvariant())
.ToHashSet();
private static readonly HashSet<string> boolTerms
= SearchEngine.boolIndexRules.Keys
.Select(s => s.ToLowerInvariant())
.ToHashSet();
= SearchEngine.FieldIndexRules.BoolFieldNames
.Select(n => n.ToLowerInvariant())
.ToHashSet();
private static readonly HashSet<string> fieldTerms
= SearchEngine.stringIndexRules.Keys
.Union(SearchEngine.numberIndexRules.Keys)
.Select(s => s.ToLowerInvariant())
.Union(idTerms)
.Union(boolTerms)
.ToHashSet();
= SearchEngine.FieldIndexRules
.SelectMany(r => r.FieldNames)
.Select(n => n.ToLowerInvariant())
.ToHashSet();
internal static string Sanitize(string searchString, StandardAnalyzer analyzer)
{

View File

@@ -1,13 +1,10 @@
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.Standard;
using Lucene.Net.Analysis.Tokenattributes;
using Lucene.Net.Documents;
using Lucene.Net.Index;
using Lucene.Net.Search;
@@ -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";
internal 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.ToLowerInvariant(),
["ProductId"] = lb => lb.Book.AudibleProductId.ToLowerInvariant(),
["Id"] = lb => lb.Book.AudibleProductId.ToLowerInvariant(),
["ASIN"] = lb => lb.Book.AudibleProductId.ToLowerInvariant()
}
);
internal 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
}
);
internal 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()
}
);
internal 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);
@@ -412,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; }

View File

@@ -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

View File

@@ -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();
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -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)
{

View File

@@ -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;
}

View File

@@ -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">

View File

@@ -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

View File

@@ -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}");

View File

@@ -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(@$"

View File

@@ -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),