Compare commits

...

81 Commits

Author SHA1 Message Date
Robert McRackan
a37eb383cd Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-03-10 19:11:22 -04:00
Robert McRackan
614965e1ab incr ver 2025-03-10 19:09:44 -04:00
rmcrackan
52d611a74c Merge pull request #1189 from Mbucari/master
Minor bug fixes.
2025-03-10 17:30:24 -04:00
Michael Bucari-Tovo
653381b1df Fix Auto download not working sometimes (#1183) 2025-03-10 12:57:52 -06:00
Michael Bucari-Tovo
4e067f5b5b Remove inadvertently committed debugging code 2025-03-10 10:46:36 -06:00
Michael Bucari-Tovo
ee05ca4eb2 Handle corrupted LibraryScans.zip file (#1185) 2025-03-10 09:49:22 -06:00
Michael Bucari-Tovo
65e12d9a8f Add default window dimensions 2025-03-10 09:07:35 -06:00
rmcrackan
5dc1bafb94 Update Docker.md 2025-03-07 10:12:05 -05:00
rmcrackan
3010f80834 Merge pull request #1181 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.7
Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
2025-03-06 20:49:34 -05:00
dependabot[bot]
6c20b85200 Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-06 22:29:43 +00:00
rmcrackan
bf87180fe9 Merge pull request #1179 from Mbucari/master
UI tweak and Linux command updates
2025-03-05 17:41:47 -05:00
Michael Bucari-Tovo
ae9aac789f Use polkit (#1176) and dnf5/dnf (#1177) 2025-03-05 14:22:51 -07:00
Mbucari
e6cd182872 Merge branch 'rmcrackan:master' into master 2025-03-05 11:25:50 -07:00
Michael Bucari-Tovo
7eeb2dcd7f Move encoding options above to the mp3 settings column (#764)
Additionally, add more tool tips for cryptic options.
2025-03-05 11:17:47 -07:00
rmcrackan
71bb0571d1 Merge pull request #1178 from RokeJulianLockhart/patch-1
Add instructions for Fedora to `InstallOnLinux.md`.
2025-03-05 11:32:10 -05:00
Mr. Beedell, Roke Julian Lockhart
7bb5b2968e Add instructions for Fedora to InstallOnLinux.md
Partially improves upon https://github.com/rmcrackan/Libation/issues/1177#issue-2897597653.
2025-03-05 15:17:49 +00:00
rmcrackan
b051283fca Update bug_report.md 2025-03-05 09:30:43 -05:00
rmcrackan
53af2ee39e Update feature_request.md 2025-03-05 09:30:18 -05:00
rmcrackan
fab32a1744 Update bug_report.md 2025-03-05 09:29:04 -05:00
rmcrackan
e2dabc8a53 Update feature_request.md 2025-03-05 09:26:47 -05:00
rmcrackan
fd82f7ae5a Update feature_request.md 2025-03-05 09:26:19 -05:00
rmcrackan
df9535a83d Update FrequentlyAskedQuestions.md 2025-03-05 08:20:42 -05:00
rmcrackan
85b6792468 Merge pull request #1175 from Mbucari/master
Additional null safety
2025-03-04 21:32:35 -05:00
Michael Bucari-Tovo
e37abbf276 Fix dark theme text color in DataGridTextColumn 2025-03-04 16:18:06 -07:00
Michael Bucari-Tovo
c3938c49a9 Additional null safety 2025-03-04 15:41:26 -07:00
Michael Bucari-Tovo
7658f21d7c Fix tags font color in dark mode 2025-03-04 15:07:37 -07:00
Michael Bucari-Tovo
c4827fc761 Add error logging 2025-03-04 12:54:05 -07:00
rmcrackan
649b52af1d Merge pull request #1174 from Mbucari/master
Null safety & UI tweak
2025-03-04 13:10:51 -05:00
Michael Bucari-Tovo
da06511951 Only show buttons on mouse over 2025-03-04 10:34:09 -07:00
Michael Bucari-Tovo
88d3e5ff0c Null safety checks. 2025-03-04 10:33:29 -07:00
rmcrackan
5f99e594d8 Merge pull request #1172 from Mbucari/master
Thread safety and AccountSettings.json error handling
2025-03-03 19:27:45 -05:00
Michael Bucari-Tovo
981a183992 Add a border around dialogs with CanResize=true 2025-03-03 15:20:58 -07:00
Michael Bucari-Tovo
ac036f65f1 Handle and notify users of invalid account settings file 2025-03-03 14:41:56 -07:00
Michael Bucari-Tovo
b36e110b49 Add thread safety and comments re threading 2025-03-03 10:11:17 -07:00
Robert McRackan
ef3c71a939 Incr ver 2025-03-02 20:34:45 -05:00
rmcrackan
b2af93bed9 Merge pull request #1169 from Mbucari/master
12.0.1 Bug Fixes
2025-03-02 20:33:59 -05:00
Mbucari
1f427919e6 Try to solve threadding issues (#1168) 2025-03-02 11:59:35 -07:00
Mbucari
c9c5bbb687 Fix errorre rmoving entries from the cache (#1167) 2025-03-02 10:55:26 -07:00
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
Robert McRackan
e3b0f80016 incr ver: 12.0 2025-02-24 12:05:24 -05:00
Robert McRackan
7b2c7e49e5 fix unit tests 2025-02-23 11:11:25 -05:00
Robert McRackan
6a40d19393 Fix unit tests 2025-02-23 10:28:09 -05:00
Robert McRackan
3167744111 #1151 : default character replacement for colon is _ 2025-02-23 10:13:15 -05:00
Robert McRackan
b6a3ba335a Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-22 22:54:12 -05:00
Robert McRackan
fa1ddc726a #1151 - unicode colon is causing issues with Windows Chardonnay 2025-02-22 22:54:00 -05:00
rmcrackan
b3b0662dec Update InstallOnMac.md
Apple can’t check app for malicious software
2025-02-19 17:09:26 -05:00
Robert McRackan
5cb22cfd24 Update AAXClean.Codecs 2025-02-17 20:07:50 -05:00
Michael Bucari-Tovo
e911344850 Workaround for DataGridView filtering internal NullReferenceException 2025-02-10 10:20:18 -07:00
Michael Bucari-Tovo
8ec7e5a9d2 Revert "DataGridView filtering internal NullReferenceException **HACK**"
This reverts commit e1f749c3da.
2025-02-10 10:19:24 -07:00
Robert McRackan
e1f749c3da DataGridView filtering internal NullReferenceException **HACK** 2025-02-10 11:23:27 -05:00
Robert McRackan
ba060d15aa Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-10 09:12:32 -05:00
Robert McRackan
93fde236c8 format for clarity 2025-02-10 09:12:29 -05:00
Michael Bucari-Tovo
13aad1a7cb Restrict audio sample rate settings to allowed values (#1116) 2025-01-16 10:35:58 -07:00
rmcrackan
65c64c4504 Update Docker.md 2025-01-13 09:28:55 -05:00
rmcrackan
14ba04c28b Update Docker.md 2025-01-13 09:26:52 -05:00
rmcrackan
96e886d207 Update FrequentlyAskedQuestions.md
Brazil login
2025-01-08 10:04:55 -05:00
Robert McRackan
c7279574a9 Upgrade avalonia 2025-01-07 08:00:35 -05:00
rmcrackan
a522e1ff7e Merge pull request #1072 from shuvashish76/patch-1
Update InstallOnLinux.md with repology badge
2024-12-08 08:56:32 -05:00
shuvashish76
9ebc4444bd Update InstallOnLinux.md 2024-12-07 06:24:34 +00:00
shuvashish76
525afdf050 Update InstallOnLinux.md 2024-12-07 06:03:46 +00:00
shuvashish76
983cdf6ad5 Update InstallOnLinux.md with repology badge 2024-12-07 05:38:35 +00:00
Robert McRackan
09bb32a435 incr ver 2024-12-06 13:10:37 -05:00
rmcrackan
817ef33fbd Merge pull request #1071 from pixil98/master
update docker image to dotnet 9
2024-12-06 13:07:39 -05:00
Aaron Reisman
be52b496a6 Update docker image to .net 9 2024-12-06 10:29:47 -06:00
107 changed files with 2078 additions and 1155 deletions

View File

@@ -6,10 +6,10 @@ labels: bug
assignees: ''
---
**Describe the bug**
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
@@ -17,15 +17,14 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Platform**
**Platform**
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here.
**Log Files**
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'

View File

@@ -6,14 +6,26 @@ labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
**No-go ideas**
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
* comprehensive api/cli
* aax/audiobook import
* bulk rename of existing files
* general metadata/tag editor
* playback features
* web gui
* supporting non-audible vendors
* official docker support
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
**Additional context**
Add any other context or screenshots about the feature request here.

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

@@ -1,5 +1,5 @@
# Dockerfile
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:8.0 AS build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
ARG TARGETARCH
COPY Source /Source
@@ -10,7 +10,7 @@ RUN dotnet publish \
--output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
FROM mcr.microsoft.com/dotnet/runtime:8.0
FROM mcr.microsoft.com/dotnet/runtime:9.0
ARG USER_UID=1001
ARG USER_GID=1001

View File

@@ -5,7 +5,7 @@
> [!WARNING]
> ## Breaking Changes
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them.
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
@@ -49,7 +49,9 @@ sudo docker run -d \
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
### User
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
@@ -63,5 +65,12 @@ sudo docker run -d \
rmcrackan/libation:latest
```
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
### Advanced Database Options
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
### Getting help
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.

View File

@@ -31,6 +31,10 @@ Self-hosting online:
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
## Q: I'm having trouble loggin into my Brazil account.
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
## Q: How do I use Libation with a South Africa account?
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.

View File

@@ -4,25 +4,61 @@
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Packaging status
# Install and Run Libation on Ubuntu
New Libation releases are automatically packed into .deb and .rpm package and are available from the Libation repository's releases page.
[![Packaging status](https://repology.org/badge/vertical-allrepos/libation.svg)](https://repology.org/project/libation/versions)
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the Libation repository's releases page.
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
- Debian
### Debian
```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
sudo apt install ./libation.deb
```
- Redhat and CentOS
### Redhat and CentOS
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
sudo yum install ./libation.rpm
```
### Fedora
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
sudo dnf5 install ./libation.rpm
```
---
### Arch Linux
```Console
yay -S libation
```
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
### NixOS
- Install via `nix-shell`
```Console
nix-shell -p libation
```
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
- Install via NixOS configuration
```Console
environment.systemPackages = [
pkgs.libation
];
```
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
- On NixOS via via `nix-env`
```Console
nix-env -iA nixos.libation
```
- On Non NixOS via `nix-env`
```Console
nix-env -iA nixpkgs.libation
```
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
If your desktop uses gtk, you should now see Libation among your applications.

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
@@ -40,6 +40,15 @@ You can add Libation as a safe app without touching Gatekeeper.
* Close the terminal and use Libation!
## "Apple can't check app for malicious software"
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
* Go to Security, then click Open.
* Click Open Anyway. This button is available for about an hour after you try to open the app.
* Enter your login password, then click OK.
## Troubleshooting
If Libation fails to start after completing the above steps, try the following:

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="1.1.3" />
<PackageReference Include="AAXClean.Codecs" Version="1.1.4" />
</ItemGroup>
<ItemGroup>

View File

@@ -2,10 +2,10 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>11.6.2.1</Version>
<Version>12.0.3.2</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="13.0.1" />
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks -->
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />

View File

@@ -89,7 +89,9 @@ 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>
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
@@ -278,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()
@@ -413,7 +418,92 @@ namespace AppScaffolding
public List<string> Filters { get; set; } = new();
}
public static void migrate_to_v11_5_0(Configuration config)
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)
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
}
public static void migrate_to_v11_5_0(Configuration config)
{
// Read file, but convert old format to new (with Name field) as necessary.
if (!File.Exists(QuickFilters.JsonFile))

View File

@@ -15,12 +15,13 @@ using Newtonsoft.Json.Linq;
using Serilog;
using static DtoImporterService.PerfLogger;
#nullable enable
namespace ApplicationServices
{
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler<int> ScanEnd;
public static event EventHandler<int>? ScanBegin;
public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
@@ -100,7 +101,7 @@ namespace ApplicationServices
}
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[]? accounts)
{
logRestart();
@@ -222,7 +223,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)
@@ -232,13 +233,42 @@ namespace ApplicationServices
}
}
private static LogArchiver? openLogArchive(string? archivePath)
{
if (string.IsNullOrWhiteSpace(archivePath))
return null;
try
{
return new LogArchiver(archivePath);
}
catch (System.IO.InvalidDataException)
{
try
{
Log.Logger.Warning($"Deleting corrupted {nameof(LogArchiver)} at {archivePath}");
FileUtility.SaferDelete(archivePath);
return new LogArchiver(archivePath);
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
await using LogArchiver archiver
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);
@@ -266,13 +296,13 @@ namespace ApplicationServices
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver)
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
Account = account.MaskedLogEntry ?? "[null]"
});
logTime($"pre scanAccountAsync {account.AccountName}");
@@ -294,7 +324,7 @@ namespace ApplicationServices
throw new AggregateException(ex.InnerExceptions);
}
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null)
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception>? exceptions = null)
{
if (archiver is not null)
{
@@ -329,7 +359,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 +399,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 +428,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 +462,7 @@ namespace ApplicationServices
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
finalizeLibrarySizeChange(context);
return qtyChanges;
}
@@ -445,31 +475,35 @@ 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.
/// </summary>
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted;
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(
this LibraryBook lb,
string tags = null,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
Rating? rating = null)
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem(
this IEnumerable<LibraryBook> lb,
string tags = null,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
Rating? rating = null)
=> updateUserDefinedItem(
lb,
udi => {
@@ -518,17 +552,19 @@ 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;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
@@ -593,13 +629,15 @@ namespace ApplicationServices
return sb.ToString();
}
}
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
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

@@ -5,19 +5,58 @@ using Newtonsoft.Json;
namespace AudibleUtilities
{
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
{
/// <summary>
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// The file path of the AccountsSettings.json file
/// </summary>
public string SettingsFilePath { get; }
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
: base(exception)
{
SettingsFilePath = path;
}
}
public static class AudibleApiStorage
{
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
public static void EnsureAccountsSettingsFileExists()
{
// saves. BEWARE: this will overwrite an existing file
if (!File.Exists(AccountsSettingsFile))
_ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile);
{
//Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved
//are not fired. There's no need to fire those events on an empty AccountsSettings file.
var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings();
File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings));
}
}
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile);
public static AccountsSettingsPersister GetAccountsSettingsPersister()
{
try
{
return new AccountsSettingsPersister(AccountsSettingsFile);
}
catch (Exception ex)
{
var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex);
LoadError?.Invoke(null, args);
if (args.Handled)
return GetAccountsSettingsPersister();
throw;
}
}
public static string GetIdentityTokensJsonPath(this Account account)
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.3.0.1" />
<PackageReference Include="AudibleApi" Version="9.3.1.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -10,14 +10,14 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

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

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
<PackageReference Include="Polly" Version="8.5.0" />
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Polly" Version="8.5.2" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -157,7 +157,7 @@ namespace FileManager
/// <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(
public static LongPath SaferMoveToValidPath(
LongPath source,
LongPath destination,
ReplacementCharacters replacements,

View File

@@ -88,7 +88,7 @@ namespace FileManager
Replacement.OtherQuote(""),
Replacement.OpenAngleBracket(""),
Replacement.CloseAngleBracket(""),
Replacement.Colon(""),
Replacement.Colon("_"),
Replacement.Asterisk("✱"),
Replacement.QuestionMark(""),
Replacement.Pipe("⏐"),

View File

@@ -71,13 +71,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.1" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.1" />
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.1" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.1" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.2.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.1" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

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,13 @@
<Application.Resources>
<ResourceDictionary>
<ControlTheme x:Key="{x:Type TextBlock}" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
<Setter Property="Background" Value="Transparent" />
</ControlTheme>
<ControlTheme x:Key="{x:Type DataGridCell}" TargetType="DataGridCell" BasedOn="{StaticResource {x:Type DataGridCell}}">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
</ControlTheme>
<ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" />
@@ -81,6 +89,63 @@
<!-- 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]">
<Style Selector="^[CanResize=false] Border#DialogWindowFormBorder">
<Setter Property="BorderThickness" Value="2" />
</Style>
<Setter Property="SystemDecorations" Value="BorderOnly"/>
<Setter Property="Template">
<ControlTemplate>
<Border Name="DialogWindowFormBorder" BorderBrush="{DynamicResource SystemBaseMediumLowColor}" 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>
</Border>
</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

@@ -6,6 +6,7 @@ using LibationAvalonia.Dialogs;
using LibationFileManager;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia
{
internal static class AvaloniaUtils
@@ -14,18 +15,18 @@ namespace LibationAvalonia
=> GetBrushFromResources(name, Brushes.Transparent);
public static IBrush GetBrushFromResources(string name, IBrush defaultBrush)
{
if (App.Current.TryGetResource(name, App.Current.ActualThemeVariant, out var value) && value is IBrush brush)
if ((App.Current?.TryGetResource(name, App.Current.ActualThemeVariant, out var value) ?? false) && value is IBrush brush)
return brush;
return defaultBrush;
}
public static Task<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window owner = null)
public static Task<DialogResult> ShowDialogAsync(this DialogWindow dialogWindow, Window? owner = null)
=> dialogWindow.ShowDialog<DialogResult>(owner ?? App.MainWindow);
public static Window GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
public static Window? GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
private static Bitmap defaultImage;
private static Bitmap? defaultImage;
public static Bitmap TryLoadImageOrDefault(byte[] picture, PictureSize defaultSize = PictureSize.Native)
{
try

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

@@ -2,15 +2,15 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="650"
mc:Ignorable="d" d:DesignWidth="750" d:DesignHeight="700"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudioSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Audio">
<Grid
Margin="5"
RowDefinitions="Auto,*,Auto"
RowDefinitions="Auto,Auto"
ColumnDefinitions="*,*">
<Grid.Styles>
@@ -28,9 +28,12 @@
</Style>
</Grid.Styles>
<!--Left Column-->
<StackPanel
Grid.Row="0"
Grid.Column="0">
Grid.Column="0"
Margin="0,0,10,0"
>
<Grid ColumnDefinitions="*,Auto">
<TextBlock
@@ -65,11 +68,15 @@
SelectedItem="{CompiledBinding ClipBookmarkFormat}"/>
</Grid>
<CheckBox IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}">
<CheckBox
IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding RetainAaxFileTip}">
<TextBlock Text="{CompiledBinding RetainAaxFileText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}">
<CheckBox
IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MergeOpeningAndEndCreditsTip}">
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
</CheckBox>
@@ -84,137 +91,214 @@
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
</CheckBox>
</StackPanel>
<controls:GroupBox
Grid.Row="1"
Label="Audiobook Fix-ups"
IsEnabled="{CompiledBinding AllowLibationFixup}">
<controls:GroupBox
Grid.Row="1"
Label="Audiobook Fix-ups"
IsEnabled="{CompiledBinding AllowLibationFixup}">
<StackPanel Orientation="Vertical">
<StackPanel Orientation="Vertical">
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
</CheckBox>
<RadioButton IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}">
<StackPanel VerticalAlignment="Center">
<TextBlock
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
IsEnabled="{CompiledBinding !DecryptToLossy}"
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<RadioButton IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books as .MP3 files (transcode if necessary)" />
</RadioButton>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox
Grid.Column="1"
Grid.RowSpan="2"
Margin="10,0,0,0"
Label="Mp3 Encoding Options">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<Grid
Margin="0,5"
ColumnDefinitions="Auto,*">
<controls:GroupBox
Grid.Column="0"
Label="Target">
<Grid ColumnDefinitions="Auto,Auto">
<RadioButton
Margin="5"
Content="Bitrate"
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
<RadioButton
Grid.Column="1"
Margin="5"
Content="Quality"
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
</Grid>
</controls:GroupBox>
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
</CheckBox>
<CheckBox
HorizontalAlignment="Right"
Grid.Column="1"
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Downsample to mono? (Recommended)" />
IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripAudibleBrandAudioTip}">
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
</CheckBox>
</Grid>
<CheckBox
IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripUnabridgedTip}">
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
</CheckBox>
</StackPanel>
</controls:GroupBox>
</StackPanel>
<!--Right Column-->
<StackPanel
Grid.Row="0"
Grid.Column="1"
Margin="10,0,0,0">
<RadioButton
IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
<Grid Grid.Row="1" Margin="0,5" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<StackPanel VerticalAlignment="Center">
<TextBlock
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
IsEnabled="{CompiledBinding !DecryptToLossy}"
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MoveMoovToBeginningTip}">
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding SampleRates}"
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
<RadioButton
IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books as .MP3 files (transcode if necessary)" />
</RadioButton>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
<controls:GroupBox
Grid.Column="1"
IsEnabled="{CompiledBinding DecryptToLossy}"
Label="Mp3 Encoding Options">
<controls:WheelComboBox
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding EncoderQualities}"
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<controls:GroupBox
Grid.Row="2"
Margin="0,5"
Label="Bitrate"
IsEnabled="{CompiledBinding LameTargetBitrate}" >
<Grid
Margin="0,5"
ColumnDefinitions="Auto,*">
<StackPanel>
<Grid ColumnDefinitions="*,25,Auto">
<controls:GroupBox
Grid.Column="0"
Label="Target">
<Grid ColumnDefinitions="Auto,Auto">
<RadioButton
Margin="5"
Content="Bitrate"
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
<RadioButton
Grid.Column="1"
Margin="5"
Content="Quality"
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
</Grid>
</controls:GroupBox>
<CheckBox
HorizontalAlignment="Right"
Grid.Column="1"
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding LameDownsampleMonoTip}">
<TextBlock
TextWrapping="Wrap"
Text="Downsample to mono? (Recommended)" />
</CheckBox>
</Grid>
<Grid Grid.Row="1" Margin="0,5" RowDefinitions="Auto,Auto" ColumnDefinitions="Auto,*,Auto">
<TextBlock Margin="0,0,0,5" Text="Max audio sample rate:" />
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding SampleRates}"
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
<controls:WheelComboBox
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding EncoderQualities}"
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<controls:GroupBox
Grid.Row="2"
Margin="0,5"
Label="Bitrate"
IsEnabled="{CompiledBinding LameTargetBitrate}" >
<StackPanel>
<Grid ColumnDefinitions="*,25,Auto">
<Slider
Grid.Column="0"
IsEnabled="{CompiledBinding !LameMatchSource}"
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
Minimum="16"
Maximum="320"
IsSnapToTickEnabled="True" TickFrequency="16"
Ticks="16,32,48,64,80,96,112,128,144,160,176,192,208,224,240,256,272,288,304,320"
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
</Style>
</Slider.Styles>
</Slider>
<TextBlock
Grid.Column="1"
HorizontalAlignment="Right"
Text="{CompiledBinding LameBitrate}" />
<TextBlock
Grid.Column="2"
Text=" Kbps" />
</Grid>
<Grid ColumnDefinitions="*,*">
<CheckBox
Grid.Column="0"
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Restrict Encoder to Constant Bitrate?" />
</CheckBox>
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Match Source Bitrate?" />
</CheckBox>
</Grid>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox
Grid.Row="3"
Margin="0,5"
Label="Quality"
IsEnabled="{CompiledBinding !LameTargetBitrate}">
<Grid
ColumnDefinitions="*,Auto,25"
RowDefinitions="*,Auto">
<Slider
Grid.Column="0"
IsEnabled="{CompiledBinding !LameMatchSource}"
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
Minimum="16"
Maximum="320"
IsSnapToTickEnabled="True" TickFrequency="16"
Ticks="16,32,48,64,80,96,112,128,144,160,176,192,208,224,240,256,272,288,304,320"
Grid.ColumnSpan="2"
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
Minimum="0"
Maximum="9"
IsSnapToTickEnabled="True" TickFrequency="1"
Ticks="0,1,2,3,4,5,6,7,8,9"
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
@@ -222,105 +306,42 @@
</Slider.Styles>
</Slider>
<TextBlock
Grid.Column="1"
HorizontalAlignment="Right"
Text="{CompiledBinding LameBitrate}" />
<TextBlock
<StackPanel
Grid.Column="2"
Text=" Kbps" />
</Grid>
<Grid ColumnDefinitions="*,*">
<CheckBox
Grid.Column="0"
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
Text="Restrict Encoder to Constant Bitrate?" />
</CheckBox>
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
Orientation="Horizontal">
<TextBlock
TextWrapping="Wrap"
Text="Match Source Bitrate?" />
<TextBlock Text="V" />
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
</StackPanel>
<TextBlock
Grid.Column="0"
Grid.Row="1"
Text="Higher" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
HorizontalAlignment="Right"
Text="Lower" />
</CheckBox>
</Grid>
</StackPanel>
</controls:GroupBox>
<controls:GroupBox
Grid.Row="3"
Margin="0,5"
Label="Quality"
IsEnabled="{CompiledBinding !LameTargetBitrate}">
</controls:GroupBox>
<Grid
ColumnDefinitions="*,Auto,25"
RowDefinitions="*,Auto">
<TextBlock
Grid.Row="4"
Margin="0,5"
VerticalAlignment="Bottom"
Text="Using L.A.M.E encoding engine"
FontStyle="Oblique" />
</Grid>
</controls:GroupBox>
<Slider
Grid.Column="0"
Grid.ColumnSpan="2"
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
Minimum="0"
Maximum="9"
IsSnapToTickEnabled="True" TickFrequency="1"
Ticks="0,1,2,3,4,5,6,7,8,9"
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
</Style>
</Slider.Styles>
</Slider>
<StackPanel
Grid.Column="2"
HorizontalAlignment="Right"
Orientation="Horizontal">
<TextBlock Text="V" />
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
</StackPanel>
<TextBlock
Grid.Column="0"
Grid.Row="1"
Text="Higher" />
<TextBlock
Grid.Column="1"
Grid.Row="1"
HorizontalAlignment="Right"
Text="Lower" />
</Grid>
</controls:GroupBox>
<TextBlock
Grid.Row="4"
Margin="0,5"
VerticalAlignment="Bottom"
Text="Using L.A.M.E encoding engine"
FontStyle="Oblique" />
</Grid>
</controls:GroupBox>
</StackPanel>
<!--Bottom Row-->
<controls:GroupBox
Grid.Row="2"
Grid.ColumnSpan="2"

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,7 @@
using Avalonia.Controls;
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.Primitives;
using Avalonia.Styling;
using LibationFileManager;
using System;
using System.Threading.Tasks;
@@ -9,19 +12,101 @@ 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;
if (Design.IsDesignMode)
RequestedThemeVariant = ThemeVariant.Dark;
}
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

@@ -42,6 +42,17 @@ namespace LibationAvalonia.Dialogs
public EditQuickFilters()
{
InitializeComponent();
if (Design.IsDesignMode)
{
Filters = new ObservableCollection<Filter>([
new Filter { Name = "Filter 1", FilterString = "[filter1 string]" },
new Filter { Name = "Filter 2", FilterString = "[filter2 string]" },
new Filter { Name = "Filter 3", FilterString = "[filter3 string]" },
new Filter { Name = "Filter 4", FilterString = "[filter4 string]" }
]);
DataContext = this;
return;
}
// WARNING: accounts persister will write ANY EDIT to object immediately to file
// here: copy strings and dispose of persister

View File

@@ -51,7 +51,7 @@
<DataGridTemplateColumn Width="Auto" Header="Tag">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -59,7 +59,7 @@
<DataGridTemplateColumn Width="Auto" Header="Description">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextPresenter
<TextBlock
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center" Text="{Binding Item2}" />

View File

@@ -1,8 +1,8 @@
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Controls.Documents;
using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Styling;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
@@ -11,175 +11,175 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
namespace LibationAvalonia.Dialogs;
public partial class EditTemplateDialog : DialogWindow
{
public partial class EditTemplateDialog : DialogWindow
private EditTemplateViewModel _viewModel;
public EditTemplateDialog()
{
private EditTemplateViewModel _viewModel;
InitializeComponent();
public EditTemplateDialog()
if (Design.IsDesignMode)
{
InitializeComponent();
if (Design.IsDesignMode)
{
_ = Configuration.Instance.LibationFiles;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.TemplateName}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {templateEditor.TemplateName}";
_ = Configuration.Instance.LibationFiles;
RequestedThemeVariant = ThemeVariant.Dark;
var editor = TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(Configuration.Instance.Books, Configuration.Instance.FileTemplate);
_viewModel = new(Configuration.Instance, editor);
_viewModel.ResetTextBox(editor.EditingTemplate.TemplateText);
Title = $"Edit {editor.TemplateName}";
DataContext = _viewModel;
}
}
public EditTemplateDialog(ITemplateEditor templateEditor) : this()
{
ArgumentValidator.EnsureNotNull(templateEditor, nameof(templateEditor));
_viewModel = new EditTemplateViewModel(Configuration.Instance, templateEditor);
_viewModel.ResetTextBox(templateEditor.EditingTemplate.TemplateText);
Title = $"Edit {templateEditor.TemplateName}";
DataContext = _viewModel;
}
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
public void EditTemplateViewModel_DoubleTapped(object sender, Avalonia.Input.TappedEventArgs e)
{
var dataGrid = sender as DataGrid;
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
if (string.IsNullOrWhiteSpace(item)) return;
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
{
if (!await _viewModel.Validate())
return;
await base.SaveAndCloseAsync();
}
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public InlineCollection Inlines { get; } = new();
public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
{
var dataGrid = sender as DataGrid;
config = configuration;
TemplateEditor = templates;
Description = templates.TemplateDescription;
ListItems
= new AvaloniaList<Tuple<string, string, string>>(
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{t.TagName}>",
t.Description,
t.DefaultValue)
)
);
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
if (string.IsNullOrWhiteSpace(item)) return;
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
// hold the work-in-progress value. not guaranteed to be valid
private string _userTemplateText;
public string UserTemplateText
{
if (!await _viewModel.Validate())
return;
await base.SaveAndCloseAsync();
}
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
private class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public InlineCollection Inlines { get; } = new();
public ITemplateEditor TemplateEditor { get; }
public EditTemplateViewModel(Configuration configuration, ITemplateEditor templates)
get => _userTemplateText;
set
{
config = configuration;
TemplateEditor = templates;
Description = templates.TemplateDescription;
ListItems
= new AvaloniaList<Tuple<string, string, string>>(
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
templateTb_TextChanged();
}
}
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
public string Description { get; }
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
public void ResetTextBox(string value) => UserTemplateText = value;
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
public async Task<bool> Validate()
{
if (TemplateEditor.EditingTemplate.IsValid)
return true;
var errors
= TemplateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
private void templateTb_TextChanged()
{
TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
// result: can wrap long paths. eg:
// |-- LINE WRAP BOUNDARIES --|
// \books\author with a very <= normal line break on space between words
// long name\narrator narrator
// \title <= line break on the zero-with space we added before slashes
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
TemplateEditor
.EditingTemplate
.TagsRegistered
.Cast<TemplateTags>()
.Select(
t => new Tuple<string, string, string>(
$"<{t.TagName}>",
t.Description,
t.DefaultValue)
)
);
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
}
var bold = FontWeight.Bold;
var reg = FontWeight.Normal;
// hold the work-in-progress value. not guaranteed to be valid
private string _userTemplateText;
public string UserTemplateText
Inlines.Clear();
if (!TemplateEditor.IsFilePath)
{
get => _userTemplateText;
set
{
this.RaiseAndSetIfChanged(ref _userTemplateText, value);
templateTb_TextChanged();
}
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
private string _warningText;
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
var folder = TemplateEditor.GetFolderName();
var file = TemplateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
public string Description { get; }
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
public void ResetTextBox(string value) => UserTemplateText = value;
public void ResetToDefault() => ResetTextBox(TemplateEditor.DefaultTemplate);
Inlines.Add(new Run(sing));
public async Task<bool> Validate()
{
if (TemplateEditor.EditingTemplate.IsValid)
return true;
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
var errors
= TemplateEditor
.EditingTemplate
.Errors
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
await MessageBox.Show($"This template text is not valid. Errors:\r\n{errors}", "Invalid", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
private void templateTb_TextChanged()
{
TemplateEditor.SetTemplateText(UserTemplateText);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
// result: can wrap long paths. eg:
// |-- LINE WRAP BOUNDARIES --|
// \books\author with a very <= normal line break on space between words
// long name\narrator narrator
// \title <= line break on the zero-with space we added before slashes
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
WarningText
= !TemplateEditor.EditingTemplate.HasWarnings
? ""
: "Warning:\r\n" +
TemplateEditor
.EditingTemplate
.Warnings
.Select(err => $"- {err}")
.Aggregate((a, b) => $"{a}\r\n{b}");
var bold = FontWeight.Bold;
var reg = FontWeight.Normal;
Inlines.Clear();
if (!TemplateEditor.IsFilePath)
{
Inlines.Add(new Run(TemplateEditor.GetName()) { FontWeight = bold });
return;
}
var folder = TemplateEditor.GetFolderName();
var file = TemplateEditor.GetFileName();
var ext = config.DecryptToLossy ? "mp3" : "m4b";
Inlines.Add(new Run(slashWrap(TemplateEditor.BaseDirectory.PathWithoutPrefix)) { FontWeight = reg });
Inlines.Add(new Run(sing) { FontWeight = reg });
Inlines.Add(new Run(slashWrap(folder)) { FontWeight = TemplateEditor.IsFolder ? bold : reg });
Inlines.Add(new Run(sing));
Inlines.Add(new Run(slashWrap(file)) { FontWeight = TemplateEditor.IsFolder ? reg : bold });
Inlines.Add(new Run($".{ext}"));
}
Inlines.Add(new Run($".{ext}"));
}
}
}

View File

@@ -5,6 +5,7 @@
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="165"
MinHeight="165" MaxHeight="165"
MinWidth="800" MaxWidth="800"
Width="800" Height="165"
x:Class="LibationAvalonia.Dialogs.LibationFilesDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
WindowStartupLocation="CenterScreen"

View File

@@ -5,6 +5,7 @@
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
MinWidth="600" MinHeight="450"
MaxWidth="600" MaxHeight="450"
Width="600" Height="450"
x:Class="LibationAvalonia.Dialogs.MessageBoxAlertAdminDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
Title="MessageBoxAlertAdminDialog"

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

@@ -6,17 +6,17 @@ using LibationFileManager;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia
{
public static class FormSaveExtension
{
static readonly WindowIcon WindowIcon;
static readonly WindowIcon? WindowIcon;
static FormSaveExtension()
{
if (Application.Current.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow is not null)
WindowIcon = desktop.MainWindow.Icon;
else
WindowIcon = null;
WindowIcon = Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop && desktop.MainWindow?.Icon is WindowIcon icon
? icon
: null;
}
public static void SetLibationIcon(this Window form)
@@ -29,7 +29,7 @@ namespace LibationAvalonia
if (Design.IsDesignMode) return;
try
{
var savedState = config.GetNonString<FormSizeAndPosition>(defaultValue: null, form.GetType().Name);
var savedState = config.GetNonString<FormSizeAndPosition?>(defaultValue: null, form.GetType().Name);
if (savedState is null)
return;
@@ -40,12 +40,14 @@ namespace LibationAvalonia
savedState.Width = (int)form.Width;
savedState.Height = (int)form.Height;
}
// Fit to the current screen size in case the screen resolution changed since the size was last persisted
if (savedState.Width > form.Screens.Primary.WorkingArea.Width)
savedState.Width = form.Screens.Primary.WorkingArea.Width;
if (savedState.Height > form.Screens.Primary.WorkingArea.Height)
savedState.Height = form.Screens.Primary.WorkingArea.Height;
if (form.Screens.Primary is Screen primaryScreen)
{
// Fit to the current screen size in case the screen resolution changed since the size was last persisted
if (savedState.Width > primaryScreen.WorkingArea.Width)
savedState.Width = primaryScreen.WorkingArea.Width;
if (savedState.Height > primaryScreen.WorkingArea.Height)
savedState.Height = primaryScreen.WorkingArea.Height;
}
var rect = new PixelRect(savedState.X, savedState.Y, savedState.Width, savedState.Height);
@@ -109,23 +111,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

@@ -74,13 +74,13 @@
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.2" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.2.2" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.2" />
<PackageReference Include="Avalonia.Diagnostics" Version="11.2.5" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.2.5" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.2.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.5" />
</ItemGroup>
<ItemGroup>

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

@@ -5,10 +5,10 @@ using System.Threading.Tasks;
using ApplicationServices;
using AppScaffolding;
using Avalonia;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.ReactiveUI;
using LibationFileManager;
#nullable enable
namespace LibationAvalonia
{
static class Program
@@ -57,7 +57,7 @@ namespace LibationAvalonia
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
}
BuildAvaloniaApp().StartWithClassicDesktopLifetime(null);
BuildAvaloniaApp().StartWithClassicDesktopLifetime([]);
}
catch (Exception ex)
{
@@ -77,27 +77,27 @@ namespace LibationAvalonia
private static void LogError(object exceptionObject)
{
var logError = $"""
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
{DateTime.Now} - Libation Crash
OS {Configuration.OS}
Version {LibationScaffolding.BuildVersion}
ReleaseIdentifier {LibationScaffolding.ReleaseIdentifier}
InteropFunctionsType {InteropFactory.InteropFunctionsType}
LibationFiles {getConfigValue(c => c.LibationFiles)}
Books Folder {getConfigValue(c => c.Books)}
=== EXCEPTION ===
{exceptionObject}
""";
var crashLog = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "LibationCrash.log");
using var sw = new StreamWriter(crashLog, true);
sw.WriteLine(logError);
static string getConfigValue(Func<Configuration, string> selector)
static string getConfigValue(Func<Configuration, string?> selector)
{
try
{
return selector(Configuration.Instance);
return selector(Configuration.Instance) ?? "[null]";
}
catch (Exception ex)
{

View File

@@ -4,6 +4,7 @@ using DataLayer;
using LibationUiBase.GridView;
using System;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class AvaloniaEntryStatus : EntryStatus, IEntryStatus, IComparable
@@ -17,6 +18,6 @@ namespace LibationAvalonia.ViewModels
=> AvaloniaUtils.TryLoadImageOrDefault(picture, LibationFileManager.PictureSize._80x80);
//Button icons are handled by LiberateStatusButton
protected override Bitmap GetResourceImage(string rescName) => null;
protected override Bitmap? GetResourceImage(string rescName) => null;
}
}

View File

@@ -1,5 +1,6 @@
using ReactiveUI;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class LiberateStatusButtonViewModel : ViewModelBase

View File

@@ -4,15 +4,15 @@ using DataLayer;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM
{
private Task<LibraryCommands.LibraryStats> updateCountsTask;
private LibraryCommands.LibraryStats _libraryStats;
private Task<LibraryCommands.LibraryStats>? updateCountsTask;
private LibraryCommands.LibraryStats? _libraryStats;
/// <summary> The "Begin Book and PDF Backup" menu item header text </summary>
public string BookBackupsToolStripText { get; private set; } = "Begin Book and PDF Backups: 0";
@@ -20,7 +20,7 @@ namespace LibationAvalonia.ViewModels
public string PdfBackupsToolStripText { get; private set; } = "Begin PDF Only Backups: 0";
/// <summary> The user's library statistics </summary>
public LibraryCommands.LibraryStats LibraryStats
public LibraryCommands.LibraryStats? LibraryStats
{
get => _libraryStats;
set
@@ -28,12 +28,12 @@ namespace LibationAvalonia.ViewModels
this.RaiseAndSetIfChanged(ref _libraryStats, value);
BookBackupsToolStripText
= LibraryStats.HasPendingBooks
= LibraryStats?.HasPendingBooks ?? false
? "Begin " + menufyText($"Book and PDF Backups: {LibraryStats.PendingBooks} remaining")
: "All books have been liberated";
PdfBackupsToolStripText
= LibraryStats.pdfsNotDownloaded > 0
= LibraryStats?.pdfsNotDownloaded > 0
? "Begin " + menufyText($"PDF Only Backups: {LibraryStats.pdfsNotDownloaded} remaining")
: "All PDFs have been downloaded";
@@ -44,22 +44,21 @@ 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);
if (Configuration.Instance.AutoDownloadEpisodes
&& stats.booksNoProgress + stats.pdfsNotDownloaded > 0)
&& stats.PendingBooks + stats.pdfsNotDownloaded > 0)
await Dispatcher.UIThread.InvokeAsync(BackupAllBooks);
}
}

View File

@@ -5,6 +5,7 @@ using LibationFileManager;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM
@@ -18,7 +19,7 @@ namespace LibationAvalonia.ViewModels
var options = new FilePickerSaveOptions
{
Title = "Where to export Library",
SuggestedStartLocation = await MainWindow.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix),
SuggestedStartLocation = await MainWindow.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? Configuration.DefaultBooksDirectory),
SuggestedFileName = $"Libation Library Export {DateTime.Now:yyyy-MM-dd}",
DefaultExtension = "xlsx",
ShowOverwritePrompt = true,
@@ -41,7 +42,7 @@ namespace LibationAvalonia.ViewModels
AppleUniformTypeIdentifiers = new[] { "public.json" }
},
new("All files (*.*)") { Patterns = new[] { "*" } }
}
}
};
var selectedFile = (await MainWindow.StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath();

View File

@@ -9,16 +9,17 @@ using System;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM
{
private QuickFilters.NamedFilter lastGoodFilter = new(string.Empty, null);
private QuickFilters.NamedFilter _selectedNamedFilter = new(string.Empty, null);
private QuickFilters.NamedFilter? lastGoodFilter = new(string.Empty, null);
private QuickFilters.NamedFilter? _selectedNamedFilter = new(string.Empty, null);
private bool _firstFilterIsDefault = true;
/// <summary> Library filterting query </summary>
public QuickFilters.NamedFilter SelectedNamedFilter { get => _selectedNamedFilter; set => this.RaiseAndSetIfChanged(ref _selectedNamedFilter, value); }
public QuickFilters.NamedFilter? SelectedNamedFilter { get => _selectedNamedFilter; set => this.RaiseAndSetIfChanged(ref _selectedNamedFilter, value); }
public AvaloniaList<Control> QuickFilterMenuItems { get; } = new();
/// <summary> Indicates if the first quick filter is the default filter </summary>
public bool FirstFilterIsDefault { get => _firstFilterIsDefault; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); }
@@ -50,36 +51,44 @@ namespace LibationAvalonia.ViewModels
QuickFilterMenuItems.Add(new Separator());
}
public void AddQuickFilterBtn() => QuickFilters.Add(SelectedNamedFilter);
public void AddQuickFilterBtn() { if (SelectedNamedFilter != null) QuickFilters.Add(SelectedNamedFilter); }
public async Task FilterBtn() => await PerformFilter(SelectedNamedFilter);
public async Task FilterHelpBtn() => await new LibationAvalonia.Dialogs.SearchSyntaxDialog().ShowDialog(MainWindow);
public void ToggleFirstFilterIsDefault() => FirstFilterIsDefault = !FirstFilterIsDefault;
public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow);
public async Task PerformFilter(QuickFilters.NamedFilter namedFilter)
public async Task PerformFilter(QuickFilters.NamedFilter? namedFilter)
{
SelectedNamedFilter = namedFilter;
var tryFilter = namedFilter?.Filter;
try
{
await ProductsDisplay.Filter(namedFilter.Filter);
await ProductsDisplay.Filter(tryFilter);
lastGoodFilter = namedFilter;
}
catch (Exception ex)
{
await MessageBox.Show($"Bad filter string:\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
Serilog.Log.Logger.Error(ex, "Error performing filtering. {@namedFilter} {@lastGoodFilter}", namedFilter, lastGoodFilter);
await MessageBox.Show($"Bad filter string: \"{tryFilter}\"\r\n\r\n{ex.Message}", "Bad filter string", MessageBoxButtons.OK, MessageBoxIcon.Error);
// re-apply last good filter
await PerformFilter(lastGoodFilter);
}
}
private void updateFiltersMenu(object _ = null, object __ = null)
private void updateFiltersMenu(object? _ = null, object? __ = null)
{
//Clear all filters
var quickFilterNativeMenu = (NativeMenuItem)NativeMenu.GetMenu(MainWindow).Items[3];
for (int i = quickFilterNativeMenu.Menu.Items.Count - 1; i >= 3; i--)
if (NativeMenu.GetMenu(MainWindow)?.Items[3] is not NativeMenuItem ss ||
ss.Menu is not NativeMenu quickFilterNativeMenu)
{
var command = ((NativeMenuItem)quickFilterNativeMenu.Menu.Items[i]).Command as IDisposable;
Serilog.Log.Logger.Error($"Unable to find {nameof(quickFilterNativeMenu)}");
return;
}
//Clear all filters
for (int i = quickFilterNativeMenu.Items.Count - 1; i >= 3; i--)
{
var command = ((NativeMenuItem)quickFilterNativeMenu.Items[i]).Command as IDisposable;
if (command != null)
{
var existingBinding = MainWindow.KeyBindings.FirstOrDefault(kb => kb.Command == command);
@@ -89,7 +98,7 @@ namespace LibationAvalonia.ViewModels
command.Dispose();
}
quickFilterNativeMenu.Menu.Items.RemoveAt(i);
quickFilterNativeMenu.Items.RemoveAt(i);
QuickFilterMenuItems.RemoveAt(i);
}
@@ -116,7 +125,7 @@ namespace LibationAvalonia.ViewModels
}
QuickFilterMenuItems.Add(menuItem);
quickFilterNativeMenu.Menu.Items.Add(nativeMenuItem);
quickFilterNativeMenu.Items.Add(nativeMenuItem);
}
}
}

View File

@@ -8,6 +8,7 @@ using System.Linq;
using System.Threading.Tasks;
using Avalonia.Input;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public partial class MainVM
@@ -90,7 +91,9 @@ namespace LibationAvalonia.ViewModels
public async Task ScanAccountAsync()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
await scanLibrariesAsync(persister.AccountsSettings.GetAll().FirstOrDefault());
var firstAccount = persister.AccountsSettings.GetAll().FirstOrDefault();
if (firstAccount != null)
await scanLibrariesAsync(firstAccount);
}
public async Task ScanAllAccountsAsync()
@@ -194,7 +197,7 @@ namespace LibationAvalonia.ViewModels
await ProductsDisplay.ScanAndRemoveBooksAsync(accounts);
}
private async Task scanLibrariesAsync(params Account[] accounts)
private async Task scanLibrariesAsync(params Account[]? accounts)
{
try
{
@@ -218,37 +221,44 @@ namespace LibationAvalonia.ViewModels
}
}
private void refreshImportMenu(object _ = null, EventArgs __ = null)
private void refreshImportMenu(object? _ = null, EventArgs? __ = null)
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
AccountsCount = persister.AccountsSettings.Accounts.Count;
var importMenuItem = (NativeMenuItem)NativeMenu.GetMenu(MainWindow).Items[0];
for (int i = importMenuItem.Menu.Items.Count - 1; i >= 2; i--)
importMenuItem.Menu.Items.RemoveAt(i);
if (NativeMenu.GetMenu(MainWindow)?.Items[0] is not NativeMenuItem ss ||
ss.Menu is not NativeMenu importMenuItem)
{
Serilog.Log.Logger.Error($"Unable to find {nameof(importMenuItem)}");
return;
}
for (int i = importMenuItem.Items.Count - 1; i >= 2; i--)
importMenuItem.Items.RemoveAt(i);
if (AccountsCount < 1)
{
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "No accounts yet. Add Account...", Command = ReactiveCommand.Create(AddAccountsAsync) });
importMenuItem.Items.Add(new NativeMenuItem { Header = "No accounts yet. Add Account...", Command = ReactiveCommand.Create(AddAccountsAsync) });
}
else if (AccountsCount == 1)
{
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Scan Library", Command = ReactiveCommand.Create(ScanAccountAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta)});
importMenuItem.Menu.Items.Add(new NativeMenuItemSeparator());
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Remove Library Books", Command = ReactiveCommand.Create(RemoveBooksAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta)});
importMenuItem.Items.Add(new NativeMenuItem { Header = "Scan Library", Command = ReactiveCommand.Create(ScanAccountAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta) });
importMenuItem.Items.Add(new NativeMenuItemSeparator());
importMenuItem.Items.Add(new NativeMenuItem { Header = "Remove Library Books", Command = ReactiveCommand.Create(RemoveBooksAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta) });
}
else
{
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Scan Library of All Accounts", Command = ReactiveCommand.Create(ScanAllAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta)});
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Scan Library of Some Accounts", Command = ReactiveCommand.Create(ScanSomeAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) });
importMenuItem.Menu.Items.Add(new NativeMenuItemSeparator());
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Remove Books from All Accounts", Command = ReactiveCommand.Create(RemoveBooksAllAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta)});
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Remove Books from Some Accounts", Command = ReactiveCommand.Create(RemoveBooksSomeAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) });
importMenuItem.Items.Add(new NativeMenuItem { Header = "Scan Library of All Accounts", Command = ReactiveCommand.Create(ScanAllAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta) });
importMenuItem.Items.Add(new NativeMenuItem { Header = "Scan Library of Some Accounts", Command = ReactiveCommand.Create(ScanSomeAccountsAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) });
importMenuItem.Items.Add(new NativeMenuItemSeparator());
importMenuItem.Items.Add(new NativeMenuItem { Header = "Remove Books from All Accounts", Command = ReactiveCommand.Create(RemoveBooksAllAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta) });
importMenuItem.Items.Add(new NativeMenuItem { Header = "Remove Books from Some Accounts", Command = ReactiveCommand.Create(RemoveBooksSomeAsync), Gesture = new KeyGesture(Key.R, KeyModifiers.Alt | KeyModifiers.Meta | KeyModifiers.Shift) });
}
importMenuItem.Menu.Items.Add(new NativeMenuItemSeparator());
importMenuItem.Menu.Items.Add(new NativeMenuItem { Header = "Locate Audiobooks...", Command = ReactiveCommand.Create(LocateAudiobooksAsync) });
importMenuItem.Items.Add(new NativeMenuItemSeparator());
importMenuItem.Items.Add(new NativeMenuItem { Header = "Locate Audiobooks...", Command = ReactiveCommand.Create(LocateAudiobooksAsync) });
}
}
}

View File

@@ -5,6 +5,7 @@ using System.Linq;
using System.Threading.Tasks;
using DataLayer;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@@ -6,6 +6,7 @@ using Dinah.Core;
using LibationUiBase.GridView;
using ReactiveUI;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@@ -6,6 +6,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM
@@ -50,7 +51,7 @@ namespace LibationAvalonia.ViewModels
}
private List<(string AccountId, string LocaleName)> preSaveDefaultAccounts;
private List<(string AccountId, string LocaleName)>? preSaveDefaultAccounts;
private List<(string AccountId, string LocaleName)> getDefaultAccounts()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
@@ -61,17 +62,17 @@ namespace LibationAvalonia.ViewModels
.ToList();
}
private void accountsPreSave(object sender = null, EventArgs e = null)
private void accountsPreSave(object? sender = null, EventArgs? e = null)
=> preSaveDefaultAccounts = getDefaultAccounts();
private void accountsPostSave(object sender = null, EventArgs e = null)
private void accountsPostSave(object? sender = null, EventArgs? e = null)
{
if (getDefaultAccounts().Except(preSaveDefaultAccounts).Any())
if (getDefaultAccounts().Except(preSaveDefaultAccounts ?? Enumerable.Empty<(string AccountId, string LocaleName)>()).Any())
startAutoScan();
}
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
private void startAutoScan(object sender = null, EventArgs e = null)
private void startAutoScan(object? sender = null, EventArgs? e = null)
{
AutoScanChecked = Configuration.Instance.AutoScan;
if (AutoScanChecked)

View File

@@ -4,6 +4,7 @@ using ReactiveUI;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM
@@ -12,7 +13,9 @@ namespace LibationAvalonia.ViewModels
public bool MenuBarVisible { get => _menuBarVisible; set => this.RaiseAndSetIfChanged(ref _menuBarVisible, value); }
private void Configure_Settings()
{
((NativeMenuItem)NativeMenu.GetMenu(App.Current).Items[0]).Command = ReactiveCommand.Create(ShowAboutAsync);
if (App.Current is Avalonia.Application app &&
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
}
public Task ShowAboutAsync() => new LibationAvalonia.Dialogs.AboutDialog().ShowDialog(MainWindow);

View File

@@ -6,12 +6,13 @@ using Avalonia.Threading;
using LibationAvalonia.Dialogs;
using ReactiveUI;
#nullable enable
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}";
@@ -56,13 +57,13 @@ namespace LibationAvalonia.ViewModels
this.RaisePropertyChanged(nameof(LiberateVisibleToolStripText_2));
}
public async void ProductsDisplay_VisibleCountChanged(object sender, int qty)
public async void ProductsDisplay_VisibleCountChanged(object? sender, int qty)
{
setVisibleCount(qty);
await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem);
}
private async void setLiberatedVisibleMenuItemAsync(object _, object __)
private async void setLiberatedVisibleMenuItemAsync(object? _, object __)
=> await Dispatcher.UIThread.InvokeAsync(setLiberatedVisibleMenuItem);

View File

@@ -2,6 +2,7 @@
using LibationUiBase;
using System.IO;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@@ -1,8 +1,12 @@
using ApplicationServices;
using DataLayer;
using LibationAvalonia.Views;
using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public partial class MainVM : ViewModelBase
@@ -20,7 +24,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 +38,20 @@ namespace LibationAvalonia.ViewModels
Configure_VisibleBooks();
}
private async void LibraryCommands_LibrarySizeChanged(object? sender, List<LibraryBook> fullLibrary)
{
try
{
await Task.WhenAll(
SetBackupCountsAsync(fullLibrary),
Task.Run(() => ProductsDisplay.UpdateGridAsync(fullLibrary)));
}
catch (System.Exception ex)
{
await MessageBox.ShowAdminAlert(MainWindow, "An error occurred while updating the library.", "Library Size Change Error", ex);
}
}
private static string menufyText(string header) => Configuration.IsMacOs ? header : $"_{header}";
}
}

View File

@@ -16,6 +16,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public enum ProcessBookResult
@@ -45,28 +46,28 @@ namespace LibationAvalonia.ViewModels
/// </summary>
public class ProcessBookViewModel : ViewModelBase
{
public event EventHandler Completed;
public event EventHandler? Completed;
public LibraryBook LibraryBook { get; private set; }
private ProcessBookResult _result = ProcessBookResult.None;
private ProcessBookStatus _status = ProcessBookStatus.Queued;
private string _narrator;
private string _author;
private string _title;
private string? _narrator;
private string? _author;
private string? _title;
private int _progress;
private string _eta;
private Bitmap _cover;
private string? _eta;
private Bitmap? _cover;
#region Properties exposed to the view
public ProcessBookResult Result { get => _result; set { this.RaiseAndSetIfChanged(ref _result, value); this.RaisePropertyChanged(nameof(StatusText)); } }
public ProcessBookStatus Status { get => _status; set { this.RaiseAndSetIfChanged(ref _status, value); this.RaisePropertyChanged(nameof(BackgroundColor)); this.RaisePropertyChanged(nameof(IsFinished)); this.RaisePropertyChanged(nameof(IsDownloading)); this.RaisePropertyChanged(nameof(Queued)); } }
public string Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
public string Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); }
public string Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); }
public string? Narrator { get => _narrator; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _narrator, value)); }
public string? Author { get => _author; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _author, value)); }
public string? Title { get => _title; set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _title, value)); }
public int Progress { get => _progress; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _progress, value)); }
public string ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
public Bitmap Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
public string? ETA { get => _eta; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _eta, value)); }
public Bitmap? Cover { get => _cover; private set => Dispatcher.UIThread.Invoke(() => this.RaiseAndSetIfChanged(ref _cover, value)); }
public bool IsFinished => Status is not ProcessBookStatus.Queued and not ProcessBookStatus.Working;
public bool IsDownloading => Status is ProcessBookStatus.Working;
public bool Queued => Status is ProcessBookStatus.Queued;
@@ -95,8 +96,8 @@ namespace LibationAvalonia.ViewModels
private TimeSpan TimeRemaining { set { ETA = $"ETA: {value:mm\\:ss}"; } }
private Processable CurrentProcessable => _currentProcessable ??= Processes.Dequeue().Invoke();
private Processable NextProcessable() => _currentProcessable = null;
private Processable _currentProcessable;
private Processable? NextProcessable() => _currentProcessable = null;
private Processable? _currentProcessable;
private readonly Queue<Func<Processable>> Processes = new();
private readonly LogMe Logger;
@@ -118,7 +119,7 @@ namespace LibationAvalonia.ViewModels
_cover = AvaloniaUtils.TryLoadImageOrDefault(picture, PictureSize._80x80);
}
private void PictureStorage_PictureCached(object sender, PictureCachedEventArgs e)
private void PictureStorage_PictureCached(object? sender, PictureCachedEventArgs e)
{
if (e.Definition.PictureId == LibraryBook.Book.PictureId)
{
@@ -255,14 +256,14 @@ namespace LibationAvalonia.ViewModels
#region AudioDecodable event handlers
private void AudioDecodable_TitleDiscovered(object sender, string title) => Title = title;
private void AudioDecodable_TitleDiscovered(object? sender, string title) => Title = title;
private void AudioDecodable_AuthorsDiscovered(object sender, string authors) => Author = authors;
private void AudioDecodable_AuthorsDiscovered(object? sender, string authors) => Author = authors;
private void AudioDecodable_NarratorsDiscovered(object sender, string narrators) => Narrator = narrators;
private void AudioDecodable_NarratorsDiscovered(object? sender, string narrators) => Narrator = narrators;
private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e)
private byte[] AudioDecodable_RequestCoverArt(object? sender, EventArgs e)
{
var quality
= Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null
@@ -275,7 +276,7 @@ namespace LibationAvalonia.ViewModels
return coverData;
}
private void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt)
private void AudioDecodable_CoverImageDiscovered(object? sender, byte[] coverArt)
{
using var ms = new System.IO.MemoryStream(coverArt);
Cover = new Avalonia.Media.Imaging.Bitmap(ms);
@@ -284,10 +285,10 @@ namespace LibationAvalonia.ViewModels
#endregion
#region Streamable event handlers
private void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining;
private void Streamable_StreamingTimeRemaining(object? sender, TimeSpan timeRemaining) => TimeRemaining = timeRemaining;
private void Streamable_StreamingProgressChanged(object sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress)
private void Streamable_StreamingProgressChanged(object? sender, Dinah.Core.Net.Http.DownloadProgress downloadProgress)
{
if (!downloadProgress.ProgressPercentage.HasValue)
return;
@@ -302,21 +303,25 @@ namespace LibationAvalonia.ViewModels
#region Processable event handlers
private async void Processable_Begin(object sender, LibraryBook libraryBook)
private async void Processable_Begin(object? sender, LibraryBook libraryBook)
{
await Dispatcher.UIThread.InvokeAsync(() => Status = ProcessBookStatus.Working);
Logger.Info($"{Environment.NewLine}{((Processable)sender).Name} Step, Begin: {libraryBook.Book}");
if (sender is Processable processable)
Logger.Info($"{Environment.NewLine}{processable.Name} Step, Begin: {libraryBook.Book}");
Title = libraryBook.Book.TitleWithSubtitle;
Author = libraryBook.Book.AuthorNames();
Narrator = libraryBook.Book.NarratorNames();
}
private async void Processable_Completed(object sender, LibraryBook libraryBook)
private async void Processable_Completed(object? sender, LibraryBook libraryBook)
{
Logger.Info($"{((Processable)sender).Name} Step, Completed: {libraryBook.Book}");
UnlinkProcessable((Processable)sender);
if (sender is Processable processable)
{
Logger.Info($"{processable.Name} Step, Completed: {libraryBook.Book}");
UnlinkProcessable(processable);
}
if (Processes.Count == 0)
{
@@ -375,7 +380,7 @@ namespace LibationAvalonia.ViewModels
: str;
details =
$@" Title: {libraryBook.Book.TitleWithSubtitle}
$@" Title: {libraryBook.Book.TitleWithSubtitle}
ID: {libraryBook.Book.AudibleProductId}
Author: {trunc(libraryBook.Book.AuthorNames())}
Narr: {trunc(libraryBook.Book.NarratorNames())}";

View File

@@ -12,15 +12,17 @@ using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class ProcessQueueViewModel : ViewModelBase, ILogForm
{
public ObservableCollection<LogEntry> LogEntries { get; } = new();
public AvaloniaList<ProcessBookViewModel> Items { get; } = new();
public TrackedQueue<ProcessBookViewModel> Queue { get; }
public ProcessBookViewModel SelectedItem { get; set; }
public Task QueueRunner { get; private set; }
public ProcessBookViewModel? SelectedItem { get; set; }
public Task? QueueRunner { get; private set; }
public bool Running => !QueueRunner?.IsCompleted ?? false;
private readonly LogMe Logger;
@@ -41,14 +43,14 @@ namespace LibationAvalonia.ViewModels
private int _completedCount;
private int _errorCount;
private int _queuedCount;
private string _runningTime;
private string? _runningTime;
private bool _progressBarVisible;
private decimal _speedLimit;
public int CompletedCount { get => _completedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _completedCount, value); this.RaisePropertyChanged(nameof(AnyCompleted)); }); }
public int QueuedCount { get => _queuedCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _queuedCount, value); this.RaisePropertyChanged(nameof(AnyQueued)); }); }
public int ErrorCount { get => _errorCount; private set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _errorCount, value); this.RaisePropertyChanged(nameof(AnyErrors)); }); }
public string RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
public string? RunningTime { get => _runningTime; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _runningTime, value); }); }
public bool ProgressBarVisible { get => _progressBarVisible; set => Dispatcher.UIThread.Invoke(() => { this.RaiseAndSetIfChanged(ref _progressBarVisible, value); }); }
public bool AnyCompleted => CompletedCount > 0;
public bool AnyQueued => QueuedCount > 0;
@@ -89,7 +91,7 @@ namespace LibationAvalonia.ViewModels
public decimal SpeedLimitIncrement { get; private set; }
private async void Queue_CompletedCountChanged(object sender, int e)
private async void Queue_CompletedCountChanged(object? sender, int e)
{
int errCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.FailedAbort or ProcessBookResult.FailedSkip or ProcessBookResult.FailedRetry or ProcessBookResult.ValidationFail);
int completeCount = Queue.Completed.Count(p => p.Result is ProcessBookResult.Success);
@@ -98,7 +100,7 @@ namespace LibationAvalonia.ViewModels
CompletedCount = completeCount;
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
}
private async void Queue_QueuededCountChanged(object sender, int cueCount)
private async void Queue_QueuededCountChanged(object? sender, int cueCount)
{
QueuedCount = cueCount;
await Dispatcher.UIThread.InvokeAsync(() => this.RaisePropertyChanged(nameof(Progress)));
@@ -118,7 +120,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 });
@@ -210,13 +220,17 @@ namespace LibationAvalonia.ViewModels
while (Queue.MoveNext())
{
var nextBook = Queue.Current;
if (Queue.Current is not ProcessBookViewModel nextBook)
{
Serilog.Log.Logger.Information("Current queue item is empty.");
continue;
}
Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook?.LibraryBook);
Serilog.Log.Logger.Information("Begin processing queued item. {item_LibraryBook}", nextBook.LibraryBook);
var result = await nextBook.ProcessOneAsync();
Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook?.LibraryBook, result);
Serilog.Log.Logger.Information("Completed processing queued item: {item_LibraryBook}\r\nResult: {result}", nextBook.LibraryBook, result);
if (result == ProcessBookResult.ValidationFail)
Queue.ClearCurrent();
@@ -248,7 +262,7 @@ This error appears to be caused by a temporary interruption of service that some
}
}
private void CounterTimer_Tick(object state)
private void CounterTimer_Tick(object? state)
{
string timeToStr(TimeSpan time)
{
@@ -265,6 +279,6 @@ This error appears to be caused by a temporary interruption of service that some
{
public DateTime LogDate { get; init; }
public string LogDateString => LogDate.ToShortTimeString();
public string LogMessage { get; init; }
public string? LogMessage { get; init; }
}
}

View File

@@ -4,6 +4,7 @@ using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading;
using DataLayer;
using Dinah.Core.Collections.Generic;
using LibationAvalonia.Dialogs.Login;
using LibationFileManager;
using LibationUiBase.GridView;
@@ -15,20 +16,27 @@ using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class ProductsDisplayViewModel : ViewModelBase
{
/// <summary>Number of visible rows has changed</summary>
public event EventHandler<int> VisibleCountChanged;
public event EventHandler<int> RemovableCountChanged;
public event EventHandler<int>? VisibleCountChanged;
public event EventHandler<int>? RemovableCountChanged;
/// <summary>Backing list of all grid entries</summary>
private readonly AvaloniaList<IGridEntry> SOURCE = new();
/// <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 HashSet<IGridEntry>? FilteredInGridEntries;
public string? FilterString { 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); }
@@ -53,14 +61,14 @@ namespace LibationAvalonia.ViewModels
VisibleCountChanged?.Invoke(this, 0);
}
private static readonly System.Reflection.MethodInfo SetFlagsMethod;
private static readonly System.Reflection.MethodInfo? SetFlagsMethod;
/// <summary>
/// Tells the <see cref="DataGridCollectionView"/> whether it should process changes to the underlying collection
/// </summary>
/// <remarks> DataGridCollectionView.CollectionViewFlags.ShouldProcessCollectionChanged = 4</remarks>
private void SetShouldProcessCollectionChanged(bool flagSet)
=> SetFlagsMethod.Invoke(GridEntries, new object[] { 4, flagSet });
=> SetFlagsMethod?.Invoke(GridEntries, new object[] { 4, flagSet });
static ProductsDisplayViewModel()
{
@@ -96,15 +104,20 @@ namespace LibationAvalonia.ViewModels
internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{
GridEntries = new(SOURCE) { Filter = CollectionFilter };
if (dbBooks == null)
throw new ArgumentNullException(nameof(dbBooks));
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync
var sc = await Dispatcher.UIThread.InvokeAsync(() => AvaloniaSynchronizationContext.Current);
AvaloniaSynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry<AvaloniaEntryStatus>.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
//Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
//Adding entries to the Source list will invoke CollectionFilter
//Add all IGridEntries to the SOURCE list. Note that SOURCE has not yet been linked to the UI via
//the GridEntries property, so adding items to SOURCE will not trigger any refreshes or UI action.
//This this can be done on any thread.
SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
//Add all children beneath their parent
@@ -115,11 +128,23 @@ namespace LibationAvalonia.ViewModels
SOURCE.Insert(++seriesIndex, child);
}
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
//Create the filtered-in list before adding entries to GridEntries to avoid a refresh or UI action
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
// Adding SOURCE to the DataGridViewCollection _after_ building the SOURCE list
//Saves ~500 ms on a library of ~4500 books.
//Perform on UI thread for safety, but at this time, merely setting the DataGridCollectionView
//does not trigger UI actions in the way that modifying the list after it's been linked does.
await Dispatcher.UIThread.InvokeAsync(() =>
{
GridEntries = new(SOURCE) { Filter = CollectionFilter };
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
});
GridEntries_CollectionChanged();
}
private void GridEntries_CollectionChanged(object sender = null, EventArgs e = null)
private void GridEntries_CollectionChanged(object? sender = null, EventArgs? e = null)
{
var count
= FilteredInGridEntries?.OfType<ILibraryBookEntry>().Count()
@@ -133,12 +158,16 @@ namespace LibationAvalonia.ViewModels
/// </summary>
internal async Task UpdateGridAsync(List<LibraryBook> dbBooks)
{
GridEntries.CollectionChanged -= GridEntries_CollectionChanged;
if (dbBooks == null)
throw new ArgumentNullException(nameof(dbBooks));
if (GridEntries == null)
throw new InvalidOperationException($"Must call {nameof(BindToGridAsync)} before calling {nameof(UpdateGridAsync)}");
#region Add new or update existing grid entries
//Add absent entries to grid, or update existing entry
var allEntries = SOURCE.BookEntries().ToList();
var allEntries = SOURCE.BookEntries().ToDictionarySafe(b => b.AudibleProductId);
var seriesEntries = SOURCE.SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
@@ -146,7 +175,7 @@ namespace LibationAvalonia.ViewModels
{
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null;
if (libraryBook.Book.IsProduct())
UpsertBook(libraryBook, existingEntry);
@@ -185,7 +214,8 @@ namespace LibationAvalonia.ViewModels
await Filter(FilterString);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
if (GridEntries != null)
GridEntries.CollectionChanged += GridEntries_CollectionChanged;
GridEntries_CollectionChanged();
}
@@ -193,7 +223,7 @@ namespace LibationAvalonia.ViewModels
{
foreach (var removed in removedBooks.Cast<IGridEntry>().Concat(removedSeries).Where(b => b is not null).ToList())
{
if (GridEntries.PassesFilter(removed))
if (GridEntries?.PassesFilter(removed) ?? false)
GridEntries.Remove(removed);
else
{
@@ -204,7 +234,7 @@ namespace LibationAvalonia.ViewModels
}
}
private void UpsertBook(LibraryBook book, ILibraryBookEntry existingBookEntry)
private void UpsertBook(LibraryBook book, ILibraryBookEntry? existingBookEntry)
{
if (existingBookEntry is null)
// Add the new product to top
@@ -214,7 +244,7 @@ namespace LibationAvalonia.ViewModels
existingBookEntry.UpdateLibraryBook(book);
}
private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
private void UpsertEpisode(LibraryBook episodeBook, ILibraryBookEntry? existingEpisodeEntry, List<ISeriesEntry> seriesEntries, IEnumerable<LibraryBook> dbBooks)
{
if (existingEpisodeEntry is null)
{
@@ -264,10 +294,13 @@ namespace LibationAvalonia.ViewModels
private async Task refreshGrid()
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
if (GridEntries != null)
{
if (GridEntries.IsEditingItem)
await Dispatcher.UIThread.InvokeAsync(GridEntries.CommitEdit);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
await Dispatcher.UIThread.InvokeAsync(GridEntries.Refresh);
}
}
public async Task ToggleSeriesExpanded(ISeriesEntry seriesEntry)
@@ -281,7 +314,7 @@ namespace LibationAvalonia.ViewModels
#region Filtering
public async Task Filter(string searchString)
public async Task Filter(string? searchString)
{
FilterString = searchString;
@@ -305,7 +338,7 @@ namespace LibationAvalonia.ViewModels
return FilteredInGridEntries.Contains(item);
}
private async void SearchEngineCommands_SearchEngineUpdated(object sender, EventArgs e)
private async void SearchEngineCommands_SearchEngineUpdated(object? sender, EventArgs? e)
{
var filterResults = SOURCE.FilterEntries(FilterString);
@@ -348,9 +381,9 @@ namespace LibationAvalonia.ViewModels
foreach (var book in selectedBooks)
book.PropertyChanged -= GridEntry_PropertyChanged;
void BindingList_CollectionChanged(object sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs e)
void BindingList_CollectionChanged(object? sender, System.Collections.Specialized.NotifyCollectionChangedEventArgs? e)
{
if (e.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
if (e?.Action != System.Collections.Specialized.NotifyCollectionChangedAction.Reset)
return;
//After DisplayBooks() re-creates the list,
@@ -362,7 +395,8 @@ namespace LibationAvalonia.ViewModels
GridEntries.CollectionChanged -= BindingList_CollectionChanged;
}
GridEntries.CollectionChanged += BindingList_CollectionChanged;
if (GridEntries != null)
GridEntries.CollectionChanged += BindingList_CollectionChanged;
//The RemoveBooksAsync will fire LibrarySizeChanged, which calls ProductsDisplay2.Display(),
//so there's no need to remove books from the grid display here.
@@ -414,9 +448,9 @@ namespace LibationAvalonia.ViewModels
}
}
private void GridEntry_PropertyChanged(object sender, PropertyChangedEventArgs e)
private void GridEntry_PropertyChanged(object? sender, PropertyChangedEventArgs? e)
{
if (e.PropertyName == nameof(IGridEntry.Remove) && sender is ILibraryBookEntry)
if (e?.PropertyName == nameof(IGridEntry.Remove) && sender is ILibraryBookEntry)
{
int removeCount = GetAllBookEntries().Count(lbe => lbe.Remove is true);
RemovableCountChanged?.Invoke(this, removeCount);

View File

@@ -3,17 +3,18 @@ using LibationUiBase.GridView;
using System.ComponentModel;
using System.Reflection;
#nullable enable
namespace LibationAvalonia.ViewModels
{
internal class RowComparer : RowComparerBase
{
private static readonly PropertyInfo HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo? HeaderCellPi = typeof(DataGridColumn).GetProperty("HeaderCell", BindingFlags.NonPublic | BindingFlags.Instance);
private static readonly PropertyInfo? CurrentSortingStatePi = typeof(DataGridColumnHeader).GetProperty("CurrentSortingState", BindingFlags.NonPublic | BindingFlags.Instance);
private DataGridColumn Column { get; init; }
public override string PropertyName { get; set; }
private DataGridColumn? Column { get; }
public override string? PropertyName { get; set; }
public RowComparer(DataGridColumn column)
public RowComparer(DataGridColumn? column)
{
Column = column;
PropertyName = Column?.SortMemberPath ?? nameof(IGridEntry.DateAdded);
@@ -22,7 +23,7 @@ namespace LibationAvalonia.ViewModels
//Avalonia doesn't expose the column's CurrentSortingState, so we must get it through reflection
protected override ListSortDirection GetSortOrder()
=> Column is null ? ListSortDirection.Descending
: CurrentSortingStatePi.GetValue(HeaderCellPi.GetValue(Column)) is ListSortDirection lsd ? lsd
: CurrentSortingStatePi?.GetValue(HeaderCellPi?.GetValue(Column)) is ListSortDirection lsd ? lsd
: ListSortDirection.Descending;
}
}

View File

@@ -8,6 +8,7 @@ using ReactiveUI;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.ViewModels.Settings
{
public class AudioSettingsVM : ViewModelBase
@@ -23,25 +24,23 @@ namespace LibationAvalonia.ViewModels.Settings
private string _chapterTitleTemplate;
public EnumDiaplay<SampleRate> SelectedSampleRate { get; set; }
public NAudio.Lame.EncoderQuality SelectedEncoderQuality { get; set; }
public AvaloniaList<EnumDiaplay<SampleRate>> SampleRates { get; }
= new(Enum.GetValues<SampleRate>().Select(v => new EnumDiaplay<SampleRate>(v, $"{(int)v} Hz")));
= new(Enum.GetValues<SampleRate>()
.Where(r => r >= SampleRate.Hz_8000 && r <= SampleRate.Hz_48000)
.Select(v => new EnumDiaplay<SampleRate>(v, $"{(int)v} Hz")));
public AvaloniaList<NAudio.Lame.EncoderQuality> EncoderQualities { get; }
= new(
new[]
{
NAudio.Lame.EncoderQuality.High,
NAudio.Lame.EncoderQuality.Standard,
NAudio.Lame.EncoderQuality.Fast,
NAudio.Lame.EncoderQuality.High,
NAudio.Lame.EncoderQuality.Standard,
NAudio.Lame.EncoderQuality.Fast,
});
public AudioSettingsVM(Configuration config)
{
LoadSettings(config);
}
public void LoadSettings(Configuration config)
{
CreateCueSheet = config.CreateCueSheet;
CombineNestedChapterTitles = config.CombineNestedChapterTitles;
@@ -55,7 +54,7 @@ namespace LibationAvalonia.ViewModels.Settings
MergeOpeningAndEndCredits = config.MergeOpeningAndEndCredits;
StripAudibleBrandAudio = config.StripAudibleBrandAudio;
StripUnabridged = config.StripUnabridged;
ChapterTitleTemplate = config.ChapterTitleTemplate;
_chapterTitleTemplate = config.ChapterTitleTemplate;
DecryptToLossy = config.DecryptToLossy;
MoveMoovToBeginning = config.MoveMoovToBeginning;
LameTargetBitrate = config.LameTargetBitrate;
@@ -65,7 +64,7 @@ namespace LibationAvalonia.ViewModels.Settings
LameBitrate = config.LameBitrate;
LameVBRQuality = config.LameVBRQuality;
SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate);
SelectedSampleRate = SampleRates.SingleOrDefault(s => s.Value == config.MaxSampleRate) ?? SampleRates[0];
SelectedEncoderQuality = config.LameEncoderQuality;
}
@@ -113,21 +112,28 @@ namespace LibationAvalonia.ViewModels.Settings
public string StripUnabridgedText { get; } = Configuration.GetDescription(nameof(Configuration.StripUnabridged));
public string ChapterTitleTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public string MoveMoovToBeginningText { get; } = Configuration.GetDescription(nameof(Configuration.MoveMoovToBeginning));
public string MoveMoovToBeginningTip => Configuration.GetHelpText(nameof(MoveMoovToBeginning));
public bool CreateCueSheet { get; set; }
public bool CombineNestedChapterTitles { get; set; }
public bool DownloadCoverArt { get; set; }
public bool RetainAaxFile { get; set; }
public string RetainAaxFileTip => Configuration.GetHelpText(nameof(RetainAaxFile));
public bool DownloadClipsBookmarks { get => _downloadClipsBookmarks; set => this.RaiseAndSetIfChanged(ref _downloadClipsBookmarks, value); }
public Configuration.DownloadQuality FileDownloadQuality { get; set; }
public Configuration.ClipBookmarkFormat ClipBookmarkFormat { get; set; }
public bool MergeOpeningAndEndCredits { get; set; }
public string MergeOpeningAndEndCreditsTip => Configuration.GetHelpText(nameof(MergeOpeningAndEndCredits));
public bool StripAudibleBrandAudio { get; set; }
public string StripAudibleBrandAudioTip => Configuration.GetHelpText(nameof(StripAudibleBrandAudio));
public bool StripUnabridged { get; set; }
public string StripUnabridgedTip => Configuration.GetHelpText(nameof(StripUnabridged));
public bool DecryptToLossy { get => _decryptToLossy; set => this.RaiseAndSetIfChanged(ref _decryptToLossy, value); }
public string DecryptToLossyTip => Configuration.GetHelpText(nameof(DecryptToLossy));
public bool MoveMoovToBeginning { get; set; }
public bool LameDownsampleMono { get; set; } = Design.IsDesignMode;
public string LameDownsampleMonoTip => Configuration.GetHelpText(nameof(LameDownsampleMono));
public bool LameConstantBitrate { get; set; } = Design.IsDesignMode;
public bool SplitFilesByChapter { get => _splitFilesByChapter; set { this.RaiseAndSetIfChanged(ref _splitFilesByChapter, value); } }

View File

@@ -3,6 +3,7 @@ using LibationFileManager;
using ReactiveUI;
using System.Collections.Generic;
#nullable enable
namespace LibationAvalonia.ViewModels.Settings
{
public class DownloadDecryptSettingsVM : ViewModelBase
@@ -15,32 +16,28 @@ namespace LibationAvalonia.ViewModels.Settings
public DownloadDecryptSettingsVM(Configuration config)
{
Config = config;
LoadSettings(config);
BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask;
BadBookAbort = config.BadBook is Configuration.BadBookAction.Abort;
BadBookRetry = config.BadBook is Configuration.BadBookAction.Retry;
BadBookIgnore = config.BadBook is Configuration.BadBookAction.Ignore;
_folderTemplate = config.FolderTemplate;
_fileTemplate = config.FileTemplate;
_chapterFileTemplate = config.ChapterFileTemplate;
InProgressDirectory = config.InProgress;
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
SaveMetadataToFile = config.SaveMetadataToFile;
}
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
{
Configuration.KnownDirectories.WinTemp,
Configuration.KnownDirectories.ApplicationData,
Configuration.KnownDirectories.UserProfile,
Configuration.KnownDirectories.AppDir,
Configuration.KnownDirectories.MyDocs,
Configuration.KnownDirectories.LibationFiles
};
public void LoadSettings(Configuration config)
{
BadBookAsk = config.BadBook is Configuration.BadBookAction.Ask;
BadBookAbort = config.BadBook is Configuration.BadBookAction.Abort;
BadBookRetry = config.BadBook is Configuration.BadBookAction.Retry;
BadBookIgnore = config.BadBook is Configuration.BadBookAction.Ignore;
FolderTemplate = config.FolderTemplate;
FileTemplate = config.FileTemplate;
ChapterFileTemplate = config.ChapterFileTemplate;
InProgressDirectory = config.InProgress;
UseCoverAsFolderIcon = config.UseCoverAsFolderIcon;
SaveMetadataToFile = config.SaveMetadataToFile;
}
public void SaveSettings(Configuration config)
{
config.BadBook
@@ -61,10 +58,10 @@ namespace LibationAvalonia.ViewModels.Settings
public string UseCoverAsFolderIconText { get; } = Configuration.GetDescription(nameof(Configuration.UseCoverAsFolderIcon));
public string SaveMetadataToFileText { get; } = Configuration.GetDescription(nameof(Configuration.SaveMetadataToFile));
public string BadBookGroupboxText { get; } = Configuration.GetDescription(nameof(Configuration.BadBook));
public string BadBookAskText { get; } = Configuration.BadBookAction.Ask.GetDescription();
public string BadBookAbortText { get; } = Configuration.BadBookAction.Abort.GetDescription();
public string BadBookRetryText { get; } = Configuration.BadBookAction.Retry.GetDescription();
public string BadBookIgnoreText { get; } = Configuration.BadBookAction.Ignore.GetDescription();
public string BadBookAskText { get; } = Configuration.BadBookAction.Ask.GetDescription() ?? nameof(Configuration.BadBookAction.Ask);
public string BadBookAbortText { get; } = Configuration.BadBookAction.Abort.GetDescription() ?? nameof(Configuration.BadBookAction.Abort);
public string BadBookRetryText { get; } = Configuration.BadBookAction.Retry.GetDescription() ?? nameof(Configuration.BadBookAction.Retry);
public string BadBookIgnoreText { get; } = Configuration.BadBookAction.Ignore.GetDescription() ?? nameof(Configuration.BadBookAction.Ignore);
public string FolderTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public string FileTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.FileTemplate));
public string ChapterFileTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));

View File

@@ -1,15 +1,11 @@
using LibationFileManager;
#nullable enable
namespace LibationAvalonia.ViewModels.Settings
{
public class ImportSettingsVM
{
public ImportSettingsVM(Configuration config)
{
LoadSettings(config);
}
public void LoadSettings(Configuration config)
{
AutoScan = config.AutoScan;
ShowImportedStats = config.ShowImportedStats;

View File

@@ -7,6 +7,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationAvalonia.ViewModels.Settings
{
public class ImportantSettingsVM : ViewModelBase
@@ -18,12 +19,8 @@ namespace LibationAvalonia.ViewModels.Settings
public ImportantSettingsVM(Configuration config)
{
this.config = config;
LoadSettings(config);
}
public void LoadSettings(Configuration config)
{
BooksDirectory = config.Books.PathWithoutPrefix;
BooksDirectory = config.Books?.PathWithoutPrefix ?? Configuration.DefaultBooksDirectory;
SavePodcastsToParentFolder = config.SavePodcastsToParentFolder;
OverwriteExisting = config.OverwriteExisting;
CreationTime = DateTimeSources.SingleOrDefault(v => v.Value == config.CreationTime) ?? DateTimeSources[0];
@@ -32,9 +29,9 @@ namespace LibationAvalonia.ViewModels.Settings
GridScaleFactor = scaleFactorToLinearRange(config.GridScaleFactor);
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
ThemeVariant = initialThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant));
if (string.IsNullOrWhiteSpace(initialThemeVariant))
ThemeVariant = initialThemeVariant = "System";
themeVariant = initialThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) ?? "";
if (string.IsNullOrWhiteSpace(initialThemeVariant))
themeVariant = initialThemeVariant = "System";
}
public void SaveSettings(Configuration config)
@@ -67,7 +64,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));
@@ -99,12 +97,18 @@ namespace LibationAvalonia.ViewModels.Settings
get => themeVariant;
set
{
var changed = !value.Equals(themeVariant);
this.RaiseAndSetIfChanged(ref themeVariant, value);
SelectionChanged = ThemeVariant != initialThemeVariant;
this.RaisePropertyChanged(nameof(SelectionChanged));
if (changed && App.Current is Avalonia.Application app)
app.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

@@ -1,6 +1,9 @@
using AudibleUtilities;
using Avalonia.Input;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
using DataLayer;
using FileManager;
using LibationAvalonia.ViewModels;
using LibationFileManager;
using LibationUiBase.GridView;
@@ -8,22 +11,22 @@ 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);
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
InitializeComponent();
Configure_Upgrade();
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) });
@@ -35,6 +38,62 @@ namespace LibationAvalonia.Views
}
}
private void AudibleApiStorage_LoadError(object sender, AccountSettingsLoadErrorEventArgs e)
{
try
{
//Backup AccountSettings.json and create a new, empty file.
var backupFile =
FileUtility.SaferMoveToValidPath(
e.SettingsFilePath,
e.SettingsFilePath,
ReplacementCharacters.Barebones,
"bak");
AudibleApiStorage.EnsureAccountsSettingsFileExists();
e.Handled = true;
showAccountSettingsRecoveredMessage(backupFile);
}
catch
{
showAccountSettingsUnrecoveredMessage();
}
async void showAccountSettingsRecoveredMessage(LongPath backupFile)
=> await MessageBox.Show(this, $"""
Libation could not load your account settings, so it had created a new, empty account settings file.
You will need to re-add you Audible account(s) before scanning or downloading.
The old account settings file has been archived at '{backupFile.PathWithoutPrefix}'
{e.GetException().ToString()}
""",
"Error Loading Account Settings",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
void showAccountSettingsUnrecoveredMessage()
{
var messageBoxWindow = MessageBox.Show(this, $"""
Libation could not load your account settings. The file may be corrupted, but Libation is unable to delete it.
Please move or delete the account settings file '{e.SettingsFilePath}'
{e.GetException().ToString()}
""",
"Error Loading Account Settings",
MessageBoxButtons.OK);
//Force the message box to show synchronously because we're not handling the exception
//and libation will crash after the event handler returns
var frame = new DispatcherFrame();
_ = messageBoxWindow.ContinueWith(static (_, s) => ((DispatcherFrame)s).Continue = false, frame);
Dispatcher.UIThread.PushFrame(frame);
messageBoxWindow.GetAwaiter().GetResult();
}
}
private async void MainWindow_Opened(object sender, EventArgs e)
{
if (Configuration.Instance.FirstLaunch)
@@ -56,21 +115,24 @@ 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)
{
//Get the ViewModel before crossing the await boundary
var vm = ViewModel;
if (QuickFilters.UseDefault)
await vm.PerformFilter(QuickFilters.Filters.FirstOrDefault());
await Task.WhenAll(
vm.SetBackupCountsAsync(initialLibrary),
Task.Run(() => vm.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,10 +6,20 @@
x:DataType="vm:ProcessBookViewModel"
mc:Ignorable="d" d:DesignWidth="400" d:DesignHeight="87" MaxHeight="87" MinHeight="87" MinWidth="300"
x:Class="LibationAvalonia.Views.ProcessBookControl" Background="{CompiledBinding BackgroundColor}">
<Border BorderBrush="{DynamicResource SystemControlForegroundBaseMediumBrush}" BorderThickness="0,0,0,1">
<UserControl.Styles>
<Style Selector="Border#QueuedItemBorder:not(:pointerover) Button">
<Setter Property="IsVisible" Value="False" />
</Style>
<Style Selector="Border#QueuedItemBorder:pointerover Button">
<Setter Property="IsVisible" Value="True" />
</Style>
</UserControl.Styles>
<Border Name="QueuedItemBorder" Background="Transparent" BorderBrush="{DynamicResource SystemControlForegroundBaseMediumBrush}" BorderThickness="0,0,0,1">
<Grid ColumnDefinitions="Auto,*,Auto">
<Panel Grid.Column="0" Margin="3" Width="80" Height="80" HorizontalAlignment="Left">
<Image Width="80" Height="80" Source="{CompiledBinding Cover}" Stretch="Uniform" />
</Panel>
@@ -29,7 +39,7 @@
<TextBlock IsVisible="{CompiledBinding !IsDownloading}" Text="{CompiledBinding StatusText}"/>
</Panel>
</Grid>
<Grid Margin="3" Grid.Column="2" HorizontalAlignment="Right" ColumnDefinitions="Auto,Auto">
<Grid Name="ButtonsGrid" Margin="3" Grid.Column="2" HorizontalAlignment="Right" ColumnDefinitions="Auto,Auto">
<Grid.Styles>
<Style Selector="Button">
<Setter Property="Padding" Value="0,1,0,1" />
@@ -42,22 +52,22 @@
</Style>
</Grid.Styles>
<StackPanel IsVisible="{CompiledBinding Queued}" Grid.Column="0" VerticalAlignment="Center" HorizontalAlignment="Right" Orientation="Vertical">
<Button Click="MoveFirst_Click">
<Button ToolTip.Tip="Move book to top of queue" Click="MoveFirst_Click">
<Path VerticalAlignment="Top" Data="{StaticResource FirstButtonIcon}" />
</Button>
<Button Click="MoveUp_Click">
<Button ToolTip.Tip="Move book up in queue" Click="MoveUp_Click">
<Path VerticalAlignment="Top" Data="{StaticResource UpButtonIcon}" />
</Button>
<Button Click="MoveDown_Click">
<Button ToolTip.Tip="Move book down in queue" Click="MoveDown_Click">
<Path VerticalAlignment="Bottom" Data="{StaticResource DownButtonIcon}" />
</Button>
<Button Click="MoveLast_Click">
<Button ToolTip.Tip="Move book to bottom of queue" Click="MoveLast_Click">
<Path VerticalAlignment="Bottom" Data="{StaticResource LastButtonIcon}" />
</Button>
</StackPanel>
<Panel Margin="3,0,0,0" Grid.Column="1" VerticalAlignment="Top">
<Button Height="32" Background="{DynamicResource CancelRed}" Width="22" IsVisible="{CompiledBinding !IsFinished}" CornerRadius="11" Click="Cancel_Click">
<Panel Margin="3,0,0,0" Grid.Column="1" VerticalAlignment="Top" IsVisible="{CompiledBinding !IsFinished}">
<Button Height="32" Background="{DynamicResource CancelRed}" Width="22" CornerRadius="11" Click="Cancel_Click">
<Path Fill="{DynamicResource ProcessQueueBookDefaultBrush}" VerticalAlignment="Center" Data="{StaticResource CancelButtonIcon}" RenderTransform="{StaticResource Rotate45Transform}" />
</Button>
</Panel>
@@ -65,7 +75,7 @@
<Panel Margin="3" Width="50" Grid.Column="2">
<TextPresenter FontSize="9" VerticalAlignment="Bottom" HorizontalAlignment="Right" IsVisible="{CompiledBinding IsDownloading}" Text="{CompiledBinding ETA}" />
</Panel>
</Grid>
</Border>
</UserControl>

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

@@ -6,12 +6,13 @@ namespace LibationFileManager
{
public partial class Configuration
{
public static ReadOnlyDictionary<string, string> HelpText { get; } = new Dictionary<string, string>
private static ReadOnlyDictionary<string, string> HelpText { get; } = new Dictionary<string, string>
{
{ nameof(CombineNestedChapterTitles),"""
If the book has nested chapters, e.g. a chapter named "Part 1"
that contains chapters "Chapter 1" and "Chapter 2", then combine
the chapter titles like the following example:
{nameof(CombineNestedChapterTitles),"""
If the book has nested chapters, e.g. a chapter named
"Part 1" that contains chapters "Chapter 1" and
"Chapter 2", then combine the chapter titles like the
following example:
Part 1: Chapter 1
Part 1: Chapter 2
@@ -19,7 +20,7 @@ namespace LibationFileManager
{nameof(AllowLibationFixup), """
In addition to the options that are enabled if you allow
"fixing up" the audiobook, it does the following:
* Sets the ©gen metadata tag for the genres.
* Adds the TCOM (@wrt in M4B files) metadata tag for the narrators.
* Unescapes the copyright symbol (replace &#169; with ©)
@@ -27,10 +28,64 @@ namespace LibationFileManager
* Adds various other metadata tags recognized by AudiobookShelf
* Sets the embedded cover art image with cover art retrieved from Audible
""" },
{nameof(MoveMoovToBeginning), """
Moves the mpeg 'moov' box to the beginning of the file.
Using this option will generally make the audiobook load
faster, and will make streaming the file over the internet
faster.
This is an extra operation performed after the m4b file
has been created, and the speed of it can vary greatly
depending on how fast Libation can read and write from the
book storage location.
""" },
{nameof(LameDownsampleMono), """
Most "stereo" audiobooks just duplicate the same audio
for both channels, so you can save on storage size and
decrease encoding time by only using one audio channel.
""" },
{nameof(DecryptToLossy), """
Audible delivers its audiobooks in the mpeg-4 audio
file format (aka M4B). If you choose the "Lossless"
option, Libation will leave the original Audible audio
untouched. If you choose "MP3", Libation will re-
encode the audio as an MP3 using the settings below.
Note that podcasts are usually delivered as MP3s.
""" },
{nameof(MergeOpeningAndEndCredits), """
This setting only affects the chapter metadata.
In most audiobooks, the first chapter is "Opening
Credits" and the last chapter is "End Credits".
Enabling this option will remove the credits chapter
markers and shift the adjacent chapter markers to
fill the space.
""" },
{nameof(RetainAaxFile), """
Libation will keep the Audible source aax file
and move it to the book's destination directory.
Libation will also create a .key file containing
the decryption key and IV.
""" },
{nameof(StripUnabridged), """
Many audiobooks contain "(Unabridged)" in the title.
Enabling this option will remove that text from the
Title and Album metadata tags.
""" },
{nameof(StripAudibleBrandAudio), """
All audiobooks begin and end with a few seconds of
Audible branding audio. In English it's "This is
Audible" and "Audible hopes you have enjoyed this
program".
Enabling this option will remove that branded audio
from the decrypted audiobook. This does not require
re-encoding.
""" },
}
.AsReadOnly();
public static string? GetHelpText(string settingName)
=> HelpText.TryGetValue(settingName, out var value) ? value : null;
}
public static string GetHelpText(string? settingName)
=> settingName != null && HelpText.TryGetValue(settingName, out var value) ? value : "";
}
}

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

@@ -82,7 +82,7 @@ namespace LibationFileManager
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public static string? GetDescription(string propertyName)
public static string GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
@@ -90,7 +90,7 @@ namespace LibationFileManager
.SingleOrDefault()
as DescriptionAttribute;
return attribute?.Description;
return attribute?.Description ?? $"[{propertyName}]";
}
public bool Exists(string propertyName) => Settings.Exists(propertyName);
@@ -118,12 +118,15 @@ namespace LibationFileManager
// 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
public string InProgress
{
get
{
var tempDir = GetString();
return string.IsNullOrWhiteSpace(tempDir) ? WinTemp : tempDir;
}
set => SetString(value); }
set => SetString(value);
}
[Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup { get => GetNonString(defaultValue: true); set => SetNonString(value); }
@@ -162,10 +165,10 @@ namespace LibationFileManager
public NAudio.Lame.EncoderQuality LameEncoderQuality { get => GetNonString(defaultValue: NAudio.Lame.EncoderQuality.High); set => SetNonString(value); }
[Description("Lame encoder downsamples to mono")]
public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); }
public bool LameDownsampleMono { get => GetNonString(defaultValue: true); set => SetNonString(value); }
[Description("Lame target bitrate [16,320]")]
public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); }
public int LameBitrate { get => GetNonString(defaultValue: 64); set => SetNonString(value); }
[Description("Restrict encoder to constant bitrate?")]
public bool LameConstantBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); }
@@ -179,8 +182,8 @@ namespace LibationFileManager
private static readonly EquatableDictionary<string, bool> DefaultColumns = new(
new KeyValuePair<string, bool>[]
{
new ("SeriesOrder", false),
new ("LastDownload", false)
new ("SeriesOrder", false),
new ("LastDownload", false)
});
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
@@ -200,7 +203,7 @@ namespace LibationFileManager
[Description("Download clips and bookmarks?")]
public bool DownloadClipsBookmarks { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("File format to save clips and bookmarks")]
public ClipBookmarkFormat ClipsBookmarksFileFormat { get => GetNonString(defaultValue: ClipBookmarkFormat.CSV); set => SetNonString(value); }

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,98 @@ 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))
{
var entryToRemove = matchingFiles[i];
matchingFiles.RemoveAt(i);
cacheChanged |= Remove(entryToRemove);
}
}
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
{
var entryToRemove = matchingFiles[i];
matchingFiles.RemoveAt(i);
cacheChanged |= Remove(entryToRemove);
}
}
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 +112,7 @@ namespace LibationFileManager
public static void Insert(CacheEntry entry)
{
cache.Add(entry);
Cache.Add(entry.Id, entry);
Inserted?.Invoke(null, entry);
save();
}
@@ -102,7 +122,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 +132,56 @@ 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();
private static object lockObject = 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)
{
lock (lockObject)
{
if (Dictionary.TryGetValue(id, out List<TEntry>? entries))
{
var removed = entries?.Remove(entry) ?? false;
if (removed && entries?.Count == 0)
{
Dictionary.Remove(id, out _);
}
return removed;
}
else return false;
}
}
}
}
}

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

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.0" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="9.0.2" />
<PackageReference Include="NameParserSharp" Version="1.5.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.4.0" />
</ItemGroup>

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)
@@ -46,13 +46,14 @@ namespace LibationFileManager
// Note that records overload equality automagically, so should be able to
// compare these the same way as comparing simple strings.
public record NamedFilter(string Filter, string Name)
public record NamedFilter(string Filter, string? Name)
{
public string Filter { get; set; } = Filter;
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

@@ -53,7 +53,13 @@ namespace LibationUiBase.GridView
public bool IsSeries { get; }
public bool IsEpisode { get; }
public bool IsBook => !IsSeries && !IsEpisode;
public bool IsUnavailable => !IsSeries & isAbsent & (BookStatus is not LiberatedStatus.Liberated || PdfStatus is not null and not LiberatedStatus.Liberated);
public bool IsUnavailable
=> !IsSeries
& isAbsent
& (
BookStatus is not LiberatedStatus.Liberated
|| PdfStatus is not null and not LiberatedStatus.Liberated
);
public double Opacity => !IsSeries && Book.UserDefinedItem.Tags.ContainsInsensitive("hidden") ? 0.4 : 1;
public abstract object BackgroundBrush { get; }
public object ButtonImage => GetLiberateIcon();

View File

@@ -33,37 +33,29 @@ namespace LibationUiBase.GridView
LoadCover();
}
/// <summary>
/// Creates <see cref="LibraryBookEntry{TStatus}"/> for all non-episode books in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
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

@@ -2,6 +2,7 @@
using DataLayer;
using System;
using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq;
namespace LibationUiBase.GridView
@@ -47,9 +48,11 @@ namespace LibationUiBase.GridView
otherSet is not null &&
searchSet.Intersect(otherSet).Count() != searchSet.Count);
public static HashSet<IGridEntry>? FilterEntries(this IEnumerable<IGridEntry> entries, string searchString)
[return: NotNullIfNotNull(nameof(searchString))]
public static HashSet<IGridEntry>? FilterEntries(this IEnumerable<IGridEntry> entries, string? searchString)
{
if (string.IsNullOrEmpty(searchString)) return null;
if (string.IsNullOrEmpty(searchString))
return null;
var searchResultSet = SearchEngineCommands.Search(searchString);

View File

@@ -2,6 +2,7 @@
using System.Collections.Generic;
using System.ComponentModel;
#nullable enable
namespace LibationUiBase.GridView
{
/// <summary>
@@ -11,67 +12,10 @@ namespace LibationUiBase.GridView
/// </summary>
public abstract class RowComparerBase : IComparer, IComparer<IGridEntry>, IComparer<object>
{
public abstract string PropertyName { get; set; }
public abstract string? PropertyName { get; set; }
public int Compare(object x, object y)
{
if (x is null && y is not null) return -1;
if (x is not null && y is null) return 1;
if (x is null && y is null) return 0;
var geA = (IGridEntry)x;
var geB = (IGridEntry)y;
var sortDirection = GetSortOrder();
ISeriesEntry parentA = null;
ISeriesEntry parentB = null;
if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA)
parentA = seA;
if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB)
parentB = seB;
//both a and b are top-level grid entries
if (parentA is null && parentB is null)
return InternalCompare(geA, geB);
//a is top-level, b is a child
if (parentA is null && parentB is not null)
{
// b is a child of a, parent is always first
if (parentB == geA)
return sortDirection is ListSortDirection.Ascending ? -1 : 1;
else
return InternalCompare(geA, parentB);
}
//a is a child, b is a top-level
if (parentA is not null && parentB is null)
{
// a is a child of b, parent is always first
if (parentA == geB)
return sortDirection is ListSortDirection.Ascending ? 1 : -1;
else
return InternalCompare(parentA, geB);
}
//both are children of the same series
if (parentA == parentB)
{
//Podcast episodes usually all have the same PurchaseDate and DateAdded property:
//the date that the series was added to the library. So when sorting by PurchaseDate
//and DateAdded, compare SeriesOrder instead..
return PropertyName switch
{
nameof(IGridEntry.DateAdded) or nameof (IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder),
_ => InternalCompare(geA, geB),
};
}
//a and b are children of different series.
return InternalCompare(parentA, parentB);
}
public int Compare(object? x, object? y)
=> Compare(x as IGridEntry, y as IGridEntry);
protected abstract ListSortDirection GetSortOrder();
@@ -80,17 +24,74 @@ namespace LibationUiBase.GridView
var val1 = x.GetMemberValue(PropertyName);
var val2 = y.GetMemberValue(PropertyName);
var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2);
var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2);
return compare == 0 && x.Liberate.IsSeries && y.Liberate.IsSeries
//Both a and b are series parents and compare as equal, so break the tie.
? x.AudibleProductId.CompareTo(y.AudibleProductId)
: compare;
}
public int Compare(IGridEntry x, IGridEntry y)
public int Compare(IGridEntry? geA, IGridEntry? geB)
{
return Compare((object)x, y);
if (geA is null && geB is not null) return -1;
if (geA is not null && geB is null) return 1;
if (geA is null || geB is null) return 0;
var sortDirection = GetSortOrder();
ISeriesEntry? parentA = null;
ISeriesEntry? parentB = null;
if (geA is ILibraryBookEntry lbA && lbA.Parent is ISeriesEntry seA)
parentA = seA;
if (geB is ILibraryBookEntry lbB && lbB.Parent is ISeriesEntry seB)
parentB = seB;
//both entries are children
if (parentA != null && parentB != null)
{
//both are children of the same series
if (parentA == parentB)
{
//Podcast episodes usually all have the same PurchaseDate and DateAdded property:
//the date that the series was added to the library. So when sorting by PurchaseDate
//and DateAdded, compare SeriesOrder instead..
return PropertyName switch
{
nameof(IGridEntry.DateAdded) or nameof(IGridEntry.PurchaseDate) => geA.SeriesOrder.CompareTo(geB.SeriesOrder),
_ => InternalCompare(geA, geB),
};
}
else
//a and b are children of different series.
return InternalCompare(parentA, parentB);
}
//a is top-level, b is a child
else if (parentA is null && parentB is not null)
{
// b is a child of a, parent is always first
if (parentB == geA)
return sortDirection is ListSortDirection.Ascending ? -1 : 1;
else
return InternalCompare(geA, parentB);
}
//a is a child, b is a top-level
else if (parentA is not null && parentB is null)
{
// a is a child of b, parent is always first
if (parentA == geB)
return sortDirection is ListSortDirection.Ascending ? 1 : -1;
else
return InternalCompare(parentA, geB);
}
//parentA and parentB are null
else
{
//both a and b are top-level grid entries
return InternalCompare(geA, geB);
}
}
}
}

View File

@@ -1,6 +1,5 @@
using DataLayer;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.Linq;
using System.Threading;
@@ -57,58 +56,63 @@ namespace LibationUiBase.GridView
LoadCover();
}
/// <summary>
/// Creates <see cref="SeriesEntry{TStatus}"/> for all episodic series in an enumeration of <see cref="LibraryBook"/>.
/// </summary>
/// <remarks>Can be called from any thread, but requires the calling thread's <see cref="SynchronizationContext.Current"/> to be valid.</remarks>
public static async Task<List<ISeriesEntry>> GetAllSeriesEntriesAsync(IEnumerable<LibraryBook> libraryBooks)
{
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

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.6" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.7" />
</ItemGroup>
<ItemGroup>

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

@@ -23,6 +23,13 @@ namespace LibationWinForms.Dialogs
toolTip.SetToolTip(combineNestedChapterTitlesCbox, Configuration.GetHelpText(nameof(config.CombineNestedChapterTitles)));
toolTip.SetToolTip(allowLibationFixupCbox, Configuration.GetHelpText(nameof(config.AllowLibationFixup)));
toolTip.SetToolTip(moveMoovAtomCbox, Configuration.GetHelpText(nameof(config.MoveMoovToBeginning)));
toolTip.SetToolTip(lameDownsampleMonoCbox, Configuration.GetHelpText(nameof(config.LameDownsampleMono)));
toolTip.SetToolTip(convertLosslessRb, Configuration.GetHelpText(nameof(config.DecryptToLossy)));
toolTip.SetToolTip(convertLossyRb, Configuration.GetHelpText(nameof(config.DecryptToLossy)));
toolTip.SetToolTip(mergeOpeningEndCreditsCbox, Configuration.GetHelpText(nameof(config.MergeOpeningAndEndCredits)));
toolTip.SetToolTip(retainAaxFileCbox, Configuration.GetHelpText(nameof(config.RetainAaxFile)));
toolTip.SetToolTip(stripAudibleBrandingCbox, Configuration.GetHelpText(nameof(config.StripAudibleBrandAudio)));
fileDownloadQualityCb.Items.AddRange(
new object[]
@@ -41,6 +48,7 @@ namespace LibationWinForms.Dialogs
maxSampleRateCb.Items.AddRange(
Enum.GetValues<AAXClean.SampleRate>()
.Where(r => r >= AAXClean.SampleRate.Hz_8000 && r <= AAXClean.SampleRate.Hz_48000)
.Select(v => new EnumDiaplay<AAXClean.SampleRate>(v, $"{(int)v} Hz"))
.ToArray());
@@ -151,6 +159,7 @@ namespace LibationWinForms.Dialogs
private void convertFormatRb_CheckedChanged(object sender, EventArgs e)
{
moveMoovAtomCbox.Enabled = convertLosslessRb.Checked;
lameOptionsGb.Enabled = !convertLosslessRb.Checked;
lameTargetRb_CheckedChanged(sender, e);
LameMatchSourceBRCbox_CheckedChanged(sender, e);
}

View File

@@ -84,13 +84,13 @@
folderTemplateTb = new System.Windows.Forms.TextBox();
folderTemplateLbl = new System.Windows.Forms.Label();
tab4AudioFileOptions = new System.Windows.Forms.TabPage();
moveMoovAtomCbox = new System.Windows.Forms.CheckBox();
fileDownloadQualityCb = new System.Windows.Forms.ComboBox();
fileDownloadQualityLbl = new System.Windows.Forms.Label();
combineNestedChapterTitlesCbox = new System.Windows.Forms.CheckBox();
clipsBookmarksFormatCb = new System.Windows.Forms.ComboBox();
downloadClipsBookmarksCbox = new System.Windows.Forms.CheckBox();
audiobookFixupsGb = new System.Windows.Forms.GroupBox();
moveMoovAtomCbox = new System.Windows.Forms.CheckBox();
stripUnabridgedCbox = new System.Windows.Forms.CheckBox();
chapterTitleTemplateGb = new System.Windows.Forms.GroupBox();
chapterTitleTemplateBtn = new System.Windows.Forms.Button();
@@ -111,7 +111,6 @@
label11 = new System.Windows.Forms.Label();
label3 = new System.Windows.Forms.Label();
lameBitrateTb = new System.Windows.Forms.TrackBar();
label1 = new System.Windows.Forms.Label();
lameQualityGb = new System.Windows.Forms.GroupBox();
label19 = new System.Windows.Forms.Label();
label18 = new System.Windows.Forms.Label();
@@ -129,6 +128,7 @@
groupBox2 = new System.Windows.Forms.GroupBox();
lameTargetQualityRb = new System.Windows.Forms.RadioButton();
lameTargetBitrateRb = new System.Windows.Forms.RadioButton();
label1 = new System.Windows.Forms.Label();
mergeOpeningEndCreditsCbox = new System.Windows.Forms.CheckBox();
retainAaxFileCbox = new System.Windows.Forms.CheckBox();
downloadCoverArtCbox = new System.Windows.Forms.CheckBox();
@@ -306,7 +306,7 @@
allowLibationFixupCbox.CheckState = System.Windows.Forms.CheckState.Checked;
allowLibationFixupCbox.Location = new System.Drawing.Point(19, 181);
allowLibationFixupCbox.Name = "allowLibationFixupCbox";
allowLibationFixupCbox.Size = new System.Drawing.Size(163, 19);
allowLibationFixupCbox.Size = new System.Drawing.Size(162, 19);
allowLibationFixupCbox.TabIndex = 10;
allowLibationFixupCbox.Text = "[AllowLibationFixup desc]";
allowLibationFixupCbox.UseVisualStyleBackColor = true;
@@ -315,7 +315,7 @@
// convertLossyRb
//
convertLossyRb.AutoSize = true;
convertLossyRb.Location = new System.Drawing.Point(13, 159);
convertLossyRb.Location = new System.Drawing.Point(438, 53);
convertLossyRb.Name = "convertLossyRb";
convertLossyRb.Size = new System.Drawing.Size(329, 19);
convertLossyRb.TabIndex = 12;
@@ -327,7 +327,7 @@
//
convertLosslessRb.AutoSize = true;
convertLosslessRb.Checked = true;
convertLosslessRb.Location = new System.Drawing.Point(13, 112);
convertLosslessRb.Location = new System.Drawing.Point(438, 6);
convertLosslessRb.Name = "convertLosslessRb";
convertLosslessRb.Size = new System.Drawing.Size(335, 19);
convertLosslessRb.TabIndex = 11;
@@ -540,7 +540,7 @@
overwriteExistingCbox.AutoSize = true;
overwriteExistingCbox.Location = new System.Drawing.Point(8, 171);
overwriteExistingCbox.Name = "overwriteExistingCbox";
overwriteExistingCbox.Size = new System.Drawing.Size(129, 19);
overwriteExistingCbox.Size = new System.Drawing.Size(128, 19);
overwriteExistingCbox.TabIndex = 3;
overwriteExistingCbox.Text = "[Overwrite Existing]";
overwriteExistingCbox.UseVisualStyleBackColor = true;
@@ -550,7 +550,7 @@
saveEpisodesToSeriesFolderCbox.AutoSize = true;
saveEpisodesToSeriesFolderCbox.Location = new System.Drawing.Point(8, 146);
saveEpisodesToSeriesFolderCbox.Name = "saveEpisodesToSeriesFolderCbox";
saveEpisodesToSeriesFolderCbox.Size = new System.Drawing.Size(191, 19);
saveEpisodesToSeriesFolderCbox.Size = new System.Drawing.Size(192, 19);
saveEpisodesToSeriesFolderCbox.TabIndex = 3;
saveEpisodesToSeriesFolderCbox.Text = "[Save Episodes To Series Folder]";
saveEpisodesToSeriesFolderCbox.UseVisualStyleBackColor = true;
@@ -622,7 +622,7 @@
saveMetadataToFileCbox.AutoSize = true;
saveMetadataToFileCbox.Location = new System.Drawing.Point(482, 428);
saveMetadataToFileCbox.Name = "saveMetadataToFileCbox";
saveMetadataToFileCbox.Size = new System.Drawing.Size(165, 19);
saveMetadataToFileCbox.Size = new System.Drawing.Size(166, 19);
saveMetadataToFileCbox.TabIndex = 22;
saveMetadataToFileCbox.Text = "[SaveMetadataToFile desc]";
saveMetadataToFileCbox.UseVisualStyleBackColor = true;
@@ -770,9 +770,12 @@
// tab4AudioFileOptions
//
tab4AudioFileOptions.AutoScroll = true;
tab4AudioFileOptions.Controls.Add(moveMoovAtomCbox);
tab4AudioFileOptions.Controls.Add(fileDownloadQualityCb);
tab4AudioFileOptions.Controls.Add(fileDownloadQualityLbl);
tab4AudioFileOptions.Controls.Add(combineNestedChapterTitlesCbox);
tab4AudioFileOptions.Controls.Add(convertLosslessRb);
tab4AudioFileOptions.Controls.Add(convertLossyRb);
tab4AudioFileOptions.Controls.Add(clipsBookmarksFormatCb);
tab4AudioFileOptions.Controls.Add(downloadClipsBookmarksCbox);
tab4AudioFileOptions.Controls.Add(audiobookFixupsGb);
@@ -791,6 +794,16 @@
tab4AudioFileOptions.Text = "Audio File Options";
tab4AudioFileOptions.UseVisualStyleBackColor = true;
//
// moveMoovAtomCbox
//
moveMoovAtomCbox.AutoSize = true;
moveMoovAtomCbox.Location = new System.Drawing.Point(448, 28);
moveMoovAtomCbox.Name = "moveMoovAtomCbox";
moveMoovAtomCbox.Size = new System.Drawing.Size(189, 19);
moveMoovAtomCbox.TabIndex = 14;
moveMoovAtomCbox.Text = "[MoveMoovToBeginning desc]";
moveMoovAtomCbox.UseVisualStyleBackColor = true;
//
// fileDownloadQualityCb
//
fileDownloadQualityCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
@@ -816,7 +829,7 @@
combineNestedChapterTitlesCbox.AutoSize = true;
combineNestedChapterTitlesCbox.Location = new System.Drawing.Point(19, 157);
combineNestedChapterTitlesCbox.Name = "combineNestedChapterTitlesCbox";
combineNestedChapterTitlesCbox.Size = new System.Drawing.Size(216, 19);
combineNestedChapterTitlesCbox.Size = new System.Drawing.Size(217, 19);
combineNestedChapterTitlesCbox.TabIndex = 13;
combineNestedChapterTitlesCbox.Text = "[CombineNestedChapterTitles desc]";
combineNestedChapterTitlesCbox.UseVisualStyleBackColor = true;
@@ -843,29 +856,16 @@
//
// audiobookFixupsGb
//
audiobookFixupsGb.Controls.Add(moveMoovAtomCbox);
audiobookFixupsGb.Controls.Add(splitFilesByChapterCbox);
audiobookFixupsGb.Controls.Add(stripUnabridgedCbox);
audiobookFixupsGb.Controls.Add(convertLosslessRb);
audiobookFixupsGb.Controls.Add(convertLossyRb);
audiobookFixupsGb.Controls.Add(stripAudibleBrandingCbox);
audiobookFixupsGb.Location = new System.Drawing.Point(6, 200);
audiobookFixupsGb.Name = "audiobookFixupsGb";
audiobookFixupsGb.Size = new System.Drawing.Size(385, 182);
audiobookFixupsGb.Size = new System.Drawing.Size(416, 116);
audiobookFixupsGb.TabIndex = 19;
audiobookFixupsGb.TabStop = false;
audiobookFixupsGb.Text = "Audiobook Fix-ups";
//
// moveMoovAtomCbox
//
moveMoovAtomCbox.AutoSize = true;
moveMoovAtomCbox.Location = new System.Drawing.Point(23, 134);
moveMoovAtomCbox.Name = "moveMoovAtomCbox";
moveMoovAtomCbox.Size = new System.Drawing.Size(188, 19);
moveMoovAtomCbox.TabIndex = 14;
moveMoovAtomCbox.Text = "[MoveMoovToBeginning desc]";
moveMoovAtomCbox.UseVisualStyleBackColor = true;
//
// stripUnabridgedCbox
//
stripUnabridgedCbox.AutoSize = true;
@@ -917,12 +917,12 @@
lameOptionsGb.Controls.Add(maxSampleRateCb);
lameOptionsGb.Controls.Add(lameDownsampleMonoCbox);
lameOptionsGb.Controls.Add(lameBitrateGb);
lameOptionsGb.Controls.Add(label1);
lameOptionsGb.Controls.Add(lameQualityGb);
lameOptionsGb.Controls.Add(groupBox2);
lameOptionsGb.Location = new System.Drawing.Point(397, 6);
lameOptionsGb.Controls.Add(label1);
lameOptionsGb.Location = new System.Drawing.Point(438, 78);
lameOptionsGb.Name = "lameOptionsGb";
lameOptionsGb.Size = new System.Drawing.Size(450, 376);
lameOptionsGb.Size = new System.Drawing.Size(412, 304);
lameOptionsGb.TabIndex = 14;
lameOptionsGb.TabStop = false;
lameOptionsGb.Text = "Mp3 Encoding Options";
@@ -930,9 +930,9 @@
// label20
//
label20.AutoSize = true;
label20.Location = new System.Drawing.Point(12, 89);
label20.Location = new System.Drawing.Point(6, 75);
label20.Name = "label20";
label20.Size = new System.Drawing.Size(101, 15);
label20.Size = new System.Drawing.Size(100, 15);
label20.TabIndex = 3;
label20.Text = "Max Sample Rate:";
//
@@ -940,7 +940,7 @@
//
label21.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label21.AutoSize = true;
label21.Location = new System.Drawing.Point(239, 89);
label21.Location = new System.Drawing.Point(227, 75);
label21.Name = "label21";
label21.Size = new System.Drawing.Size(94, 15);
label21.TabIndex = 3;
@@ -951,9 +951,9 @@
encoderQualityCb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
encoderQualityCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
encoderQualityCb.FormattingEnabled = true;
encoderQualityCb.Location = new System.Drawing.Point(337, 86);
encoderQualityCb.Location = new System.Drawing.Point(327, 72);
encoderQualityCb.Name = "encoderQualityCb";
encoderQualityCb.Size = new System.Drawing.Size(107, 23);
encoderQualityCb.Size = new System.Drawing.Size(79, 23);
encoderQualityCb.TabIndex = 2;
//
// maxSampleRateCb
@@ -961,15 +961,15 @@
maxSampleRateCb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
maxSampleRateCb.DropDownStyle = System.Windows.Forms.ComboBoxStyle.DropDownList;
maxSampleRateCb.FormattingEnabled = true;
maxSampleRateCb.Location = new System.Drawing.Point(119, 86);
maxSampleRateCb.Location = new System.Drawing.Point(113, 72);
maxSampleRateCb.Name = "maxSampleRateCb";
maxSampleRateCb.Size = new System.Drawing.Size(76, 23);
maxSampleRateCb.Size = new System.Drawing.Size(75, 23);
maxSampleRateCb.TabIndex = 2;
//
// lameDownsampleMonoCbox
//
lameDownsampleMonoCbox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
lameDownsampleMonoCbox.Location = new System.Drawing.Point(247, 29);
lameDownsampleMonoCbox.Location = new System.Drawing.Point(209, 29);
lameDownsampleMonoCbox.Name = "lameDownsampleMonoCbox";
lameDownsampleMonoCbox.Size = new System.Drawing.Size(197, 34);
lameDownsampleMonoCbox.TabIndex = 1;
@@ -988,9 +988,9 @@
lameBitrateGb.Controls.Add(label11);
lameBitrateGb.Controls.Add(label3);
lameBitrateGb.Controls.Add(lameBitrateTb);
lameBitrateGb.Location = new System.Drawing.Point(6, 116);
lameBitrateGb.Location = new System.Drawing.Point(6, 100);
lameBitrateGb.Name = "lameBitrateGb";
lameBitrateGb.Size = new System.Drawing.Size(438, 113);
lameBitrateGb.Size = new System.Drawing.Size(400, 92);
lameBitrateGb.TabIndex = 0;
lameBitrateGb.TabStop = false;
lameBitrateGb.Text = "Bitrate";
@@ -999,7 +999,7 @@
//
LameMatchSourceBRCbox.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
LameMatchSourceBRCbox.AutoSize = true;
LameMatchSourceBRCbox.Location = new System.Drawing.Point(254, 76);
LameMatchSourceBRCbox.Location = new System.Drawing.Point(254, 65);
LameMatchSourceBRCbox.Name = "LameMatchSourceBRCbox";
LameMatchSourceBRCbox.Size = new System.Drawing.Size(140, 19);
LameMatchSourceBRCbox.TabIndex = 3;
@@ -1010,7 +1010,7 @@
// lameConstantBitrateCbox
//
lameConstantBitrateCbox.AutoSize = true;
lameConstantBitrateCbox.Location = new System.Drawing.Point(6, 77);
lameConstantBitrateCbox.Location = new System.Drawing.Point(10, 65);
lameConstantBitrateCbox.Name = "lameConstantBitrateCbox";
lameConstantBitrateCbox.Size = new System.Drawing.Size(216, 19);
lameConstantBitrateCbox.TabIndex = 2;
@@ -1022,7 +1022,7 @@
label7.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label7.AutoSize = true;
label7.BackColor = System.Drawing.SystemColors.ControlLightLight;
label7.Location = new System.Drawing.Point(390, 52);
label7.Location = new System.Drawing.Point(368, 47);
label7.Name = "label7";
label7.Size = new System.Drawing.Size(25, 15);
label7.TabIndex = 1;
@@ -1033,7 +1033,7 @@
label6.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label6.AutoSize = true;
label6.BackColor = System.Drawing.SystemColors.ControlLightLight;
label6.Location = new System.Drawing.Point(309, 52);
label6.Location = new System.Drawing.Point(293, 47);
label6.Name = "label6";
label6.Size = new System.Drawing.Size(25, 15);
label6.TabIndex = 1;
@@ -1044,7 +1044,7 @@
label5.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label5.AutoSize = true;
label5.BackColor = System.Drawing.SystemColors.ControlLightLight;
label5.Location = new System.Drawing.Point(228, 52);
label5.Location = new System.Drawing.Point(217, 47);
label5.Name = "label5";
label5.Size = new System.Drawing.Size(25, 15);
label5.TabIndex = 1;
@@ -1055,7 +1055,7 @@
label4.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label4.AutoSize = true;
label4.BackColor = System.Drawing.SystemColors.ControlLightLight;
label4.Location = new System.Drawing.Point(147, 52);
label4.Location = new System.Drawing.Point(139, 47);
label4.Name = "label4";
label4.Size = new System.Drawing.Size(25, 15);
label4.TabIndex = 1;
@@ -1066,7 +1066,7 @@
label11.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label11.AutoSize = true;
label11.BackColor = System.Drawing.SystemColors.ControlLightLight;
label11.Location = new System.Drawing.Point(10, 52);
label11.Location = new System.Drawing.Point(10, 47);
label11.Name = "label11";
label11.Size = new System.Drawing.Size(19, 15);
label11.TabIndex = 1;
@@ -1077,7 +1077,7 @@
label3.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label3.AutoSize = true;
label3.BackColor = System.Drawing.SystemColors.ControlLightLight;
label3.Location = new System.Drawing.Point(71, 52);
label3.Location = new System.Drawing.Point(66, 47);
label3.Name = "label3";
label3.Size = new System.Drawing.Size(19, 15);
label3.TabIndex = 1;
@@ -1087,27 +1087,16 @@
//
lameBitrateTb.BackColor = System.Drawing.SystemColors.ControlLightLight;
lameBitrateTb.LargeChange = 32;
lameBitrateTb.Location = new System.Drawing.Point(6, 22);
lameBitrateTb.Location = new System.Drawing.Point(6, 17);
lameBitrateTb.Maximum = 320;
lameBitrateTb.Minimum = 16;
lameBitrateTb.Name = "lameBitrateTb";
lameBitrateTb.Size = new System.Drawing.Size(408, 45);
lameBitrateTb.Size = new System.Drawing.Size(388, 45);
lameBitrateTb.SmallChange = 8;
lameBitrateTb.TabIndex = 0;
lameBitrateTb.TickFrequency = 16;
lameBitrateTb.Value = 64;
//
// label1
//
label1.AutoSize = true;
label1.Enabled = false;
label1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Italic, System.Drawing.GraphicsUnit.Point);
label1.Location = new System.Drawing.Point(6, 355);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(172, 15);
label1.TabIndex = 1;
label1.Text = "Using L.A.M.E. encoding engine";
//
// lameQualityGb
//
lameQualityGb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
@@ -1124,9 +1113,9 @@
lameQualityGb.Controls.Add(label14);
lameQualityGb.Controls.Add(label2);
lameQualityGb.Controls.Add(lameVBRQualityTb);
lameQualityGb.Location = new System.Drawing.Point(6, 235);
lameQualityGb.Location = new System.Drawing.Point(6, 196);
lameQualityGb.Name = "lameQualityGb";
lameQualityGb.Size = new System.Drawing.Size(438, 109);
lameQualityGb.Size = new System.Drawing.Size(400, 85);
lameQualityGb.TabIndex = 0;
lameQualityGb.TabStop = false;
lameQualityGb.Text = "Quality";
@@ -1135,7 +1124,7 @@
//
label19.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label19.AutoSize = true;
label19.Location = new System.Drawing.Point(349, 52);
label19.Location = new System.Drawing.Point(332, 47);
label19.Name = "label19";
label19.Size = new System.Drawing.Size(20, 15);
label19.TabIndex = 1;
@@ -1145,7 +1134,7 @@
//
label18.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label18.AutoSize = true;
label18.Location = new System.Drawing.Point(307, 52);
label18.Location = new System.Drawing.Point(291, 47);
label18.Name = "label18";
label18.Size = new System.Drawing.Size(20, 15);
label18.TabIndex = 1;
@@ -1155,7 +1144,7 @@
//
label17.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label17.AutoSize = true;
label17.Location = new System.Drawing.Point(265, 52);
label17.Location = new System.Drawing.Point(251, 47);
label17.Name = "label17";
label17.Size = new System.Drawing.Size(20, 15);
label17.TabIndex = 1;
@@ -1165,7 +1154,7 @@
//
label16.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label16.AutoSize = true;
label16.Location = new System.Drawing.Point(223, 52);
label16.Location = new System.Drawing.Point(212, 47);
label16.Name = "label16";
label16.Size = new System.Drawing.Size(20, 15);
label16.TabIndex = 1;
@@ -1175,7 +1164,7 @@
//
label12.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label12.AutoSize = true;
label12.Location = new System.Drawing.Point(182, 52);
label12.Location = new System.Drawing.Point(170, 47);
label12.Name = "label12";
label12.Size = new System.Drawing.Size(20, 15);
label12.TabIndex = 1;
@@ -1185,7 +1174,7 @@
//
label15.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label15.AutoSize = true;
label15.Location = new System.Drawing.Point(140, 52);
label15.Location = new System.Drawing.Point(130, 47);
label15.Name = "label15";
label15.Size = new System.Drawing.Size(20, 15);
label15.TabIndex = 1;
@@ -1195,7 +1184,7 @@
//
label9.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label9.AutoSize = true;
label9.Location = new System.Drawing.Point(97, 52);
label9.Location = new System.Drawing.Point(89, 47);
label9.Name = "label9";
label9.Size = new System.Drawing.Size(20, 15);
label9.TabIndex = 1;
@@ -1205,7 +1194,7 @@
//
label8.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label8.AutoSize = true;
label8.Location = new System.Drawing.Point(391, 52);
label8.Location = new System.Drawing.Point(371, 47);
label8.Name = "label8";
label8.Size = new System.Drawing.Size(20, 15);
label8.TabIndex = 1;
@@ -1214,7 +1203,7 @@
// label13
//
label13.AutoSize = true;
label13.Location = new System.Drawing.Point(376, 80);
label13.Location = new System.Drawing.Point(355, 66);
label13.Name = "label13";
label13.Size = new System.Drawing.Size(39, 15);
label13.TabIndex = 1;
@@ -1223,7 +1212,7 @@
// label10
//
label10.AutoSize = true;
label10.Location = new System.Drawing.Point(6, 80);
label10.Location = new System.Drawing.Point(6, 66);
label10.Name = "label10";
label10.Size = new System.Drawing.Size(43, 15);
label10.TabIndex = 1;
@@ -1233,7 +1222,7 @@
//
label14.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label14.AutoSize = true;
label14.Location = new System.Drawing.Point(56, 52);
label14.Location = new System.Drawing.Point(50, 47);
label14.Name = "label14";
label14.Size = new System.Drawing.Size(20, 15);
label14.TabIndex = 1;
@@ -1243,7 +1232,7 @@
//
label2.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
label2.AutoSize = true;
label2.Location = new System.Drawing.Point(14, 52);
label2.Location = new System.Drawing.Point(10, 47);
label2.Name = "label2";
label2.Size = new System.Drawing.Size(20, 15);
label2.TabIndex = 1;
@@ -1253,10 +1242,10 @@
//
lameVBRQualityTb.BackColor = System.Drawing.SystemColors.ControlLightLight;
lameVBRQualityTb.LargeChange = 1;
lameVBRQualityTb.Location = new System.Drawing.Point(10, 22);
lameVBRQualityTb.Location = new System.Drawing.Point(6, 17);
lameVBRQualityTb.Maximum = 9;
lameVBRQualityTb.Name = "lameVBRQualityTb";
lameVBRQualityTb.Size = new System.Drawing.Size(404, 45);
lameVBRQualityTb.Size = new System.Drawing.Size(388, 45);
lameVBRQualityTb.TabIndex = 0;
lameVBRQualityTb.Value = 9;
//
@@ -1267,7 +1256,7 @@
groupBox2.Controls.Add(lameTargetBitrateRb);
groupBox2.Location = new System.Drawing.Point(6, 22);
groupBox2.Name = "groupBox2";
groupBox2.Size = new System.Drawing.Size(189, 58);
groupBox2.Size = new System.Drawing.Size(182, 45);
groupBox2.TabIndex = 0;
groupBox2.TabStop = false;
groupBox2.Text = "Target";
@@ -1276,7 +1265,7 @@
//
lameTargetQualityRb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
lameTargetQualityRb.AutoSize = true;
lameTargetQualityRb.Location = new System.Drawing.Point(118, 22);
lameTargetQualityRb.Location = new System.Drawing.Point(104, 18);
lameTargetQualityRb.Name = "lameTargetQualityRb";
lameTargetQualityRb.Size = new System.Drawing.Size(63, 19);
lameTargetQualityRb.TabIndex = 0;
@@ -1288,7 +1277,7 @@
// lameTargetBitrateRb
//
lameTargetBitrateRb.AutoSize = true;
lameTargetBitrateRb.Location = new System.Drawing.Point(6, 22);
lameTargetBitrateRb.Location = new System.Drawing.Point(14, 18);
lameTargetBitrateRb.Name = "lameTargetBitrateRb";
lameTargetBitrateRb.Size = new System.Drawing.Size(59, 19);
lameTargetBitrateRb.TabIndex = 0;
@@ -1297,6 +1286,17 @@
lameTargetBitrateRb.UseVisualStyleBackColor = true;
lameTargetBitrateRb.CheckedChanged += lameTargetRb_CheckedChanged;
//
// label1
//
label1.AutoSize = true;
label1.Enabled = false;
label1.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Italic);
label1.Location = new System.Drawing.Point(6, 286);
label1.Name = "label1";
label1.Size = new System.Drawing.Size(172, 15);
label1.TabIndex = 1;
label1.Text = "Using L.A.M.E. encoding engine";
//
// mergeOpeningEndCreditsCbox
//
mergeOpeningEndCreditsCbox.AutoSize = true;
@@ -1312,7 +1312,7 @@
retainAaxFileCbox.AutoSize = true;
retainAaxFileCbox.Location = new System.Drawing.Point(19, 107);
retainAaxFileCbox.Name = "retainAaxFileCbox";
retainAaxFileCbox.Size = new System.Drawing.Size(132, 19);
retainAaxFileCbox.Size = new System.Drawing.Size(131, 19);
retainAaxFileCbox.TabIndex = 10;
retainAaxFileCbox.Text = "[RetainAaxFile desc]";
retainAaxFileCbox.UseVisualStyleBackColor = true;

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

@@ -1,10 +1,8 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using System.Windows.Forms;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core.WindowsDesktop.Drawing;
using FileManager;
using LibationFileManager;
using LibationUiBase;
@@ -13,9 +11,11 @@ namespace LibationWinForms
public partial class Form1
{
private void Configure_NonUI()
{
// init default/placeholder cover art
var format = System.Drawing.Imaging.ImageFormat.Jpeg;
{
AudibleApiStorage.LoadError += AudibleApiStorage_LoadError;
// init default/placeholder cover art
var format = System.Drawing.Imaging.ImageFormat.Jpeg;
PictureStorage.SetDefaultImage(PictureSize._80x80, Properties.Resources.default_cover_80x80.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._300x300, Properties.Resources.default_cover_300x300.ToBytes(format));
PictureStorage.SetDefaultImage(PictureSize._500x500, Properties.Resources.default_cover_500x500.ToBytes(format));
@@ -32,9 +32,58 @@ namespace LibationWinForms
var libraryStats = e.Result as LibraryCommands.LibraryStats;
if ((libraryStats.booksNoProgress + libraryStats.pdfsNotDownloaded) > 0)
beginBookBackupsToolStripMenuItem_Click();
if ((libraryStats.PendingBooks + libraryStats.pdfsNotDownloaded) > 0)
Invoke(() => beginBookBackupsToolStripMenuItem_Click(null, System.EventArgs.Empty));
};
}
}
}
private void AudibleApiStorage_LoadError(object sender, AccountSettingsLoadErrorEventArgs e)
{
try
{
//Backup AccountSettings.json and create a new, empty file.
var backupFile =
FileUtility.SaferMoveToValidPath(
e.SettingsFilePath,
e.SettingsFilePath,
ReplacementCharacters.Barebones,
"bak");
AudibleApiStorage.EnsureAccountsSettingsFileExists();
e.Handled = true;
showAccountSettingsRecoveredMessage(backupFile);
}
catch
{
showAccountSettingsUnrecoveredMessage();
}
void showAccountSettingsRecoveredMessage(LongPath backupFile)
=> MessageBox.Show(this, $"""
Libation could not load your account settings, so it had created a new, empty account settings file.
You will need to re-add you Audible account(s) before scanning or downloading.
The old account settings file has been archived at '{backupFile.PathWithoutPrefix}'
{e.GetException().ToString()}
""",
"Error Loading Account Settings",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
void showAccountSettingsUnrecoveredMessage()
=> MessageBox.Show(this, $"""
Libation could not load your account settings. The file may be corrupted, but Libation is unable to delete it.
Please move or delete the account settings file '{e.SettingsFilePath}'
{e.GetException().ToString()}
""",
"Error Loading Account Settings",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
}
}
}

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()));
setBackupCounts(null, libraryBooks);
await productsDisplay.DisplayAsync(libraryBooks);
}

View File

@@ -89,11 +89,34 @@ namespace LibationWinForms.GridView
displayWindow.Show(this);
}
private void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry)
private async void productsGrid_DetailsClicked(ILibraryBookEntry liveGridEntry)
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
liveGridEntry.LibraryBook.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
// HACK: workaround for a Winforms bug.
// This event is fired by the DataGridCell.OnMouseUpInternal
// method. If any user changes made in the BookDetailsDialog
// result in the entry's row being removed from the DataGridView,
// then when this event handler returns, OnMouseUpInternal will
// throw a NRE trying to access the DataGridCell.DataGridView
// property.
//Steps to cause the bug:
// * book has tag: asdf
// * filter is `[asdf]`
// * tag asdf is removed from book
// * DataGridView throws NRE
//The workaround is to make this event handler execute
//asynchronously so that DataGridCell.OnMouseUpInternal completes
//before the user can change the DataGridView state.
await Task.Run(() => this.Invoke(runAsync));
void runAsync()
{
var bookDetailsForm = new BookDetailsDialog(liveGridEntry.LibraryBook);
if (bookDetailsForm.ShowDialog() == DialogResult.OK)
liveGridEntry.LibraryBook.UpdateUserDefinedItem(bookDetailsForm.NewTags, bookDetailsForm.BookLiberatedStatus, bookDetailsForm.PdfLiberatedStatus);
}
}
#endregion

View File

@@ -1,9 +1,9 @@
using DataLayer;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
using Dinah.Core.WindowsDesktop.Forms;
using LibationFileManager;
using LibationUiBase.GridView;
using NPOI.SS.Formula.Functions;
using System;
using System.Collections.Generic;
using System.Data;
@@ -216,8 +216,12 @@ namespace LibationWinForms.GridView
internal async Task BindToGridAsync(List<LibraryBook> dbBooks)
{
var geList = await LibraryBookEntry<WinFormsEntryStatus>.GetAllProductsAsync(dbBooks);
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for GetAllProductsAsync and GetAllSeriesEntriesAsync
var sc = Invoke(() => System.Threading.SynchronizationContext.Current);
System.Threading.SynchronizationContext.SetSynchronizationContext(sc);
var geList = await LibraryBookEntry<WinFormsEntryStatus>.GetAllProductsAsync(dbBooks);
var seriesEntries = await SeriesEntry<WinFormsEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
geList.AddRange(seriesEntries);
@@ -232,9 +236,12 @@ namespace LibationWinForms.GridView
foreach (var child in series.Children)
geList.Insert(++seriesIndex, child);
}
System.Threading.SynchronizationContext.SetSynchronizationContext(null);
bindingList = new GridEntryBindingList(geList);
bindingList.CollapseAll();
//The syncBindingSource ensures that the IGridEntry list is added on the UI thread
syncBindingSource.DataSource = bindingList;
VisibleCountChanged?.Invoke(this, bindingList.GetFilteredInItems().Count());
}
@@ -252,14 +259,19 @@ namespace LibationWinForms.GridView
//Add absent entries to grid, or update existing entry
var allEntries = bindingList.AllItems().BookEntries();
var allEntries = bindingList.AllItems().BookEntries().ToDictionarySafe(b => b.AudibleProductId);
var seriesEntries = bindingList.AllItems().SeriesEntries().ToList();
var parentedEpisodes = dbBooks.ParentedEpisodes().ToHashSet();
//Get the UI thread's synchronization context and set it on the current thread to ensure
//it's available for creation of new IGridEntry items during upsert
var sc = Invoke(() => System.Threading.SynchronizationContext.Current);
System.Threading.SynchronizationContext.SetSynchronizationContext(sc);
bindingList.RaiseListChangedEvents = false;
foreach (var libraryBook in dbBooks.OrderBy(e => e.DateAdded))
{
var existingEntry = allEntries.FindByAsin(libraryBook.Book.AudibleProductId);
var existingEntry = allEntries.TryGetValue(libraryBook.Book.AudibleProductId, out var e) ? e : null;
if (libraryBook.Book.IsProduct())
{

View File

@@ -42,7 +42,7 @@
<ItemGroup>
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="9.0.0.1" />
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="9.0.1.1" />
</ItemGroup>
<ItemGroup>

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;

View File

@@ -25,21 +25,59 @@ namespace LinuxConfigApp
public void SetFolderIcon(string image, string directory) => throw new PlatformNotSupportedException();
public void DeleteFolderIcon(string directory) => throw new PlatformNotSupportedException();
public string ReleaseIdString => LibationScaffolding.ReleaseIdentifier.ToString() + (File.Exists("/bin/yum") ? "_RPM" : "");
public string ReleaseIdString => LibationScaffolding.ReleaseIdentifier.ToString() + (File.Exists("/bin/apt") ? "_DEB" : "_RPM");
//only run the auto upgrader if the current app was installed from the
//.deb or .rpm package. Try to detect this by checking if the symlink exists.
public bool CanUpgrade => File.Exists("/bin/libation");
public void InstallUpgrade(string upgradeBundle)
{
if (File.Exists("/bin/yum"))
if (File.Exists("/bin/dnf5"))
RunAsRoot("dnf5", $"install -y '{upgradeBundle}'");
else if (File.Exists("/bin/dnf"))
RunAsRoot("dnf", $"install -y '{upgradeBundle}'");
else if (File.Exists("/bin/yum"))
RunAsRoot("yum", $"install -y '{upgradeBundle}'");
else
RunAsRoot("apt", $"install '{upgradeBundle}'");
}
private bool FindPkexec(out string exePath)
{
if (File.Exists("/usr/bin/pkexec"))
{
exePath = "/usr/bin/pkexec";
return true;
}
else if (File.Exists("/bin/pkexec"))
{
exePath = "/bin/pkexec";
return true;
}
exePath = null;
return false;
}
public Process RunAsRoot(string exe, string args)
{
//try to use polkit directly
if (FindPkexec(out var pkexec))
{
ProcessStartInfo psi = new()
{
FileName = pkexec,
Arguments = $"\"{exe}\" {args}",
UseShellExecute = false,
RedirectStandardOutput = true,
RedirectStandardError = true
};
try
{
return Process.Start(psi);
}
catch {/* fall back to old, script-based method */}
}
//cribbed this script from VirtualBox's guest additions installer.
//It's designed to launch the system's gui superuser password
//prompt across multiple distributions and desktop environments.

View File

@@ -26,7 +26,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.2903.40" />
<PackageReference Include="Microsoft.Web.WebView2" Version="1.0.3124.44" />
</ItemGroup>
<ItemGroup>

View File

@@ -19,7 +19,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" />
<PackageReference Include="Dinah.Core" Version="9.0.1.1" />
</ItemGroup>
</Project>

Some files were not shown because too many files have changed in this diff Show More