Allow Libation to start with an invalid Books directory

- Configuration.LibationSettingsAreValid is true if Books property exists and is any non-null, non-empty string.
- If LibationSettingsAreValid is false, Libation will prompt user to set up Libation.
- When the main window is shown, Libation checks if the books directory exists, and if it doesn't, user is notified and prompted to change their setting
- When a user tries to liberate or convert a book, Books directory is validated and user notified if it does not exist.
This commit is contained in:
Michael Bucari-Tovo 2025-08-04 17:29:13 -06:00
parent db588629c0
commit ac4c168725
14 changed files with 217 additions and 72 deletions

View File

@ -111,7 +111,7 @@ namespace LibationAvalonia
if (setupDialog.Config.LibationSettingsAreValid)
{
string? theme = setupDialog.SelectedTheme.Content as string;
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
await RunMigrationsAsync(setupDialog.Config);
@ -120,7 +120,10 @@ namespace LibationAvalonia
ShowMainWindow(desktop);
}
else
await CancelInstallation();
{
e.Cancel = true;
await CancelInstallation(setupDialog);
}
}
else if (setupDialog.IsReturningUser)
{
@ -128,7 +131,8 @@ namespace LibationAvalonia
}
else
{
await CancelInstallation();
e.Cancel = true;
await CancelInstallation(setupDialog);
return;
}
@ -139,11 +143,11 @@ namespace LibationAvalonia
var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file.";
try
{
await MessageBox.ShowAdminAlert(null, body, title, ex);
await MessageBox.ShowAdminAlert(setupDialog, body, title, ex);
}
catch
{
await MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
await MessageBox.Show(setupDialog, $"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
return;
}
@ -190,6 +194,7 @@ namespace LibationAvalonia
{
// path did not result in valid settings
var continueResult = await MessageBox.Show(
libationFilesDialog,
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
"New install?",
MessageBoxButtons.YesNo,
@ -207,18 +212,18 @@ namespace LibationAvalonia
ShowMainWindow(desktop);
}
else
await CancelInstallation();
await CancelInstallation(libationFilesDialog);
}
else
await CancelInstallation();
await CancelInstallation(libationFilesDialog);
}
libationFilesDialog.Close();
}
static async Task CancelInstallation()
static async Task CancelInstallation(Window window)
{
await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Environment.Exit(0);
}

View File

@ -1,7 +1,9 @@
using Avalonia.Controls;
using FileManager;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
@ -39,6 +41,21 @@ namespace LibationAvalonia.Dialogs
}
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
{
LongPath lonNewBooks = settingsDisp.ImportantSettings.GetBooksDirectory();
if (!System.IO.Directory.Exists(lonNewBooks))
{
try
{
System.IO.Directory.CreateDirectory(lonNewBooks);
}
catch (Exception ex)
{
await MessageBox.Show(this, $"Error creating Books Location:\n\n{ex.Message}", "Error creating directory", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
}
await SaveAndCloseAsync();
}
}
}

View File

@ -35,11 +35,8 @@ namespace LibationAvalonia.ViewModels.Settings
}
public void SaveSettings(Configuration config)
{
LongPath lonNewBooks = Configuration.GetKnownDirectory(BooksDirectory) is Configuration.KnownDirectories.None ? BooksDirectory : System.IO.Path.Combine(BooksDirectory, "Books");
if (!System.IO.Directory.Exists(lonNewBooks))
System.IO.Directory.CreateDirectory(lonNewBooks);
config.Books = lonNewBooks;
{
config.Books = GetBooksDirectory();
config.SavePodcastsToParentFolder = SavePodcastsToParentFolder;
config.OverwriteExisting = OverwriteExisting;
config.CreationTime = CreationTime.Value;
@ -47,6 +44,9 @@ namespace LibationAvalonia.ViewModels.Settings
config.LogLevel = LoggingLevel;
}
public LongPath GetBooksDirectory()
=> Configuration.GetKnownDirectory(BooksDirectory) is Configuration.KnownDirectories.None ? BooksDirectory : System.IO.Path.Combine(BooksDirectory, "Books");
private static float scaleFactorToLinearRange(float scaleFactor)
=> float.Round(100 * MathF.Log2(scaleFactor));
private static float linearRangeToScaleFactor(float value)

View File

@ -135,6 +135,20 @@ namespace LibationAvalonia.Views
private async void MainWindow_Opened(object sender, EventArgs e)
{
if (AudibleFileStorage.BooksDirectory is null)
{
var result = await MessageBox.Show(
this,
"Please set a valid Books location in the settings dialog.",
"Books Directory Not Set",
MessageBoxButtons.OKCancel,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button1);
if (result is DialogResult.OK)
await new SettingsDialog().ShowDialog(this);
}
if (Configuration.Instance.FirstLaunch)
{
var result = await MessageBox.Show(this, "Would you like a guided tour to get started?", "Libation Walkthrough", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);

View File

@ -1,4 +1,6 @@
using CommandLine;
using LibationFileManager;
using System;
using System.Threading.Tasks;
namespace LibationCli
@ -6,6 +8,15 @@ namespace LibationCli
[Verb("convert", HelpText = "Convert mp4 to mp3.")]
public class ConvertOptions : ProcessableOptionsBase
{
protected override Task ProcessAsync() => RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
protected override Task ProcessAsync()
{
if (AudibleFileStorage.BooksDirectory is null)
{
Console.Error.WriteLine("Error: Books directory is not set. Please configure the 'Books' setting in Settings.json.");
return Task.CompletedTask;
}
return RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
}
}
}

View File

@ -1,6 +1,8 @@
using CommandLine;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using System;
using System.Threading.Tasks;
namespace LibationCli
@ -13,9 +15,17 @@ namespace LibationCli
public bool PdfOnly { get; set; }
protected override Task ProcessAsync()
=> PdfOnly
{
if (AudibleFileStorage.BooksDirectory is null)
{
Console.Error.WriteLine("Error: Books directory is not set. Please configure the 'Books' setting in Settings.json.");
return Task.CompletedTask;
}
return PdfOnly
? RunAsync(CreateProcessable<DownloadPdf>())
: RunAsync(CreateBackupBook());
}
private static Processable CreateBackupBook()
{

View File

@ -45,13 +45,24 @@ namespace LibationFileManager
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static LongPath BooksDirectory
/// <summary>
/// The fully-qualified Books durectory path if the directory exists, otherwise null.
/// </summary>
public static LongPath? BooksDirectory
{
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Configuration.DefaultBooksDirectory;
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
return null;
try
{
return Directory.CreateDirectory(Configuration.Instance.Books)?.FullName;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error creating Books directory: {@BooksDirectory}", Configuration.Instance.Books);
return null;
}
}
}
#endregion
@ -129,8 +140,9 @@ namespace LibationFileManager
protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
private static BackgroundFileSystem newBookDirectoryFiles()
=> new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
private static BackgroundFileSystem? newBookDirectoryFiles()
=> BooksDirectory is LongPath books ? new BackgroundFileSystem(books, "*.*", SearchOption.AllDirectories)
: null;
protected override List<LongPath> GetFilePathsCustom(string productId)
{
@ -140,6 +152,7 @@ namespace LibationFileManager
BookDirectoryFiles = newBookDirectoryFiles();
var regex = GetBookSearchRegex(productId);
var diskFiles = BookDirectoryFiles?.FindFiles(regex) ?? [];
//Find all extant files matching the productId
//using both the file system and the file path cache
@ -148,17 +161,17 @@ namespace LibationFileManager
.GetFiles(productId)
.Where(c => c.fileType == FileType.Audio && File.Exists(c.path))
.Select(c => c.path)
.Union(BookDirectoryFiles.FindFiles(regex))
.Union(diskFiles)
.ToList();
}
public void Refresh()
{
if (BookDirectoryFiles is null)
if (BookDirectoryFiles is null && BooksDirectory is not null)
lock (bookDirectoryFilesLocker)
BookDirectoryFiles = newBookDirectoryFiles();
else
BookDirectoryFiles?.RefreshFiles();
BookDirectoryFiles?.RefreshFiles();
}
public LongPath? GetPath(string productId) => GetFilePath(productId);

View File

@ -36,12 +36,12 @@ namespace LibationFileManager
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "")
=> Settings.GetNonString(propertyName, defaultValue);
=> Settings is null ? default : Settings.GetNonString(propertyName, defaultValue);
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string? defaultValue = null, [CallerMemberName] string propertyName = "")
=> Settings.GetString(propertyName, defaultValue);
=> Settings?.GetString(propertyName, defaultValue);
public object? GetObject([CallerMemberName] string propertyName = "") => Settings.GetObject(propertyName);
@ -132,13 +132,13 @@ namespace LibationFileManager
/// True if the Books directory can be written to with 255 unicode character filenames
/// <para/> Does not persist. Check and set this value at runtime and whenever Books is changed.
/// </summary>
public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(Books);
public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(AudibleFileStorage.BooksDirectory);
/// <summary>
/// True if the Books directory can be written to with filenames containing characters invalid on Windows (:, *, ?, &lt;, &gt;, |)
/// <para/> Always false on Windows platforms.
/// <para/> Does not persist. Check and set this value at runtime and whenever Books is changed.
/// </summary>
public bool BooksCanWriteWindowsInvalidChars => !IsWindows && (m_BooksCanWriteWindowsInvalidChars ??= FileSystemTest.CanWriteWindowsInvalidChars(Books));
public bool BooksCanWriteWindowsInvalidChars => !IsWindows && (m_BooksCanWriteWindowsInvalidChars ??= FileSystemTest.CanWriteWindowsInvalidChars(AudibleFileStorage.BooksDirectory));
[Description("Overwrite existing files if they already exist?")]
public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); }

View File

@ -3,48 +3,58 @@ using System.IO;
using System.Linq;
using Dinah.Core;
using FileManager;
using Newtonsoft.Json.Linq;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
{
/// <summary>
/// Returns true if <see cref="SettingsFilePath"/> exists and the <see cref="Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Books"/> directory.
/// </summary>
public bool LibationSettingsAreValid => SettingsFileIsValid(SettingsFilePath);
/// <summary>
/// Returns true if <paramref name="settingsFile"/> exists and the <see cref="Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Books"/> directory.
/// </summary>
/// <param name="settingsFile">File path to the settings JSON file</param>
public static bool SettingsFileIsValid(string settingsFile)
{
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
var pDic = new PersistentDictionary(settingsFile, isReadOnly: false);
if (pDic.GetString(nameof(Books)) is not string booksDir)
return false;
if (!Directory.Exists(booksDir))
try
{
if (Path.GetDirectoryName(settingsFile) is not string dir)
throw new DirectoryNotFoundException(settingsFile);
//"Books" is not null, so setup has already been run.
//Since Books can't be found, try to create it
//and then revert to the default books directory
foreach (string d in new string[] { booksDir, DefaultBooksDirectory })
var settingsJson = JObject.Parse(File.ReadAllText(settingsFile));
return !string.IsNullOrWhiteSpace(settingsJson[nameof(Books)]?.Value<string>());
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to load settings file: {@SettingsFile}", settingsFile);
try
{
Serilog.Log.Logger.Information("Deleting invalid settings file: {@SettingsFile}", settingsFile);
FileUtility.SaferDelete(settingsFile);
Serilog.Log.Logger.Information("Creating a new, empty setting file: {@SettingsFile}", settingsFile);
try
{
Directory.CreateDirectory(d);
pDic.SetString(nameof(Books), d);
return Directory.Exists(d);
File.WriteAllText(settingsFile, "{}");
}
catch (Exception createEx)
{
Serilog.Log.Logger.Error(createEx, "Failed to create new settings file: {@SettingsFile}", settingsFile);
}
catch { /* Do Nothing */ }
}
catch (Exception deleteEx)
{
Serilog.Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {@SettingsFile}", settingsFile);
}
return false;
}
return true;
}
#region singleton stuff

View File

@ -1,5 +1,6 @@
using ApplicationServices;
using DataLayer;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
@ -95,6 +96,9 @@ public class ProcessQueueViewModel : ReactiveObject
public bool QueueDownloadPdf(IList<LibraryBook> libraryBooks)
{
if (!IsBooksDirectoryValid())
return false;
var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload()).ToArray();
if (needsPdf.Length > 0)
{
@ -107,6 +111,9 @@ public class ProcessQueueViewModel : ReactiveObject
public bool QueueConvertToMp3(IList<LibraryBook> libraryBooks)
{
if (!IsBooksDirectoryValid())
return false;
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
var preLiberated = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product).ToArray();
if (preLiberated.Length > 0)
@ -122,6 +129,9 @@ public class ProcessQueueViewModel : ReactiveObject
public bool QueueDownloadDecrypt(IList<LibraryBook> libraryBooks)
{
if (!IsBooksDirectoryValid())
return false;
if (libraryBooks.Count == 1)
{
var item = libraryBooks[0];
@ -157,6 +167,32 @@ public class ProcessQueueViewModel : ReactiveObject
return false;
}
private bool IsBooksDirectoryValid()
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
{
Serilog.Log.Logger.Error("Books location is not set in configuration.");
MessageBoxBase.Show(
"Please choose a \"Books location\" folder in the Settings menu.",
"Books Directory Not Set",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
return false;
}
else if (AudibleFileStorage.BooksDirectory is null)
{
Serilog.Log.Logger.Error("Failed to create books directory: {@booksDir}", Configuration.Instance.Books);
MessageBoxBase.Show(
$"Libation was unable to create the \"Books location\" folder at:\n{Configuration.Instance.Books}\n\nPlease change the Books location in the settings menu.",
"Failed to Create Books Directory",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
return false;
}
return true;
}
private bool IsBookInQueue(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is not ProcessBookViewModel entry ? false
: entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed ? !Queue.RemoveCompleted(entry)

View File

@ -7,6 +7,7 @@ using System.IO;
using System.Linq;
using System.Windows.Forms;
#nullable enable
namespace LibationWinForms.Dialogs
{
public partial class SettingsDialog
@ -55,7 +56,7 @@ namespace LibationWinForms.Dialogs
},
Configuration.KnownDirectories.UserProfile,
"Books");
booksSelectControl.SelectDirectory(config.Books.PathWithoutPrefix);
booksSelectControl.SelectDirectory(config.Books?.PathWithoutPrefix ?? "");
saveEpisodesToSeriesFolderCbox.Checked = config.SavePodcastsToParentFolder;
overwriteExistingCbox.Checked = config.OverwriteExisting;
@ -63,7 +64,7 @@ namespace LibationWinForms.Dialogs
gridFontScaleFactorTbar.Value = scaleFactorToLinearRange(config.GridFontScaleFactor);
}
private void Save_Important(Configuration config)
private bool Save_Important(Configuration config)
{
var newBooks = booksSelectControl.SelectedDirectory;
@ -73,19 +74,29 @@ namespace LibationWinForms.Dialogs
if (string.IsNullOrWhiteSpace(newBooks))
{
validationError("Cannot set Books Location to blank", "Location is blank");
return;
return false;
}
LongPath lonNewBooks = newBooks;
if (!Directory.Exists(lonNewBooks))
{
try
{
Directory.CreateDirectory(lonNewBooks);
}
catch (Exception ex)
{
validationError($"Error creating Books Location:\r\n{ex.Message}", "Error creating directory");
return false;
}
}
#endregion
LongPath lonNewBooks = newBooks;
if (!Directory.Exists(lonNewBooks))
Directory.CreateDirectory(lonNewBooks);
config.Books = newBooks;
{
var logLevelOld = config.LogLevel;
var logLevelNew = (Serilog.Events.LogEventLevel)loggingLevelCb.SelectedItem;
var logLevelNew = (loggingLevelCb.SelectedItem as Serilog.Events.LogEventLevel?) ?? Serilog.Events.LogEventLevel.Information;
config.LogLevel = logLevelNew;
@ -97,9 +108,9 @@ namespace LibationWinForms.Dialogs
config.SavePodcastsToParentFolder = saveEpisodesToSeriesFolderCbox.Checked;
config.OverwriteExisting = overwriteExistingCbox.Checked;
config.CreationTime = ((EnumDisplay<Configuration.DateTimeSource>)creationTimeCb.SelectedItem).Value;
config.LastWriteTime = ((EnumDisplay<Configuration.DateTimeSource>)lastWriteTimeCb.SelectedItem).Value;
config.CreationTime = (creationTimeCb.SelectedItem as EnumDisplay<Configuration.DateTimeSource>)?.Value ?? Configuration.DateTimeSource.File;
config.LastWriteTime = (lastWriteTimeCb.SelectedItem as EnumDisplay<Configuration.DateTimeSource>)?.Value ?? Configuration.DateTimeSource.File;
return true;
}
private static int scaleFactorToLinearRange(float scaleFactor)

View File

@ -43,7 +43,7 @@ namespace LibationWinForms.Dialogs
private void saveBtn_Click(object sender, EventArgs e)
{
Save_Important(config);
if (!Save_Important(config)) return;
Save_ImportLibrary(config);
Save_DownloadDecrypt(config);
Save_AudioSettings(config);

View File

@ -6,9 +6,29 @@ namespace LibationWinForms
{
public partial class Form1
{
private void Configure_Settings() { }
private void Configure_Settings()
{
Shown += FormShown_Settings;
}
private void accountsToolStripMenuItem_Click(object sender, EventArgs e) => new AccountsDialog().ShowDialog();
private void FormShown_Settings(object sender, EventArgs e)
{
if (LibationFileManager.AudibleFileStorage.BooksDirectory is null)
{
var result = MessageBox.Show(
this,
"Please set a valid Books location in the settings dialog.",
"Books Directory Not Set",
MessageBoxButtons.OKCancel,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button1);
if (result is DialogResult.OK)
new SettingsDialog().ShowDialog(this);
}
}
private void accountsToolStripMenuItem_Click(object sender, EventArgs e) => new AccountsDialog().ShowDialog();
private void basicSettingsToolStripMenuItem_Click(object sender, EventArgs e) => new SettingsDialog().ShowDialog();

View File

@ -148,7 +148,10 @@ namespace LibationWinForms
}
if (setupDialog.IsNewUser)
{
Configuration.SetLibationFiles(defaultLibationFilesDir);
config.Books = Configuration.DefaultBooksDirectory;
}
else if (setupDialog.IsReturningUser)
{
var libationFilesDialog = new LibationFilesDialog();
@ -175,16 +178,11 @@ namespace LibationWinForms
CancelInstallation();
return;
}
config.Books = Configuration.DefaultBooksDirectory;
}
// INIT DEFAULT SETTINGS
// if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog
config.Books ??= Configuration.DefaultBooksDirectory;
if (config.LibationSettingsAreValid)
return;
CancelInstallation();
if (!config.LibationSettingsAreValid)
CancelInstallation();
}
/// <summary>migrations which require Forms or are long-running</summary>