Compare commits

..

18 Commits

Author SHA1 Message Date
Robert McRackan
efbefa2784 Increm ver 2025-03-01 10:44:49 -05:00
rmcrackan
51aabe5dd4 Merge pull request #1161 from Mbucari/master
Performance and UI tweaks
2025-02-28 22:48:49 -05:00
Michael Bucari-Tovo
1c668adff8 Deliminated category names in library exports with semicolon (#1107) 2025-02-28 17:35:58 -07:00
Michael Bucari-Tovo
4170dcc1d5 Chardonnay UI bug fixes and improvements
- Theme changes do not require restart
- Fix some text appearing black in dark mode
- Fix dialog boxes not appearing correctly on Windows
- Fix queue vertical scroll bar overlapping items
2025-02-28 17:34:55 -07:00
Michael Bucari-Tovo
68cfae1d58 Fix ever-widening Form1 when form size is restored. 2025-02-28 12:04:49 -07:00
Michael Bucari-Tovo
a790c7535c Changes to default directories for file storage (#1112)
- Add My Music and Local Application Data to known directories
- Make %localappdata%\Libation the default settings folder on *nix machines
- Make %MyMusic%\Libation\Books the default books folder on *nix machines
2025-02-28 12:01:58 -07:00
Michael Bucari-Tovo
3b7d5a354f Re-add books to queue that failed or were cancelled. 2025-02-28 10:47:26 -07:00
MBucari
a9375f1520 Improve file cache performance and add migration
LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size.

The new cache uses a dictionary with the ID as its key, and a CacheEntry list as its value.
2025-02-28 10:07:45 -07:00
Michael Bucari-Tovo
47c9fcb883 Improve LibrarySizeChanged performance 2025-02-27 22:56:30 -07:00
rmcrackan
5f5c9f65ed Merge pull request #1160 from Mbucari/master
Fixed stack overflow crash when movifying large databases
2025-02-27 21:41:33 -05:00
MBucari
1417a4b992 Improve library load performance 2025-02-27 19:16:36 -07:00
Michael Bucari-Tovo
2d6120f0c4 Get full library in LibrarySizeChanged event and pass as EventArgs
There are multiple subscribers to LibraryCommands.LibrarySizeChanged, and each one calls GetLibrary_Flat_NoTracking(). Passing the full library as an event argument speeds up all operations which happen after the library size changes.

Fix initial backup counts
2025-02-27 13:11:28 -07:00
Michael Bucari-Tovo
2a25b7e0ad Load MainWindow before library finishes loading like Classic 2025-02-27 11:17:18 -07:00
Michael Bucari-Tovo
4766ea7372 Improve NRE safety for quick filters 2025-02-27 10:10:26 -07:00
Michael Bucari-Tovo
d195dd07dc Remove upload-release-assets package (out of support) 2025-02-27 09:30:08 -07:00
Michael Bucari-Tovo
bcbb7610ad Refactor GetAllEntries methods for clarity and maintainability
d
2025-02-27 09:00:26 -07:00
Michael Bucari-Tovo
6c5773df24 Fix stack overflow exception when updating large databases (#1158) 2025-02-26 14:41:59 -07:00
rmcrackan
211f15af25 Update InstallOnMac.md 2025-02-25 10:03:11 -05:00
46 changed files with 656 additions and 303 deletions

View File

@@ -51,13 +51,8 @@ jobs:
with:
name: Libation ${{ needs.prerelease.outputs.version }}
body: <Put a body here>
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: false
- name: Upload release assets
uses: dwenegar/upload-release-assets@v2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
release_id: "${{ steps.release.outputs.id }}"
assets_path: ./artifacts
files: |
artifacts/*/*

View File

@@ -13,7 +13,7 @@ This walkthrough should get you up and running with Libation on your Mac.
## Install Libation
- Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz`
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder.
- Right-click on Libation and then click on open

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.0.0.1</Version>
<Version>12.0.1.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -90,6 +90,7 @@ namespace AppScaffolding
Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_to_v11_5_0(config);
Migrations.migrate_to_v11_6_5(config);
Migrations.migrate_to_v12_0_1(config);
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
@@ -279,8 +280,11 @@ namespace AppScaffolding
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
=> SearchEngineCommands.UpdateBooks(books);
}
public static UpgradeProperties GetLatestRelease()
@@ -414,6 +418,82 @@ namespace AppScaffolding
public List<string> Filters { get; set; } = new();
}
public static void migrate_to_v12_0_1(Configuration config)
{
#nullable enable
//Migrate from version 1 file cache to the dictionary-based version 2 cache
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{
try
{
//FilePathCache loads the cache in its static constructor,
//so perform migration without using FilePathCache.CacheEntry
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
return;
Dictionary<string, JArray> cache = new();
//Convert to c# objects to speed up searching by ID inside the iterator
var allItems
= v1Cache
.Select(i => new
{
Id = i["Id"]?.Value<string>(),
Path = i["Path"]?["Path"]?.Value<string>()
}).Where(i => i.Id != null)
.ToArray();
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
{
//Use this opportunity to purge non-existent files and re-classify file types
//(due to *.aax files previously not being classified as FileType.AAXC)
var items = allItems
.Where(i => i.Id == id && File.Exists(i.Path))
.Select(i => new JObject
{
{ "Id", i.Id },
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) },
{ "Path", new JObject{ { "Path", i.Path } } }
})
.ToArray();
if (items.Length == 0)
continue;
cache[id] = new JArray(items);
}
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
var cacheFileText = cacheJson.ToString(Formatting.Indented);
void migrate()
{
File.WriteAllText(jsonFileV2, cacheFileText);
File.Delete(jsonFileV1);
}
try { migrate(); }
catch (IOException)
{
try { migrate(); }
catch (IOException)
{
migrate();
}
}
}
catch { /* eat */ }
}
#nullable restore
}
public static void migrate_to_v11_6_5(Configuration config)
{
//Settings migration for unsupported sample rates (#1116)

View File

@@ -222,7 +222,7 @@ namespace ApplicationServices
{
int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0)
await Task.Run(finalizeLibrarySizeChange);
await Task.Run(() => finalizeLibrarySizeChange(context));
return qtyChanged;
}
catch (Exception ex)
@@ -329,7 +329,7 @@ namespace ApplicationServices
// this is any changes at all to the database, not just new books
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
await Task.Run(() => finalizeLibrarySizeChange(context));
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount;
@@ -369,16 +369,16 @@ namespace ApplicationServices
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
lb.IsDeleted = true;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
lb.IsDeleted = true;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -398,16 +398,16 @@ namespace ApplicationServices
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -432,7 +432,7 @@ namespace ApplicationServices
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -445,10 +445,14 @@ namespace ApplicationServices
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
private static void finalizeLibrarySizeChange(LibationContext context)
{
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
LibrarySizeChanged?.Invoke(null, library);
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
public static event EventHandler<List<LibraryBook>> LibrarySizeChanged;
/// <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.
@@ -518,17 +522,18 @@ namespace ApplicationServices
if (libraryBooks is null || !libraryBooks.Any())
return 0;
foreach (var book in libraryBooks)
action?.Invoke(book.Book.UserDefinedItem);
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var book in libraryBooks)
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
action?.Invoke(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
udiEntity.Reference(udi => udi.Rating).TargetEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
@@ -599,7 +604,8 @@ namespace ApplicationServices
var results = libraryBooks
.AsParallel()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.WithoutParents()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.ToList();
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);

View File

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

View File

@@ -48,6 +48,8 @@ namespace ApplicationServices
}
public static void FullReIndex() => performSafeCommand(fullReIndex);
public static void FullReIndex(List<LibraryBook> libraryBooks)
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
{
@@ -94,8 +96,11 @@ namespace ApplicationServices
private static void fullReIndex(SearchEngine engine)
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
fullReIndex(engine, library);
}
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
=> engine.CreateNewIndex(libraryBooks);
#endregion
}
}

View File

@@ -6,6 +6,7 @@ namespace DataLayer
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
{
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}
}

View File

@@ -44,7 +44,11 @@ namespace DataLayer
public static bool IsEpisodeParent(this Book book)
=> book.ContentType is ContentType.Parent;
public static bool HasLiberated(this Book book)
public static IEnumerable<LibraryBook> WithoutParents(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => !lb.Book.IsEpisodeParent());
public static bool HasLiberated(this Book book)
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
}

View File

@@ -21,8 +21,8 @@ namespace DataLayer
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.AsEnumerable()
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
.ToList();
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
@@ -91,7 +91,7 @@ namespace DataLayer
}
#nullable disable
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
public static List<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList
.Where(
lb =>

View File

@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LibationAvalonia"
xmlns:controls="using:LibationAvalonia.Controls"
xmlns:dialogs="using:LibationAvalonia.Dialogs"
x:Class="LibationAvalonia.App"
Name="Libation">
@@ -12,6 +13,10 @@
<Application.Resources>
<ResourceDictionary>
<ControlTheme x:Key="{x:Type TextBlock}" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
<Setter Property="Background" Value="Transparent" />
</ControlTheme>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" />
@@ -81,6 +86,60 @@
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
</Style>
<Style Selector="dialogs|DialogWindow">
<Style Selector="^[UseCustomTitleBar=false]">
<Setter Property="SystemDecorations" Value="Full"/>
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Background="{DynamicResource SystemControlBackgroundAltHighBrush}" Content="{TemplateBinding Content}" />
</ControlTemplate>
</Setter>
</Style>
<Style Selector="^[UseCustomTitleBar=true]">
<Setter Property="SystemDecorations" Value="BorderOnly"/>
<Setter Property="Template">
<ControlTemplate>
<Panel Background="{DynamicResource SystemControlBackgroundAltHighBrush}">
<Grid RowDefinitions="30,*">
<Border Name="DialogWindowTitleBorder" Margin="5,0" Background="{DynamicResource SystemAltMediumColor}">
<Border.Styles>
<Style Selector="Button#DialogCloseButton">
<Style Selector="^:pointerover">
<Style Selector="^ /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Red" />
</Style>
<Style Selector="^ Path">
<Setter Property="Fill" Value="{DynamicResource IconFill}" />
</Style>
</Style>
<Style Selector="^:not(:pointerover) /template/ ContentPresenter#PART_ContentPresenter">
<Setter Property="Background" Value="Transparent" />
<Setter Property="BorderBrush" Value="Transparent" />
</Style>
</Style>
</Border.Styles>
<Grid ColumnDefinitions="Auto,*,Auto">
<Path Name="DialogWindowTitleIcon" Margin="3,5,0,5" Fill="{DynamicResource IconFill}" Stretch="Uniform" Data="{StaticResource LibationGlassIcon}"/>
<TextBlock Name="DialogWindowTitleTextBlock" Margin="8,0,0,0" VerticalAlignment="Center" FontWeight="DemiBold" FontSize="12" Grid.Column="1" Text="{TemplateBinding Title}" />
<Button Name="DialogCloseButton" Grid.Column="2">
<Path Fill="{DynamicResource SystemControlBackgroundBaseLowBrush}" VerticalAlignment="Center" Stretch="Uniform" RenderTransform="{StaticResource Rotate45Transform}" Data="{StaticResource CancelButtonIcon}" />
</Button>
</Grid>
</Border>
<Path Stroke="{DynamicResource SystemBaseMediumLowColor}" StrokeThickness="1" VerticalAlignment="Bottom" Stretch="Fill" Data="M0,0 L1,0" />
<ContentPresenter Grid.Row="1" Content="{TemplateBinding Content}" />
</Grid>
</Panel>
</ControlTemplate>
</Setter>
</Style>
</Style>
</Application.Styles>
<NativeMenu.Menu>

View File

@@ -1,8 +1,6 @@
using ApplicationServices;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform;
@@ -14,14 +12,13 @@ using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using ReactiveUI;
using DataLayer;
using Avalonia.Threading;
namespace LibationAvalonia
{
public class App : Application
{
public static Window MainWindow { get; private set; }
public static MainWindow MainWindow { get; private set; }
public static IBrush ProcessQueueBookFailedBrush { get; private set; }
public static IBrush ProcessQueueBookCompletedBrush { get; private set; }
public static IBrush ProcessQueueBookCancelledBrush { get; private set; }
@@ -47,7 +44,7 @@ namespace LibationAvalonia
if (!config.LibationSettingsAreValid)
{
var defaultLibationFilesDir = Configuration.UserProfile;
var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
@@ -85,8 +82,8 @@ namespace LibationAvalonia
// - error message, Exit()
if (setupDialog.IsNewUser)
{
Configuration.SetLibationFiles(Configuration.UserProfile);
setupDialog.Config.Books = Path.Combine(Configuration.UserProfile, nameof(Configuration.Books));
Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory);
setupDialog.Config.Books = Configuration.DefaultBooksDirectory;
if (setupDialog.Config.LibationSettingsAreValid)
{
@@ -177,7 +174,7 @@ namespace LibationAvalonia
if (continueResult == DialogResult.Yes)
{
config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books));
config.Books = Configuration.DefaultBooksDirectory;
if (config.LibationSettingsAreValid)
{
@@ -216,11 +213,17 @@ namespace LibationAvalonia
LoadStyles();
var mainWindow = new MainWindow();
desktop.MainWindow = MainWindow = mainWindow;
mainWindow.OnLibraryLoaded(LibraryTask.GetAwaiter().GetResult());
mainWindow.Loaded += MainWindow_Loaded;
mainWindow.RestoreSizeAndLocation(Configuration.Instance);
mainWindow.Show();
}
private static async void MainWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var library = await LibraryTask;
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
}
private static void LoadStyles()
{
ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookFailedBrush));

View File

@@ -91,6 +91,32 @@
S 192,128 147,147
</StreamGeometry>
<StreamGeometry x:Key="LibationGlassIcon">
M262,8
h-117
a 192,200 0 0 0 -36,82
a 222,334 41 0 0 138,236
v158
h-81
a 16,16 0 0 0 0,32
h192
a 16 16 0 0 0 0,-32
h-81
v-158
a 222,334 -41 0 0 138,-236
a 192,200 0 0 0 -36,-82
h-117
m-99,30
a 192,200 0 0 0 -26,95
a 187.5,334 35 0 0 125,159
a 187.5,334 -35 0 0 125,-159
a 192,200 0 0 0 -26,-95
h-198
M158,136
a 168,305 35 0 0 104,136
a 168,305 -35 0 0 104,-136
</StreamGeometry>
</ResourceDictionary>
</Styles.Resources>
</Styles>

View File

@@ -51,7 +51,9 @@ namespace LibationAvalonia.Controls
{
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.ApplicationData,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyMusic,
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.LibationFiles
};

View File

@@ -166,16 +166,7 @@
MinWidth="80"
SelectedItem="{CompiledBinding ThemeVariant, Mode=TwoWay}"
ItemsSource="{CompiledBinding Themes}"/>
<TextBlock
Grid.Column="2"
FontSize="16"
FontWeight="Bold"
Margin="10,0"
VerticalAlignment="Center"
IsVisible="{CompiledBinding SelectionChanged}"
Text="Theme change takes effect on restart"/>
</Grid>
</Grid>
</UserControl>

View File

@@ -1,4 +1,6 @@
using Avalonia.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using LibationFileManager;
using System;
using System.Threading.Tasks;
@@ -9,19 +11,98 @@ namespace LibationAvalonia.Dialogs
{
public bool SaveAndRestorePosition { get; set; } = true;
public Control ControlToFocusOnShow { get; set; }
protected override Type StyleKeyOverride => typeof(DialogWindow);
public static readonly StyledProperty<bool> UseCustomTitleBarProperty =
AvaloniaProperty.Register<DialogWindow, bool>(nameof(UseCustomTitleBar));
public bool UseCustomTitleBar
{
get { return GetValue(UseCustomTitleBarProperty); }
set { SetValue(UseCustomTitleBarProperty, value); }
}
public DialogWindow()
{
this.HideMinMaxBtns();
this.KeyDown += DialogWindow_KeyDown;
this.Initialized += DialogWindow_Initialized;
this.Opened += DialogWindow_Opened;
this.Closing += DialogWindow_Closing;
KeyDown += DialogWindow_KeyDown;
Initialized += DialogWindow_Initialized;
Opened += DialogWindow_Opened;
Closing += DialogWindow_Closing;
UseCustomTitleBar = Configuration.IsWindows;
}
private bool fixedMinHeight = false;
private bool fixedMaxHeight = false;
private bool fixedHeight = false;
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
const int customTitleBarHeight = 30;
if (UseCustomTitleBar)
{
if (change.Property == MinHeightProperty && !fixedMinHeight)
{
fixedMinHeight = true;
MinHeight += customTitleBarHeight;
fixedMinHeight = false;
}
if (change.Property == MaxHeightProperty && !fixedMaxHeight)
{
fixedMaxHeight = true;
MaxHeight += customTitleBarHeight;
fixedMaxHeight = false;
}
if (change.Property == HeightProperty && !fixedHeight)
{
fixedHeight = true;
Height += customTitleBarHeight;
fixedHeight = false;
}
}
base.OnPropertyChanged(change);
}
public DialogWindow(bool saveAndRestorePosition) : this()
{
SaveAndRestorePosition = saveAndRestorePosition;
}
protected override void OnApplyTemplate(TemplateAppliedEventArgs e)
{
base.OnApplyTemplate(e);
if (!UseCustomTitleBar)
return;
var closeButton = e.NameScope.Find<Button>("DialogCloseButton");
var border = e.NameScope.Get<Border>("DialogWindowTitleBorder");
var titleBlock = e.NameScope.Get<TextBlock>("DialogWindowTitleTextBlock");
var icon = e.NameScope.Get<Avalonia.Controls.Shapes.Path>("DialogWindowTitleIcon");
closeButton.Click += CloseButton_Click;
border.PointerPressed += Border_PointerPressed;
icon.IsVisible = Icon != null;
if (MinHeight == MaxHeight && MinWidth == MaxWidth)
{
CanResize = false;
border.Margin = new Thickness(0);
icon.Margin = new Thickness(8, 5, 0, 5);
}
}
private void Border_PointerPressed(object sender, Avalonia.Input.PointerPressedEventArgs e)
{
if (e.GetCurrentPoint(null).Properties.IsLeftButtonPressed)
BeginMoveDrag(e);
}
private void CloseButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
CancelAndClose();
}
private void DialogWindow_Initialized(object sender, EventArgs e)
{
this.WindowStartupLocation = WindowStartupLocation.CenterOwner;

View File

@@ -25,7 +25,6 @@ namespace LibationAvalonia.Dialogs
{
InitializeComponent();
this.HideMinMaxBtns();
ControlToFocusOnShow = this.FindControl<Button>(nameof(ImportButton));
LoadAccounts();

View File

@@ -12,8 +12,6 @@ namespace LibationAvalonia.Dialogs
{
InitializeComponent();
this.HideMinMaxBtns();
StringFields = @"
Search for wizard of oz:
title:oz

View File

@@ -109,23 +109,5 @@ namespace LibationAvalonia
public int Width;
public bool IsMaximized;
}
public static void HideMinMaxBtns(this Window form)
{
if (Design.IsDesignMode || !Configuration.IsWindows || form.TryGetPlatformHandle() is not IPlatformHandle handle)
return;
var currentStyle = GetWindowLong(handle.Handle, GWL_STYLE);
SetWindowLong(handle.Handle, GWL_STYLE, currentStyle & ~WS_MAXIMIZEBOX & ~WS_MINIMIZEBOX);
}
const long WS_MINIMIZEBOX = 0x00020000L;
const long WS_MAXIMIZEBOX = 0x10000L;
const int GWL_STYLE = -16;
[System.Runtime.InteropServices.DllImport("user32.dll", EntryPoint = "GetWindowLong")]
static extern long GetWindowLong(IntPtr hWnd, int nIndex);
[System.Runtime.InteropServices.DllImport("user32.dll")]
static extern int SetWindowLong(IntPtr hWnd, int nIndex, long dwNewLong);
}
}

View File

@@ -165,8 +165,6 @@ Libation.
var dialog = new MessageBoxWindow(saveAndRestorePosition);
dialog.HideMinMaxBtns();
var vm = new MessageBoxViewModel(message, caption, buttons, icon, defaultButton);
dialog.DataContext = vm;
dialog.ControlToFocusOnShow = dialog.FindControl<Control>(defaultButton.ToString());
@@ -190,11 +188,13 @@ Libation.
tbx.Height = tbx.DesiredSize.Height;
tbx.Width = tbx.DesiredSize.Width;
dialog.MinHeight = vm.FormHeightFromTboxHeight((int)tbx.DesiredSize.Height);
var absoluteHeight = vm.FormHeightFromTboxHeight((int)tbx.DesiredSize.Height);
dialog.MinHeight = absoluteHeight;
dialog.MinWidth = vm.FormWidthFromTboxWidth((int)tbx.DesiredSize.Width);
dialog.MaxHeight = dialog.MinHeight;
dialog.MaxHeight = absoluteHeight;
dialog.MaxWidth = dialog.MinWidth;
dialog.Height = dialog.MinHeight;
dialog.Height = absoluteHeight;
dialog.Width = dialog.MinWidth;
return dialog;
}

View File

@@ -4,7 +4,6 @@ using DataLayer;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
@@ -44,16 +43,15 @@ namespace LibationAvalonia.ViewModels
private void Configure_BackupCounts()
{
MainWindow.LibraryLoaded += (_, e) => setBackupCounts(e.Where(l => !l.Book.IsEpisodeParent()));
LibraryCommands.LibrarySizeChanged += (_,_) => setBackupCounts();
LibraryCommands.BookUserDefinedItemCommitted += (_, _) => setBackupCounts();
//Pass null to the setup count to get the whole library.
LibraryCommands.BookUserDefinedItemCommitted += async (_, _)
=> await SetBackupCountsAsync(null);
}
private async void setBackupCounts(IEnumerable<LibraryBook> libraryBooks = null)
public async Task SetBackupCountsAsync(IEnumerable<LibraryBook> libraryBooks)
{
if (updateCountsTask?.IsCompleted ?? true)
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
updateCountsTask = Task.Run(() => LibraryCommands.GetCounts(libraryBooks));
var stats = await updateCountsTask;
await Dispatcher.UIThread.InvokeAsync(() => LibraryStats = stats);

View File

@@ -10,8 +10,8 @@ namespace LibationAvalonia.ViewModels
{
partial class MainVM
{
private int _visibleNotLiberated = 1;
private int _visibleCount = 1;
private int _visibleNotLiberated = 0;
private int _visibleCount = 0;
/// <summary> The Bottom-right visible book count status text </summary>
public string VisibleCountText => $"Visible: {_visibleCount}";

View File

@@ -1,7 +1,10 @@
using ApplicationServices;
using DataLayer;
using LibationAvalonia.Views;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels
{
@@ -20,7 +23,7 @@ namespace LibationAvalonia.ViewModels
MainWindow = mainWindow;
ProductsDisplay.RemovableCountChanged += (_, removeCount) => RemoveBooksButtonText = removeCount == 1 ? "Remove 1 Book from Libation" : $"Remove {removeCount} Books from Libation";
LibraryCommands.LibrarySizeChanged += async (_, _) => await ProductsDisplay.UpdateGridAsync(DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
LibraryCommands.LibrarySizeChanged += LibraryCommands_LibrarySizeChanged;
Configure_NonUI();
Configure_BackupCounts();
@@ -34,6 +37,13 @@ namespace LibationAvalonia.ViewModels
Configure_VisibleBooks();
}
private async void LibraryCommands_LibrarySizeChanged(object sender, List<LibraryBook> fullLibrary)
{
await Task.WhenAll(
SetBackupCountsAsync(fullLibrary),
ProductsDisplay.UpdateGridAsync(fullLibrary));
}
private static string menufyText(string header) => Configuration.IsMacOs ? header : $"_{header}";
}
}

View File

@@ -118,7 +118,15 @@ namespace LibationAvalonia.ViewModels
#region Add Books to Queue
private bool isBookInQueue(LibraryBook libraryBook)
=> Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
{
var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
if (entry == null)
return false;
else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed)
return !Queue.RemoveCompleted(entry);
else
return true;
}
public void AddDownloadPdf(LibraryBook libraryBook)
=> AddDownloadPdf(new List<LibraryBook>() { libraryBook });

View File

@@ -28,7 +28,13 @@ namespace LibationAvalonia.ViewModels
/// <summary>Grid entries included in the filter set. If null, all grid entries are shown</summary>
private HashSet<IGridEntry> FilteredInGridEntries;
public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; private set; }
private DataGridCollectionView _gridEntries;
public DataGridCollectionView GridEntries
{
get => _gridEntries;
private set => this.RaiseAndSetIfChanged(ref _gridEntries, value);
}
private bool _removeColumnVisible;
public bool RemoveColumnVisible { get => _removeColumnVisible; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisible, value); }

View File

@@ -21,6 +21,7 @@ namespace LibationAvalonia.ViewModels.Settings
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
{
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.ApplicationData,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs,

View File

@@ -67,7 +67,8 @@ namespace LibationAvalonia.ViewModels.Settings
{
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.MyMusic,
};
public string BooksText { get; } = Configuration.GetDescription(nameof(Configuration.Books));
@@ -100,11 +101,14 @@ namespace LibationAvalonia.ViewModels.Settings
set
{
this.RaiseAndSetIfChanged(ref themeVariant, value);
SelectionChanged = ThemeVariant != initialThemeVariant;
this.RaisePropertyChanged(nameof(SelectionChanged));
App.Current.RequestedThemeVariant = themeVariant switch
{
nameof(Avalonia.Styling.ThemeVariant.Dark) => Avalonia.Styling.ThemeVariant.Dark,
nameof(Avalonia.Styling.ThemeVariant.Light) => Avalonia.Styling.ThemeVariant.Light,
// "System"
_ => Avalonia.Styling.ThemeVariant.Default
};
}
}
public bool SelectionChanged { get; private set; }
}
}

View File

@@ -8,12 +8,12 @@ using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Views
{
public partial class MainWindow : ReactiveWindow<MainVM>
{
public event EventHandler<List<LibraryBook>> LibraryLoaded;
public MainWindow()
{
DataContext = new MainVM(this);
@@ -23,7 +23,6 @@ namespace LibationAvalonia.Views
Opened += MainWindow_Opened;
Closing += MainWindow_Closing;
LibraryLoaded += MainWindow_LibraryLoaded;
KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(selectAndFocusSearchBox), Gesture = new KeyGesture(Key.F, Configuration.IsMacOs ? KeyModifiers.Meta : KeyModifiers.Control) });
@@ -56,21 +55,22 @@ namespace LibationAvalonia.Views
this.SaveSizeAndLocation(Configuration.Instance);
}
private async void MainWindow_LibraryLoaded(object sender, List<LibraryBook> dbBooks)
{
if (QuickFilters.UseDefault)
await ViewModel.PerformFilter(QuickFilters.Filters.FirstOrDefault());
await ViewModel.ProductsDisplay.BindToGridAsync(dbBooks);
}
private void selectAndFocusSearchBox()
{
filterSearchTb.SelectAll();
filterSearchTb.Focus();
}
public void OnLibraryLoaded(List<LibraryBook> initialLibrary) => LibraryLoaded?.Invoke(this, initialLibrary);
public async Task OnLibraryLoadedAsync(List<LibraryBook> initialLibrary)
{
if (QuickFilters.UseDefault)
await ViewModel.PerformFilter(QuickFilters.Filters.FirstOrDefault());
await Task.WhenAll(
ViewModel.SetBackupCountsAsync(initialLibrary),
ViewModel.ProductsDisplay.BindToGridAsync(initialLibrary));
}
public void ProductsDisplay_LiberateClicked(object _, LibraryBook libraryBook) => ViewModel.LiberateClicked(libraryBook);
public void ProductsDisplay_LiberateSeriesClicked(object _, ISeriesEntry series) => ViewModel.LiberateSeriesClicked(series);
public void ProductsDisplay_ConvertToMp3Clicked(object _, LibraryBook libraryBook) => ViewModel.ConvertToMp3Clicked(libraryBook);

View File

@@ -6,7 +6,7 @@
xmlns:views="clr-namespace:LibationAvalonia.Views"
xmlns:viewModels="clr-namespace:LibationAvalonia.ViewModels"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="850"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="650"
x:Class="LibationAvalonia.Views.ProcessQueueControl">
<UserControl.Resources>
@@ -39,7 +39,8 @@
<ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto">
VerticalScrollBarVisibility="Auto"
AllowAutoHide="False">
<ItemsRepeater IsVisible="True"
Grid.Column="0"
Name="repeater"

View File

@@ -50,7 +50,7 @@ namespace LibationFileManager
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
Configuration.Instance.Books = Configuration.DefaultBooksDirectory;
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
}
}

View File

@@ -14,8 +14,12 @@ namespace LibationFileManager
public static string AppDir_Relative => $@".{Path.PathSeparator}{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(ProcessDirectory, LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
public static string MyMusic => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyMusic), "Libation"));
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
public static string LocalAppData => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"));
public static string DefaultLibationFilesDirectory => !IsWindows ? LocalAppData : UserProfile;
public static string DefaultBooksDirectory => Path.Combine(!IsWindows ? MyMusic : UserProfile, nameof(Books));
public enum KnownDirectories
{
@@ -34,19 +38,27 @@ namespace LibationFileManager
MyDocs = 4,
[Description("Your settings folder (aka: Libation Files)")]
LibationFiles = 5
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string?> getPathFunc)> directoryOptionsPaths { get; } = new()
LibationFiles = 5,
[Description("User Application Data Folder")]
ApplicationData = 6,
[Description("My Music")]
MyMusic = 7,
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string?> getPathFunc)> directoryOptionsPaths { get; } = new()
{
(KnownDirectories.None, () => null),
(KnownDirectories.ApplicationData, () => LocalAppData),
(KnownDirectories.MyMusic, () => MyMusic),
(KnownDirectories.UserProfile, () => UserProfile),
(KnownDirectories.AppDir, () => AppDir_Relative),
(KnownDirectories.WinTemp, () => WinTemp),
(KnownDirectories.MyDocs, () => MyDocs),
// this is important to not let very early calls try to accidentally load LibationFiles too early.
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
(KnownDirectories.LibationFiles, () => LibationSettingsDirectory)
};
public static string? GetKnownDirectoryPath(KnownDirectories directory)
{

View File

@@ -22,11 +22,11 @@ namespace LibationFileManager
{
get
{
if (libationFilesPathCache is not null)
return libationFilesPathCache;
if (LibationSettingsDirectory is not null)
return LibationSettingsDirectory;
// FIRST: must write here before SettingsFilePath in next step reads cache
libationFilesPathCache = getLibationFilesSettingFromJson();
LibationSettingsDirectory = getLibationFilesSettingFromJson();
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
@@ -42,11 +42,14 @@ namespace LibationFileManager
SetWithJsonPath(jsonpath, "path", logPath, true);
return libationFilesPathCache;
return LibationSettingsDirectory;
}
}
private static string? libationFilesPathCache { get; set; }
/// <summary>
/// Directory pointed to by appsettings.json
/// </summary>
private static string? LibationSettingsDirectory { get; set; }
/// <summary>
/// Try to find appsettings.json in the following locations:
@@ -79,7 +82,7 @@ namespace LibationFileManager
string[] possibleAppsettingsDirectories = new[]
{
ProcessDirectory,
Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"),
LocalAppData,
UserProfile,
Path.Combine(Path.GetTempPath(), "Libation")
};
@@ -106,9 +109,15 @@ namespace LibationFileManager
}
//Valid appsettings.json not found. Try to create it in each folder.
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented);
var endingContents = new JObject { { LIBATION_FILES_KEY, DefaultLibationFilesDirectory } }.ToString(Formatting.Indented);
foreach (var dir in possibleAppsettingsDirectories)
{
//Don't try to create appsettings.json in the program files directory on *.nix systems.
//However, still _look_ for one there for backwards compatibility with previous installations
if (!IsWindows && dir == ProcessDirectory)
continue;
var appsettingsFile = Path.Combine(dir, appsettings_filename);
try
@@ -180,7 +189,7 @@ namespace LibationFileManager
public static void SetLibationFiles(string directory)
{
libationFilesPathCache = null;
LibationSettingsDirectory = null;
var startingContents = File.ReadAllText(AppsettingsJsonFile);
var jObj = JObject.Parse(startingContents);

View File

@@ -18,9 +18,8 @@ namespace LibationFileManager
var pDic = new PersistentDictionary(settingsFile, isReadOnly: false);
var booksDir = pDic.GetString(nameof(Books));
if (booksDir is null) return false;
if (pDic.GetString(nameof(Books)) is not string booksDir)
return false;
if (!Directory.Exists(booksDir))
{
@@ -28,17 +27,21 @@ namespace LibationFileManager
throw new DirectoryNotFoundException(settingsFile);
//"Books" is not null, so setup has already been run.
//Since Books can't be found, try to create it in Libation settings folder
booksDir = Path.Combine(dir, nameof(Books));
try
//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 })
{
Directory.CreateDirectory(booksDir);
try
{
Directory.CreateDirectory(d);
pDic.SetString(nameof(Books), booksDir);
pDic.SetString(nameof(Books), d);
return booksDir is not null && Directory.Exists(booksDir);
return Directory.Exists(d);
}
catch { /* Do Nothing */ }
}
catch { return false; }
return false;
}
return true;

View File

@@ -1,8 +1,8 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core.Collections.Immutable;
using FileManager;
using Newtonsoft.Json;
@@ -13,78 +13,96 @@ namespace LibationFileManager
{
public record CacheEntry(string Id, FileType FileType, LongPath Path);
private const string FILENAME = "FileLocations.json";
private const string FILENAME_V2 = "FileLocationsV2.json";
public static event EventHandler<CacheEntry>? Inserted;
public static event EventHandler<CacheEntry>? Removed;
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
private static LongPath jsonFileV2 => Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
private static LongPath jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
private static readonly FileCacheV2<CacheEntry> Cache = new();
static FilePathCache()
{
static FilePathCache()
{
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (!File.Exists(jsonFile))
if (!File.Exists(jsonFileV2))
return;
try
{
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
if (list is null)
throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy.");
cache = new Cache<CacheEntry>(list);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFile });
lock (locker)
File.Delete(jsonFile);
return;
}
}
public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null;
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
=> getEntries(entry => entry.Id == id)
.Select(entry => (entry.FileType, entry.Path))
.ToList();
public static LongPath? GetFirstPath(string id, FileType type)
=> getEntries(entry => entry.Id == id && entry.FileType == type)
?.FirstOrDefault()
?.Path;
private static IEnumerable<CacheEntry> getEntries(Func<CacheEntry, bool> predicate)
{
var entries = cache.Where(predicate).ToList();
if (entries is null || !entries.Any())
return Enumerable.Empty<CacheEntry>();
remove(entries.Where(e => !File.Exists(e.Path)).ToList());
return cache.Where(predicate).ToList();
}
private static void remove(List<CacheEntry> entries)
{
if (entries is null)
return;
lock (locker)
try
{
foreach (var entry in entries)
{
cache.Remove(entry);
Removed?.Invoke(null, entry);
}
save();
Cache = JsonConvert.DeserializeObject<FileCacheV2<CacheEntry>>(File.ReadAllText(jsonFileV2))
?? throw new NullReferenceException("File exists but deserialize is null. This will never happen when file is healthy.");
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error deserializing file. Wrong format. Possibly corrupt. Deleting file. {@DebugInfo}", new { jsonFileV2 });
lock (locker)
File.Delete(jsonFileV2);
return;
}
}
public static void Insert(string id, string path)
public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null;
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
{
var matchingFiles = Cache.GetIdEntries(id);
bool cacheChanged = false;
//Verify all entries exist
for (int i = 0; i < matchingFiles.Count; i++)
{
if (!File.Exists(matchingFiles[i].Path))
{
matchingFiles.RemoveAt(i);
cacheChanged |= Remove(matchingFiles[i]);
}
}
if (cacheChanged)
save();
return matchingFiles.Select(e => (e.FileType, e.Path)).ToList();
}
public static LongPath? GetFirstPath(string id, FileType type)
{
var matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList();
bool cacheChanged = false;
try
{
//Verify entries exist, but return first matching 'type'
for (int i = 0; i < matchingFiles.Count; i++)
{
if (File.Exists(matchingFiles[i].Path))
return matchingFiles[i].Path;
else
{
matchingFiles.RemoveAt(i);
cacheChanged |= Remove(matchingFiles[i]);
}
}
return null;
}
finally
{
if (cacheChanged)
save();
}
}
private static bool Remove(CacheEntry entry)
{
if (Cache.Remove(entry.Id, entry))
{
Removed?.Invoke(null, entry);
return true;
}
return false;
}
public static void Insert(string id, string path)
{
var type = FileTypes.GetFileTypeFromPath(path);
Insert(new CacheEntry(id, type, path));
@@ -92,7 +110,7 @@ namespace LibationFileManager
public static void Insert(CacheEntry entry)
{
cache.Add(entry);
Cache.Add(entry.Id, entry);
Inserted?.Invoke(null, entry);
save();
}
@@ -102,7 +120,7 @@ namespace LibationFileManager
private static void save()
{
// create json if not exists
static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
static void resave() => File.WriteAllText(jsonFileV2, JsonConvert.SerializeObject(Cache, Formatting.Indented));
lock (locker)
{
@@ -112,11 +130,41 @@ namespace LibationFileManager
try { resave(); }
catch (IOException ex)
{
Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME}");
Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME_V2}");
throw;
}
}
}
}
}
}
private class FileCacheV2<TEntry>
{
[JsonProperty]
private readonly ConcurrentDictionary<string, List<TEntry>> Dictionary = new();
public List<TEntry> GetIdEntries(string id)
{
static List<TEntry> empty() => new();
return Dictionary.TryGetValue(id, out var entries) ? entries.ToList() : empty();
}
public void Add(string id, TEntry entry)
{
Dictionary.AddOrUpdate(id, [entry], (id, entries) => { entries.Add(entry); return entries; });
}
public void AddRange(string id, IEnumerable<TEntry> entries)
{
Dictionary.AddOrUpdate(id, entries.ToList(), (id, entries) =>
{
entries.AddRange(entries);
return entries;
});
}
public bool Remove(string id, TEntry entry)
=> Dictionary.TryGetValue(id, out List<TEntry>? entries) && entries.Remove(entry);
}
}
}

View File

@@ -11,6 +11,7 @@ namespace LibationFileManager
{
private static Dictionary<string, FileType> dic => new()
{
["aax"] = FileType.AAXC,
["aaxc"] = FileType.AAXC,
["cue"] = FileType.Cue,
["pdf"] = FileType.PDF,

View File

@@ -24,14 +24,14 @@ namespace LibationFileManager
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
public static FilterState InMemoryState { get; set; } = null!;
public static FilterState? InMemoryState { get; set; }
public static bool UseDefault
{
get => InMemoryState.UseDefault;
get => InMemoryState?.UseDefault ?? false;
set
{
if (UseDefault == value)
if (InMemoryState is null || UseDefault == value)
return;
lock (locker)
@@ -52,7 +52,8 @@ namespace LibationFileManager
public string? Name { get; set; } = Name;
}
public static IEnumerable<NamedFilter> Filters => InMemoryState.Filters.AsReadOnly();
public static IEnumerable<NamedFilter> Filters
=> InMemoryState?.Filters.AsReadOnly() ?? Enumerable.Empty<NamedFilter>();
public static void Add(NamedFilter namedFilter)
{
@@ -64,10 +65,11 @@ namespace LibationFileManager
namedFilter.Filter = namedFilter.Filter?.Trim() ?? string.Empty;
namedFilter.Name = namedFilter.Name?.Trim() ?? null;
lock (locker)
{
// check for duplicates
if (InMemoryState.Filters.Select(x => x.Filter).ContainsInsensative(namedFilter.Filter))
lock (locker)
{
InMemoryState ??= new();
// check for duplicates
if (InMemoryState.Filters.Select(x => x.Filter).ContainsInsensative(namedFilter.Filter))
return;
InMemoryState.Filters.Add(namedFilter);
@@ -79,6 +81,8 @@ namespace LibationFileManager
{
lock (locker)
{
if (InMemoryState is null)
return;
InMemoryState.Filters.Remove(filter);
save();
}
@@ -88,8 +92,7 @@ namespace LibationFileManager
{
lock (locker)
{
var index = InMemoryState.Filters.IndexOf(oldFilter);
if (index < 0)
if (InMemoryState is null || InMemoryState.Filters.IndexOf(oldFilter) < 0)
return;
InMemoryState.Filters = InMemoryState.Filters.Select(f => f == oldFilter ? newFilter : f).ToList();
@@ -107,7 +110,8 @@ namespace LibationFileManager
filter.Filter = filter.Filter.Trim();
lock (locker)
{
InMemoryState.Filters = new List<NamedFilter>(filters);
InMemoryState ??= new();
InMemoryState.Filters = new List<NamedFilter>(filters);
save();
}
}

View File

@@ -33,37 +33,25 @@ namespace LibationUiBase.GridView
LoadCover();
}
public static async Task<List<IGridEntry>> GetAllProductsAsync(IEnumerable<LibraryBook> libraryBooks)
{
var products = libraryBooks.Where(lb => lb.Book.IsProduct()).ToArray();
if (products.Length == 0)
return [];
int parallelism = int.Max(1, Environment.ProcessorCount - 1);
(int numPer, int rem) = int.DivRem(products.Length, parallelism);
if (rem != 0) numPer++;
(int batchSize, int rem) = int.DivRem(products.Length, parallelism);
if (rem != 0) batchSize++;
var tasks = new Task<IGridEntry[]>[parallelism];
var syncContext = SynchronizationContext.Current;
for (int i = 0; i < parallelism; i++)
//Asynchronously create an ILibraryBookEntry for every book in the library
var tasks = products.Chunk(batchSize).Select(batch => Task.Run(() =>
{
int start = i * numPer;
tasks[i] = Task.Run(() =>
{
SynchronizationContext.SetSynchronizationContext(syncContext);
int length = int.Min(numPer, products.Length - start);
if (length < 1) return Array.Empty<IGridEntry>();
var result = new IGridEntry[length];
for (int j = 0; j < length; j++)
result[j] = new LibraryBookEntry<TStatus>(products[start + j]);
return result;
});
}
SynchronizationContext.SetSynchronizationContext(syncContext);
return batch.Select(lb => new LibraryBookEntry<TStatus>(lb) as IGridEntry);
}));
return (await Task.WhenAll(tasks)).SelectMany(a => a).ToList();
}

View File

@@ -1,6 +1,5 @@
using DataLayer;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -62,53 +61,54 @@ namespace LibationUiBase.GridView
var seriesBooks = libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).ToArray();
var allEpisodes = libraryBooks.Where(lb => lb.Book.IsEpisodeChild()).ToArray();
int parallelism = int.Max(1, Environment.ProcessorCount - 1);
var tasks = new Task[parallelism];
var syncContext = SynchronizationContext.Current;
var q = new BlockingCollection<(int, LibraryBook episode)>();
var seriesEntries = new ISeriesEntry[seriesBooks.Length];
var seriesEpisodes = new ConcurrentBag<ILibraryBookEntry>[seriesBooks.Length];
var seriesEpisodes = new ILibraryBookEntry[seriesBooks.Length][];
for (int i = 0; i < parallelism; i++)
{
tasks[i] = Task.Run(() =>
{
SynchronizationContext.SetSynchronizationContext(syncContext);
var syncContext = SynchronizationContext.Current;
var options = new ParallelOptions { MaxDegreeOfParallelism = int.Max(1, Environment.ProcessorCount - 1) };
while (q.TryTake(out var entry, -1))
{
var parent = seriesEntries[entry.Item1];
var episodeBag = seriesEpisodes[entry.Item1];
episodeBag.Add(new LibraryBookEntry<TStatus>(entry.episode, parent));
}
});
}
//Asynchronously create an ILibraryBookEntry for every episode in the library
await Parallel.ForEachAsync(getAllEpisodes(), options, createEpisodeEntry);
for (int i = 0; i <seriesBooks.Length; i++)
{
var series = seriesBooks[i];
seriesEntries[i] = new SeriesEntry<TStatus>(series, Enumerable.Empty<LibraryBook>());
seriesEpisodes[i] = new ConcurrentBag<ILibraryBookEntry>();
foreach (var ep in allEpisodes.FindChildren(series))
q.Add((i, ep));
}
q.CompleteAdding();
await Task.WhenAll(tasks);
for (int i = 0; i < seriesBooks.Length; i++)
//Match all episode entries to their corresponding parents
for (int i = seriesEntries.Length - 1; i >= 0; i--)
{
var series = seriesEntries[i];
series.Children.AddRange(seriesEpisodes[i].OrderByDescending(c => c.SeriesOrder));
//Sort episodes by series order descending, then add them to their parent's entry
Array.Sort(seriesEpisodes[i], (a, b) => -a.SeriesOrder.CompareTo(b.SeriesOrder));
series.Children.AddRange(seriesEpisodes[i]);
series.UpdateLibraryBook(series.LibraryBook);
}
return seriesEntries.Where(s => s.Children.Count != 0).ToList();
return seriesEntries.Where(s => s.Children.Count != 0).Cast<ISeriesEntry>().ToList();
//Create a LibraryBookEntry for a single episode
ValueTask createEpisodeEntry((int seriesIndex, int episodeIndex, LibraryBook episode) data, CancellationToken cancellationToken)
{
SynchronizationContext.SetSynchronizationContext(syncContext);
var parent = seriesEntries[data.seriesIndex];
seriesEpisodes[data.seriesIndex][data.episodeIndex] = new LibraryBookEntry<TStatus>(data.episode, parent);
return ValueTask.CompletedTask;
}
//Enumeration all series episodes, along with the index to its seriesEntries entry
//and an index to its seriesEpisodes entry
IEnumerable<(int seriesIndex, int episodeIndex, LibraryBook episode)> getAllEpisodes()
{
for (int i = 0; i < seriesBooks.Length; i++)
{
var series = seriesBooks[i];
var childEpisodes = allEpisodes.FindChildren(series);
SynchronizationContext.SetSynchronizationContext(syncContext);
seriesEntries[i] = new SeriesEntry<TStatus>(series, []);
seriesEpisodes[i] = new ILibraryBookEntry[childEpisodes.Count];
for (int j = 0; j < childEpisodes.Count; j++)
yield return (i, j, childEpisodes[j]);
}
}
}
public void RemoveChild(ILibraryBookEntry lbe)

View File

@@ -169,6 +169,16 @@ namespace LibationUiBase
}
}
public T FirstOrDefault(Func<T, bool> predicate)
{
lock (lockObject)
{
return Current != null && predicate(Current) ? Current
: _completed.FirstOrDefault(predicate) is T completed ? completed
: _queued.FirstOrDefault(predicate);
}
}
public void MoveQueuePosition(T item, QueuePosition requestedPosition)
{
lock (lockObject)

View File

@@ -37,6 +37,7 @@ namespace LibationWinForms.Dialogs
inProgressSelectControl.SetDirectoryItems(new()
{
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.ApplicationData,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs,

View File

@@ -44,7 +44,8 @@ namespace LibationWinForms.Dialogs
{
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.MyMusic,
},
Configuration.KnownDirectories.UserProfile,
"Books");

View File

@@ -17,7 +17,9 @@ namespace LibationWinForms
beginPdfBackupsToolStripMenuItem.Format(0);
LibraryCommands.LibrarySizeChanged += setBackupCounts;
LibraryCommands.BookUserDefinedItemCommitted += setBackupCounts;
//Pass null to the runner to get the whole library.
LibraryCommands.BookUserDefinedItemCommitted += (_, _)
=> setBackupCounts(null, null);
updateCountsBw.DoWork += UpdateCountsBw_DoWork;
updateCountsBw.RunWorkerCompleted += exportMenuEnable;
@@ -28,12 +30,12 @@ namespace LibationWinForms
private bool runBackupCountsAgain;
private void setBackupCounts(object _, object __)
private void setBackupCounts(object _, List<LibraryBook> libraryBooks)
{
runBackupCountsAgain = true;
if (!updateCountsBw.IsBusy)
updateCountsBw.RunWorkerAsync();
updateCountsBw.RunWorkerAsync(libraryBooks);
}
private void UpdateCountsBw_DoWork(object sender, System.ComponentModel.DoWorkEventArgs e)
@@ -41,11 +43,7 @@ namespace LibationWinForms
while (runBackupCountsAgain)
{
runBackupCountsAgain = false;
if (e.Argument is not IEnumerable<LibraryBook> lbs)
lbs = DbContexts.GetLibrary_Flat_NoTracking();
e.Result = LibraryCommands.GetCounts(lbs);
e.Result = LibraryCommands.GetCounts(e.Argument as IEnumerable<LibraryBook>);
}
}

View File

@@ -105,13 +105,14 @@ namespace LibationWinForms
splitContainer1.Panel2Collapsed = false;
processBookQueue1.popoutBtn.Visible = true;
}
Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed));
toggleQueueHideBtn.Text = splitContainer1.Panel2Collapsed ? "❰❰❰" : "❱❱❱";
}
private void ToggleQueueHideBtn_Click(object sender, EventArgs e)
{
SetQueueCollapseState(!splitContainer1.Panel2Collapsed);
Configuration.Instance.SetNonString(splitContainer1.Panel2Collapsed, nameof(splitContainer1.Panel2Collapsed));
}
private void ProcessBookQueue1_PopOut(object sender, EventArgs e)

View File

@@ -17,7 +17,7 @@ namespace LibationWinForms
//Set this size before restoring form size and position
splitContainer1.Panel2MinSize = this.DpiScale(350);
this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
FormClosing += Form1_FormClosing;
// this looks like a perfect opportunity to refactor per below.
// since this loses design-time tooling and internal access, for now I'm opting for partial classes
@@ -52,11 +52,20 @@ namespace LibationWinForms
// Configure_Grid(); // since it's just this, can keep here. If it needs more, then give grid it's own 'partial class Form1'
{
LibraryCommands.LibrarySizeChanged += (_, __) => Invoke(() => productsDisplay.DisplayAsync());
LibraryCommands.LibrarySizeChanged += (object _, List<LibraryBook> fullLibrary)
=> Invoke(() => productsDisplay.DisplayAsync(fullLibrary));
}
Shown += Form1_Shown;
}
private void Form1_FormClosing(object sender, FormClosingEventArgs e)
{
//Always close the queue before saving the form to prevent
//Form1 from getting excessively wide when it's restored.
SetQueueCollapseState(true);
this.SaveSizeAndLocation(Configuration.Instance);
}
private async void Form1_Shown(object sender, EventArgs e)
{
if (Configuration.Instance.FirstLaunch)
@@ -75,7 +84,7 @@ namespace LibationWinForms
public async Task InitLibraryAsync(List<LibraryBook> libraryBooks)
{
runBackupCountsAgain = true;
updateCountsBw.RunWorkerAsync(libraryBooks.Where(b => !b.Book.IsEpisodeParent()));
updateCountsBw.RunWorkerAsync(libraryBooks);
await productsDisplay.DisplayAsync(libraryBooks);
}

View File

@@ -82,7 +82,15 @@ namespace LibationWinForms.ProcessQueue
}
private bool isBookInQueue(DataLayer.LibraryBook libraryBook)
=> Queue.Any(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
{
var entry = Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId);
if (entry == null)
return false;
else if (entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed)
return !Queue.RemoveCompleted(entry);
else
return true;
}
public void AddDownloadPdf(DataLayer.LibraryBook libraryBook)
=> AddDownloadPdf(new List<DataLayer.LibraryBook>() { libraryBook });

View File

@@ -98,7 +98,7 @@ namespace LibationWinForms
if (config.LibationSettingsAreValid)
return;
var defaultLibationFilesDir = Configuration.UserProfile;
var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
// check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
@@ -154,7 +154,7 @@ namespace LibationWinForms
// INIT DEFAULT SETTINGS
// if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog
config.Books ??= Path.Combine(defaultLibationFilesDir, "Books");
config.Books ??= Configuration.DefaultBooksDirectory;
if (config.LibationSettingsAreValid)
return;