Compare commits

...
134 Commits
Author SHA1 Message Date
Robert McRackan 56e6bd164b By user request, other Serilog.Sink packages are included for experimental use 2024-06-26 07:07:49 -04:00
rmcrackan 9b28bdceaa Merge pull request #923 from rmcrackan/dependabot/github_actions/docker/build-push-action-6
Bump docker/build-push-action from 5 to 6
2024-06-17 16:33:34 -04:00
dependabot[bot] b7f7d9004d Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-17 14:23:33 +00:00
rmcrackan 1be0991e62 Add Audibly to FAQ 2024-06-07 13:27:18 -04:00
rmcrackan ff8a2e59c5 Update FrequentlyAskedQuestions.md 2024-05-27 11:48:48 -04:00
Robert McRackan 453904261b Bug fix. Audible's api acts weird when page count gets to ~400 2024-05-16 07:53:19 -04:00
Robert McRackan f9340db90a update references. audible api bugfix 2024-05-15 06:55:25 -04:00
Robert McRackan 5cd329dd26 refactor 2024-05-13 15:23:37 -04:00
Robert McRackan b2a882b79d Bug fix #904 -- navigation bug with new Accessibility feature 2024-05-13 15:17:17 -04:00
Robert McRackan 75df78a2f7 Add OSVersion to logs 2024-05-13 14:01:34 -04:00
Robert McRackan 3ad52cbecc docker to .net8 2024-05-09 10:01:38 -04:00
Robert McRackan 27b2fe741c Add accessibility 2024-05-07 10:39:30 -04:00
Robert McRackan d19fe2250c Add accessibility text to grid stoplight buttons 2024-05-06 21:56:00 -04:00
Robert McRackan d16d0c8de2 Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-05-05 16:24:07 -04:00
Robert McRackan c213d5d9f6 Attempt to solve networking issue by disabling ipv6 2024-05-05 16:24:00 -04:00
rmcrackan c73a023572 Update FrequentlyAskedQuestions.md 2024-04-30 22:42:34 -04:00
rmcrackan 67389917fd Merge pull request #879 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.4
Bump SixLabors.ImageSharp from 3.1.3 to 3.1.4 in /Source/LibationUiBase
2024-04-15 17:03:13 -04:00
dependabot[bot] b3264d5f42 Bump SixLabors.ImageSharp from 3.1.3 to 3.1.4 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.3...v3.1.4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 20:27:24 +00:00
Robert McRackan 962d9b550f "Unable to load module" should just be a warning, not error 2024-04-15 08:03:49 -04:00
Robert McRackan 91ce7272ae Bug fix #874 : Chardonnay wasn't allowing custom directories ending in "Books" 2024-04-04 08:13:37 -04:00
rmcrackan 2f64ca6856 Merge pull request #870 from ScubyG/patch-1
Update InstallOnLinux.md
2024-03-22 07:37:00 -04:00
Davy Jones cfe5db436c Update InstallOnLinux.md
Fixed typo
2024-03-22 01:23:52 +00:00
rmcrackan 3653fc8094 Merge pull request #863 from rmcrackan/dependabot/github_actions/softprops/action-gh-release-2
Bump softprops/action-gh-release from 1 to 2
2024-03-11 10:45:44 -04:00
dependabot[bot] 662c0ec871 Bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 14:20:12 +00:00
rmcrackan 44d39eabdb Merge pull request #861 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.3
Bump SixLabors.ImageSharp from 3.1.2 to 3.1.3 in /Source/LibationUiBase
2024-03-05 11:45:59 -05:00
dependabot[bot] a0550d5c97 Bump SixLabors.ImageSharp from 3.1.2 to 3.1.3 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.2...v3.1.3)

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

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-05 16:38:00 +00:00
Robert McRackan 14eaca6d45 Update dependency. Security update 2024-03-05 11:36:36 -05:00
Robert McRackan 93ccc206ef Update dependencies 2024-03-04 14:02:42 -05:00
Robert McRackan d3f0fd711e Remove validation check: SeriesName is null . At least 1 exception found in the wild 2024-02-29 14:44:41 -05:00
rmcrackan 3f4604e877 Update FrequentlyAskedQuestions.md
reformat
2024-02-29 10:15:47 -05:00
rmcrackan c316709af8 Update FrequentlyAskedQuestions.md
Classic vs Chardonnay
2024-02-29 10:15:01 -05:00
Robert McRackan 221d5c7f1c paypal link 2024-02-19 10:32:26 -05:00
Robert McRackan 5a86a1a27b Typo 2024-02-13 21:42:24 -05:00
rmcrackan dc5e55de68 Merge pull request #848 from patienttruth/master
Update Docker.md
2024-02-13 07:22:33 -05:00
patienttruth ee37864a42 Update Docker.md
Included an explanation that you may have to manually input the InProgress variable.
2024-02-13 06:49:33 +00:00
Robert McRackan efe347667c Better error reporting in audible api -- incl. inner exceptions 2024-01-20 21:44:29 -05:00
Robert McRackan f27a18bdbb Better error reporting in audible api 2024-01-19 09:30:48 -05:00
Robert McRackan d1834659d9 incr ver 2024-01-12 09:08:14 -05:00
Robert McRackan 7842b521d7 Update dependencies. Notes about upgrading 2024-01-12 07:44:40 -05:00
Robert McRackan 0822f0229d Revert Avalonia. Bug in 11.0.6 2024-01-04 10:03:33 -05:00
Robert McRackan 26aee4d29d Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-01-03 11:43:59 -05:00
Robert McRackan 17a80a23a8 AAXClean upgrade to .net8 2024-01-03 11:43:45 -05:00
rmcrackan e26fc9ca62 Merge pull request #808 from rmcrackan/dependabot/github_actions/actions/download-artifact-4
Bump actions/download-artifact from 3 to 4
2023-12-15 09:50:25 -05:00
rmcrackan a03ccf1143 Merge pull request #807 from rmcrackan/dependabot/github_actions/actions/upload-artifact-4
Bump actions/upload-artifact from 3 to 4
2023-12-15 09:50:11 -05:00
dependabot[bot] bb8dd615db Bump actions/download-artifact from 3 to 4
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 14:08:26 +00:00
dependabot[bot] 9022a2889f Bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 14:08:20 +00:00
rmcrackan ef049a3b02 Merge pull request #805 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-4
Bump actions/setup-dotnet from 3 to 4
2023-12-05 10:01:34 -05:00
dependabot[bot] 77409750aa Bump actions/setup-dotnet from 3 to 4
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 3 to 4.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-05 14:14:17 +00:00
Robert McRackan 1702130b01 Upgrade to .net8 2023-11-15 19:57:39 -05:00
Robert McRackan b6d1a7e3ba Upgrade to .net8 2023-11-15 19:53:26 -05:00
Robert McRackan 2907ba5c13 Add FAQ 2023-10-20 08:27:20 -04:00
Robert McRackan 6df6c79ac8 New locale: Brazil 2023-10-18 22:33:24 -04:00
rmcrackan 3a9ca5d827 Merge pull request #779 from rmcrackan/dependabot/github_actions/dwenegar/upload-release-assets-2
Bump dwenegar/upload-release-assets from 1 to 2
2023-10-13 14:41:26 -04:00
dependabot[bot] e1e663e327 Bump dwenegar/upload-release-assets from 1 to 2
Bumps [dwenegar/upload-release-assets](https://github.com/dwenegar/upload-release-assets) from 1 to 2.
- [Release notes](https://github.com/dwenegar/upload-release-assets/releases)
- [Commits](https://github.com/dwenegar/upload-release-assets/compare/v1...v2)

---
updated-dependencies:
- dependency-name: dwenegar/upload-release-assets
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-13 14:37:55 +00:00
Robert McRackan 4b00d5fd84 incr ver 2023-09-18 13:58:23 -04:00
rmcrackan 02dbf8aad0 Merge pull request #751 from Mbucari/master
Minor Bug Fixes
2023-09-18 13:56:53 -04:00
rmcrackan 8326389f5c Merge pull request #732 from rmcrackan/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2023-09-18 13:56:33 -04:00
dependabot[bot] 34535b3ce1 Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 17:33:43 +00:00
rmcrackan 7e5366ab95 Merge pull request #738 from rmcrackan/dependabot/github_actions/docker/setup-buildx-action-3
Bump docker/setup-buildx-action from 2 to 3
2023-09-18 13:30:24 -04:00
dependabot[bot] 690de9bc5c Bump docker/setup-buildx-action from 2 to 3
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 17:25:20 +00:00
rmcrackan c976aa2bb2 Merge pull request #739 from rmcrackan/dependabot/github_actions/docker/login-action-3
Bump docker/login-action from 2 to 3
2023-09-18 13:25:07 -04:00
rmcrackan 27f659285d Merge pull request #740 from rmcrackan/dependabot/github_actions/docker/build-push-action-5
Bump docker/build-push-action from 4 to 5
2023-09-18 13:24:55 -04:00
rmcrackan 423a5e7720 Merge pull request #741 from rmcrackan/dependabot/github_actions/docker/setup-qemu-action-3
Bump docker/setup-qemu-action from 2 to 3
2023-09-18 13:24:44 -04:00
Michael Bucari-Tovo 9152e12fe1 Fix #748 2023-09-18 10:14:54 -06:00
Michael Bucari-Tovo f471c53139 Fix #734 2023-09-18 10:08:15 -06:00
dependabot[bot] 66d055bb90 Bump docker/setup-qemu-action from 2 to 3
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:49 +00:00
dependabot[bot] 2bbb35363a Bump docker/build-push-action from 4 to 5
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:46 +00:00
dependabot[bot] 1d3687cf9e Bump docker/login-action from 2 to 3
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:42 +00:00
Mbucari daf925157f Update AppScaffolding.csproj 2023-09-02 07:59:30 -06:00
Mbucari 40eec9e674 Merge pull request #730 from Mbucari/master
Fix #729
2023-09-01 18:46:42 -06:00
MBucari 6f0782053e Restore PDF functionality (#729) 2023-09-01 16:30:35 -06:00
Michael Bucari-Tovo 04ad033ba0 Remove appsettings.json from program files dir 2023-08-31 09:36:38 -06:00
Robert McRackan f12f8ba3ee incr ver 2023-08-30 15:00:20 -04:00
rmcrackan cc6feb21ff Merge pull request #728 from Mbucari/master
Minor Bug fixes
2023-08-30 14:46:16 -04:00
Michael Bucari-Tovo c4f2ec428d Allow Settings form resizing 2023-08-30 12:38:28 -06:00
Michael Bucari-Tovo 59689cb647 Update Dependencies 2023-08-30 11:59:40 -06:00
Michael Bucari-Tovo d7f3758ebc Add support for environment variables and unix shortcuts in appsettings.json path. 2023-08-30 11:47:50 -06:00
Mbucari 49775b019c Update InstallOnMac.md 2023-08-30 09:06:57 -06:00
Michael Bucari-Tovo e55e969349 Fix issue with save button being disabled. 2023-08-30 08:59:20 -06:00
Mbucari 42a93bfac1 Update AAXClean 2023-08-27 19:47:32 -06:00
Mbucari f86c77a546 Create directory before trying to create appsettings.json 2023-08-27 19:47:00 -06:00
MBucari 88c35e2a56 Add scrolling view to settings window panels (#720) 2023-08-23 20:25:30 -06:00
MBucari b405e8b6b2 Remove unised response groups 2023-08-20 11:40:24 -06:00
MBucari 92d283187d Add support for locating mp3 audiobooks 2023-08-20 11:38:43 -06:00
Robert McRackan 51b8cfe71f incr ver 2023-08-17 14:03:40 -04:00
rmcrackan c80da5357b Merge pull request #716 from Mbucari/master
Fix NRE when item has no category ladders (#715)
2023-08-17 14:02:43 -04:00
Michael Bucari-Tovo 736d7c4a5f Fix NRE when item has no category ladders (#715) 2023-08-17 11:55:28 -06:00
Robert McRackan f175b7592e incr major ver 2023-08-12 21:45:11 -04:00
rmcrackan 415e6e7bc6 Merge pull request #710 from Mbucari/master
Batch of Fixxed Issues
2023-08-12 21:20:37 -04:00
MBucari d6a413e8d9 Update Avalonia to 11.0.3 2023-08-12 17:30:08 -06:00
Mbucari 3049de6246 Merge branch 'rmcrackan:master' into master 2023-08-12 17:20:09 -06:00
MBucari fb9b4eb77e Update audio duration on library scan (#707) 2023-08-12 17:19:56 -06:00
MBucari e65b6c76a8 Add ability to preview templates on user's books (#700)
Add template editor menu items to main grid context menu
2023-08-12 17:12:50 -06:00
Mbucari 167a021eb1 Change glass icon shading 2023-08-11 10:55:31 -06:00
Mbucari ff3ac2d6fd Fix MessageBox crash when UI has no Screens (#708) 2023-08-11 09:04:05 -06:00
Robert McRackan f733079a49 remove Moq 2023-08-10 15:24:43 -04:00
Mbucari 893d68190d Lazy load cover to improve startup time 2023-08-10 10:18:28 -06:00
Mbucari 97f94d8782 Add custom column widths to chardonnay 2023-08-07 15:54:50 -06:00
Mbucari 4b2ce0c2d1 Brighter stoplight colors (#702) 2023-08-07 11:31:22 -06:00
Mbucari ee00417c6f Add white glow to libation_glass.svg (#701) 2023-08-07 11:31:03 -06:00
Robert McRackan 768afd8ecd incr ver 2023-08-04 11:57:15 -04:00
rmcrackan 32c3fa85ce Merge pull request #699 from Mbucari/master
Fix broken template editor (#698)
2023-08-04 11:55:30 -04:00
Mbucari 6986c8f018 Fix broken template editor (#698) 2023-08-04 09:26:51 -06:00
Robert McRackan f69c2b1cfc incr ver 2023-08-02 21:55:19 -04:00
rmcrackan b11675c36a Merge pull request #696 from Mbucari/master
Bug fixes
2023-08-02 21:52:57 -04:00
Mbucari 379c2ed62d Fix account nickname retrieval (#629) 2023-08-02 13:54:49 -06:00
Mbucari 7c8489b52f Fix walkthrough causing freeze (#695) 2023-08-02 13:15:58 -06:00
rmcrackan c61a863edd Merge pull request #694 from Mbucari/master
Fix DPI scaling bug (#692)
2023-08-01 15:03:21 -04:00
Mbucari 1d54f32ef3 Fix DPI scaling bug (#692) 2023-08-01 11:55:23 -06:00
rmcrackan fabe4afd94 Merge pull request #691 from Mbucari/master
Fix #686 and enable nullable in FileManager and LibationFileManager
2023-07-30 20:22:55 -04:00
MBucari 61efa3c0c1 Update dependencies 2023-07-30 14:00:12 -06:00
MBucari fe70daf0bc Update avalonia to v11.0.1 2023-07-30 13:54:44 -06:00
MBucari 34033e7947 Enable Nullable 2023-07-30 13:31:57 -06:00
MBucari e8c63e9a6e Fix UI control overlapping label (#686) 2023-07-30 13:15:43 -06:00
Robert McRackan 9315165f80 update dependencies 2023-07-21 21:21:48 -04:00
Robert McRackan ce624399ba incr ver 2023-07-19 07:48:11 -04:00
rmcrackan 63e9700c4a Merge pull request #682 from Mbucari/master
Fix #680 and Add category ladders
2023-07-19 07:46:05 -04:00
Mbucari 914e574bf8 Improve GridView
- Remove LongDescription
- Description has the full description
- Better MyRating updating
2023-07-18 16:18:01 -06:00
Mbucari b94f9bbc15 Fix grid update bug 2023-07-18 16:11:22 -06:00
Mbucari 4e34834c35 Fix category search indexing 2023-07-18 16:00:06 -06:00
Mbucari 3211b2dc85 Improved Category Ladders 2023-07-18 15:51:02 -06:00
Mbucari ea6adeb58f Add category ladders 2023-07-17 16:50:45 -06:00
Mbucari 90eccbf2f6 Fix FilePathCache NRE (#680) 2023-07-17 08:55:55 -06:00
rmcrackan 668cd7dba8 Merge pull request #679 from Mbucari/master
Mp3 embedded cuesheet and raw metadata
2023-07-16 14:32:49 -04:00
MBucari c08b2b575c UI Tweak 2023-07-16 10:57:17 -06:00
MBucari 07eaa48e10 Save raw json metadata 2023-07-16 10:54:05 -06:00
MBucari 3cf5fc1d99 Add mp3 embedded cuesheet (#677) 2023-07-15 10:44:31 -06:00
Robert McRackan 15ad753fa1 update dependencies 2023-07-14 20:58:26 -04:00
rmcrackan 75b984bdb2 Merge pull request #678 from Mbucari/master
Fix quick filter not being applied on startup
2023-07-14 20:53:57 -04:00
Mbucari f586d1d59f Fix quick filter not being applied on startup 2023-07-13 11:00:05 -06:00
Mbucari cb91a591f0 inc ver 2023-07-13 09:58:45 -06:00
Mbucari 0c0c556c6a Merge pull request #674 from Mbucari/master
Fix #673
2023-07-13 09:31:28 -06:00
MBucari ff63b73c09 Fix #673 2023-07-13 09:30:02 -06:00
Mbucari c1d56adbd2 Add groupbox title 2023-07-12 21:29:00 -06:00
170 changed files with 2807 additions and 1243 deletions

No files matched your search

+4 -4
View File
@@ -33,7 +33,7 @@ on:
env: env:
DOTNET_CONFIGURATION: 'Release' DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x' DOTNET_VERSION: '8.0.x'
RELEASE_NAME: 'chardonnay' RELEASE_NAME: 'chardonnay'
jobs: jobs:
@@ -41,9 +41,9 @@ jobs:
name: '${{ inputs.OS }}-${{ inputs.architecture }}' name: '${{ inputs.OS }}-${{ inputs.architecture }}'
runs-on: ${{ inputs.runs_on }} runs-on: ${{ inputs.runs_on }}
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
env: env:
@@ -124,7 +124,7 @@ jobs:
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}" echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle - name: Publish bundle
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ${{ steps.bundle.outputs.artifact }} name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }} path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
+4 -4
View File
@@ -18,7 +18,7 @@ on:
env: env:
DOTNET_CONFIGURATION: 'Release' DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x' DOTNET_VERSION: '8.0.x'
jobs: jobs:
build: build:
@@ -35,9 +35,9 @@ jobs:
release_name: classic release_name: classic
prefix: Classic- prefix: Classic-
steps: steps:
- uses: actions/checkout@v3 - uses: actions/checkout@v4
- name: Setup .NET - name: Setup .NET
uses: actions/setup-dotnet@v3 uses: actions/setup-dotnet@v4
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ env.DOTNET_VERSION }}
env: env:
@@ -107,7 +107,7 @@ jobs:
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip" Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
- name: Publish artifact - name: Publish artifact
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v4
with: with:
name: ${{ steps.zip.outputs.artifact }}.zip name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
+5 -5
View File
@@ -24,22 +24,22 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v3 uses: actions/checkout@v4
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v2 uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx - name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2 uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub - name: Login to Docker Hub
uses: docker/login-action@v2 uses: docker/login-action@v3
with: with:
username: ${{ secrets.docker_username }} username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }} password: ${{ secrets.docker_token }}
- name: Build and push - name: Build and push
uses: docker/build-push-action@v4 uses: docker/build-push-action@v6
with: with:
push: true push: true
build-args: 'FOLDER_NAME=Linux-chardonnay' build-args: 'FOLDER_NAME=Linux-chardonnay'
+3 -3
View File
@@ -39,13 +39,13 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v3 uses: actions/download-artifact@v4
with: with:
path: artifacts path: artifacts
- name: Release - name: Release
id: release id: release
uses: softprops/action-gh-release@v1 uses: softprops/action-gh-release@v2
with: with:
name: Libation v${{ needs.prerelease.outputs.version }} name: Libation v${{ needs.prerelease.outputs.version }}
body: <Put a body here> body: <Put a body here>
@@ -53,7 +53,7 @@ jobs:
prerelease: false prerelease: false
- name: Upload release assets - name: Upload release assets
uses: dwenegar/upload-release-assets@v1 uses: dwenegar/upload-release-assets@v2
env: env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}' GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
with: with:
+2 -2
View File
@@ -1,12 +1,12 @@
# Dockerfile # Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env FROM mcr.microsoft.com/dotnet/sdk:8.0 as build-env
COPY Source /Source COPY Source /Source
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
FROM mcr.microsoft.com/dotnet/runtime:7.0 FROM mcr.microsoft.com/dotnet/runtime:8.0
ENV SLEEP_TIME "30m" ENV SLEEP_TIME "30m"
+1 -1
View File
@@ -1,6 +1,6 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
+12 -3
View File
@@ -1,10 +1,11 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
### Disclaimer
# Disclaimer
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported. The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
### Setup ### Setup
@@ -12,7 +13,15 @@ In order to use the docker image, you'll need to provide it with a copy of the `
In Settings.json, make the following changes: In Settings.json, make the following changes:
* Change `Books` to `/data` * Change `Books` to `/data`
* Change `InProgress` to `/tmp` * Change `InProgress` to `/tmp` *
*You may have to paste the following at the end of your your Settings.json file if `InProgess` is not present:
```
"InProgress": "/tmp"
```
![image](https://github.com/patienttruth/Libation/assets/105557996/cf65a108-cadf-4284-9000-e7672c99c59b)
### Running ### Running
Once the configuration files are copied and edited, the docker image can be run with the following command. Once the configuration files are copied and edited, the docker image can be run with the following command.
+38
View File
@@ -0,0 +1,38 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Frequently Asked Questions
## Q: What's the difference between 'Classic' and 'Chardonnay'?
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
* **Classic** is Windows only. Its older 'grey boxes' look has a compact design which allows for more information on the screen. Notably, Classic was written using an older, more mature technology which has built-in support for screenreaders.
* **Chardonnay** is available for Windows, Mac, and Linux. Its modern design has a more open look and feel.
## Q: Now that I've downloaded my books, how can I listen to them?
**A:** You can use any app which plays m4b files (or mp3 files if you used that setting). Here are just a few ideas. Disclaimer: I have no affiliation with any of these companies:
* iOS: [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
* iOS: [Bound](https://apps.apple.com/us/app/bound-audiobook-player/id1041727137)
* Android: [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer&hl=en_US&gl=US)
* Android: [Listen](https://play.google.com/store/apps/details?id=ru.litres.android.audio&hl=en_US&gl=US)
* Desktop: [VLC](https://www.videolan.org/)
* Windows Desktop: [Audibly](https://github.com/rstewa/Audibly) -- a desktop player build specifically for audiobooks
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: 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.
(Not exactly a *frequently* asked question but it's come up more than once)
+1 -1
View File
@@ -1,6 +1,6 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
+4 -3
View File
@@ -1,15 +1,16 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
### Install and Run Libation on Ubuntu
# 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. 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 dowbnload and install Libation, replacing the url with the latest Libation package url: Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
- Debian - Debian
```Console ```Console
+13 -1
View File
@@ -1,9 +1,10 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Run Libation on MacOS # Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac. This walkthrough should get you up and running with Libation on your Mac.
@@ -23,6 +24,17 @@ This walkthrough should get you up and running with Libation on your Mac.
``` ```
- Close the terminal and use Libation! - Close the terminal and use Libation!
## Troubleshooting
If Libation fails to start after completing the above steps, try the following:
1. Right-click the Libation app in your applications folder and select _Show Package Contents_
2. Open the `Contents` folder and then the `MacOS` folder.
3. Find the file named `Libation`, right-click it, and then select _Open_.
Libation _should_ launch, and you should now be able to open Libation by just double-clicking the app bundle in your applications folder.
## Running Hangover ## Running Hangover
Libation comes with a recovery app called Hangover. You can start it by running this command: Libation comes with a recovery app called Hangover. You can start it by running this command:
+7
View File
@@ -1,3 +1,10 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Naming Templates # Naming Templates
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options. File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
+1 -1
View File
@@ -1,6 +1,6 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
+38 -27
View File
@@ -1,28 +1,39 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524">
<path id="glass" d= <defs>
"M139,2 <g id="glass">
A 192,200 0 0 0 103,84 <path fill-rule="evenodd" d=
A 222,334 41 0 0 241,320 "M262,8
V478 h-117
H160 a 192,200 0 0 0 -36,82
A 16,16 0 0 0 160,510 a 222,334 41 0 0 138,236
H352 v158
A16 16 0 0 0 352,478 h-81
H271 a 16,16 0 0 0 0,32
V320 h192
A 222,334 -41 0 0 409,84 a 16 16 0 0 0 0,-32
A 192,200 0 0 0 373,2 h-81
M355,32 v-158
A 192,200 0 0 1 381,127 a 222,334 -41 0 0 138,-236
A 187.5,334 -35 0 1 256,286 a 192,200 0 0 0 -36,-82
A 187.5,334 35 0 1 131,127 h-117
A 192,200 0 0 1 157,32 m-99,30
H355 a 192,200 0 0 0 -26,95
z" /> a 187.5,334 35 0 0 125,159
a 187.5,334 -35 0 0 125,-159
<path id="wine-level" d= a 192,200 0 0 0 -26,-95
"M146,128 h-198
A 168,300 35 0 0 256,270 z"/>
A 168,300 -35 0 0 366,128 </g>
z"/> <g id="wine-level">
<path d=
"M158,136
a 168,305 35 0 0 104,136
a 168,305 -35 0 0 104,-136
z"/>
</g>
</defs>
<use href="#glass" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#wine-level" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#glass" fill="Black" />
<use href="#wine-level" fill="Black" />
</svg> </svg>

Before

Width:  |  Height:  |  Size: 585 B

After

Width:  |  Height:  |  Size: 968 B

+4 -1
View File
@@ -2,9 +2,11 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/MBucari?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Table of Contents # Table of Contents
- [Audible audiobook manager](#audible-audiobook-manager) - [Audible audiobook manager](#audible-audiobook-manager)
@@ -31,6 +33,7 @@
- [Custom File Naming](Documentation/NamingTemplates.md) - [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface) - [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Docker](Documentation/Docker.md) - [Docker](Documentation/Docker.md)
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
## Getting started ## Getting started
-3
View File
@@ -109,9 +109,6 @@ ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi fi
# workaround until this file is moved to the user's home directory
touch /usr/lib/libation/appsettings.json
chmod 666 /usr/lib/libation/appsettings.json
" >> $FOLDER_DEBIAN/postinst " >> $FOLDER_DEBIAN/postinst
echo "Creating control file..." echo "Creating control file..."
-2
View File
@@ -94,8 +94,6 @@ install * %{buildroot}%{_libdir}/%{name}/
if [ \$1 -eq 1 ] ; then if [ \$1 -eq 1 ] ; then
# Initial installation # Initial installation
touch %{_libdir}/%{name}/appsettings.json
chmod 666 %{_libdir}/%{name}/appsettings.json
ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation
ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -13,7 +13,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="1.1.0" /> <PackageReference Include="AAXClean.Codecs" Version="1.1.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -81,14 +81,7 @@ namespace AaxDecrypter
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString()); AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
} }
//Finishing configuring lame encoder. OnInitialized();
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged); OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]"); OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]"); OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
@@ -98,5 +91,7 @@ namespace AaxDecrypter
return !IsCanceled; return !IsCanceled;
} }
protected virtual void OnInitialized() { }
} }
} }
@@ -21,6 +21,18 @@ namespace AaxDecrypter
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
} }
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
chapters: null);
}
/* /*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489 https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
@@ -3,7 +3,6 @@ using AAXClean.Codecs;
using Dinah.Core.Net.Http; using Dinah.Core.Net.Http;
using FileManager; using FileManager;
using System; using System;
using System.Diagnostics;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
@@ -26,6 +25,18 @@ namespace AaxDecrypter
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync; AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
} }
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
DownloadOptions.ChapterInfo);
}
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync() protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{ {
FileUtility.SaferDelete(OutputFileName); FileUtility.SaferDelete(OutputFileName);
+12 -1
View File
@@ -8,7 +8,12 @@ namespace AaxDecrypter
public static class MpegUtil public static class MpegUtil
{ {
private const string TagDomain = "com.pilabor.tone"; private const string TagDomain = "com.pilabor.tone";
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate) public static void ConfigureLameOptions(
Mp4File mp4File,
LameConfig lameConfig,
bool downsample,
bool matchSourceBitrate,
ChapterInfo chapters)
{ {
double bitrateMultiple = 1; double bitrateMultiple = 1;
@@ -53,6 +58,12 @@ namespace AaxDecrypter
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part) if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
lameConfig.ID3.UserDefinedText.Add("PART", part); lameConfig.ID3.UserDefinedText.Add("PART", part);
if (chapters?.Count > 0)
{
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
}
} }
} }
} }
+9 -5
View File
@@ -1,12 +1,16 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<Version>10.6.0.1</Version> <Version>11.3.14.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Octokit" Version="6.0.0" /> <PackageReference Include="Octokit" Version="11.0.1" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="1.0.1" /> <!-- 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" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" /> <ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
@@ -19,4 +23,4 @@
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
@@ -124,6 +124,9 @@ namespace AppScaffolding
{ "MinimumLevel", "Information" }, { "MinimumLevel", "Information" },
{ "WriteTo", new JArray { "WriteTo", new JArray
{ {
// ABOUT SINKS
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use.
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved // new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject new JObject
{ {
@@ -233,6 +236,7 @@ namespace AppScaffolding
Version = BuildVersion.ToString(), Version = BuildVersion.ToString(),
ReleaseIdentifier, ReleaseIdentifier,
Configuration.OS, Configuration.OS,
Environment.OSVersion,
InteropFactory.InteropFunctionsType, InteropFactory.InteropFunctionsType,
Mode = mode, Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
@@ -242,6 +246,7 @@ namespace AppScaffolding
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(), LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(), LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.AutoScan,
config.BetaOptIn, config.BetaOptIn,
config.UseCoverAsFolderIcon, config.UseCoverAsFolderIcon,
config.LibationFiles, config.LibationFiles,
@@ -254,6 +259,8 @@ namespace AppScaffolding
AudibleFileStorage.DecryptInProgressDirectory, AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(), DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
}); });
if (InteropFactory.InteropFunctionsType is null) if (InteropFactory.InteropFunctionsType is null)
@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="30.0.1" /> <PackageReference Include="CsvHelper" Version="32.0.3" />
<PackageReference Include="NPOI" Version="2.6.0" /> <PackageReference Include="NPOI" Version="2.7.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -119,9 +119,15 @@ namespace ApplicationServices
logTime($"pre {nameof(scanAccountsAsync)} all"); logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions var libraryOptions = new LibraryOptions
{ {
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, ResponseGroups
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215 = LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
}; };
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all"); logTime($"post {nameof(scanAccountsAsync)} all");
@@ -141,7 +141,7 @@ namespace ApplicationServices
PictureId = a.Book.PictureId, PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged, IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished, DatePublished = a.Book.DatePublished,
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "", CategoriesNames = a.Book.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "",
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating, MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating, MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
+5 -1
View File
@@ -207,7 +207,11 @@ namespace AudibleUtilities
try try
{ {
var sw = Stopwatch.StartNew(); var sw = Stopwatch.StartNew();
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
sw.Stop(); sw.Stop();
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms"); Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
@@ -90,8 +90,10 @@ namespace AudibleUtilities
var distinct = items.GetSeriesDistinct(); var distinct = items.GetSeriesDistinct();
if (distinct.Any(s => s.SeriesId is null)) if (distinct.Any(s => s.SeriesId is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items))); exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
if (distinct.Any(s => s.SeriesName is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items))); //// unfortunately, a user has a series with no name
//if (distinct.Any(s => s.SeriesName is null))
// exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
return exceptions; return exceptions;
} }
@@ -1,11 +1,11 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AudibleApi" Version="8.4.1.1" /> <PackageReference Include="AudibleApi" Version="9.1.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
namespace DataLayer.Configurations
{
internal class BookCategoryConfig : IEntityTypeConfiguration<BookCategory>
{
public void Configure(EntityTypeBuilder<BookCategory> entity)
{
entity.HasKey(bc => new { bc.BookId, bc.CategoryLadderId });
entity.HasIndex(bc => bc.BookId);
entity.HasIndex(bc => bc.CategoryLadderId);
entity
.HasOne(bc => bc.Book)
.WithMany(b => b.CategoriesLink)
.HasForeignKey(bc => bc.BookId);
entity
.HasOne(bc => bc.CategoryLadder)
.WithMany(c => c.BooksLink)
.HasForeignKey(bc => bc.CategoryLadderId);
}
}
}
+1 -21
View File
@@ -21,10 +21,7 @@ namespace DataLayer.Configurations
entity.Ignore(nameof(Book.Narrators)); entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat)); entity.Ignore(nameof(Book.AudioFormat));
entity.Ignore(nameof(Book.TitleWithSubtitle)); entity.Ignore(nameof(Book.TitleWithSubtitle));
//// these don't seem to matter entity.Ignore(b => b.Categories);
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));
//entity.Ignore(nameof(Book.HasPdfs));
// OwnsMany: "Can only ever appear on navigation properties of other entity types. // OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner." // Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
@@ -58,23 +55,6 @@ namespace DataLayer.Configurations
// owns it 1:1, store in same table // owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating); b_udi.OwnsOne(udi => udi.Rating);
}); });
entity
.Metadata
.FindNavigation(nameof(Book.ContributorsLink))
// PropertyAccessMode.Field : Contributions is a get-only property, not a field, so use its backing field
.SetPropertyAccessMode(PropertyAccessMode.Field);
entity
.Metadata
.FindNavigation(nameof(Book.SeriesLink))
// PropertyAccessMode.Field : Series is a get-only property, not a field, so use its backing field
.SetPropertyAccessMode(PropertyAccessMode.Field);
entity
.HasOne(b => b.Category)
.WithMany()
.HasForeignKey(b => b.CategoryId);
} }
} }
} }
@@ -10,8 +10,11 @@ namespace DataLayer.Configurations
entity.HasKey(c => c.CategoryId); entity.HasKey(c => c.CategoryId);
entity.HasIndex(c => c.AudibleCategoryId); entity.HasIndex(c => c.AudibleCategoryId);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs entity.Ignore(c => c.CategoryLadders);
entity.HasData(Category.GetEmpty());
entity
.HasMany(e => e._categoryLadders)
.WithMany(e => e._categories);
} }
} }
} }
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Metadata.Builders;
using Microsoft.EntityFrameworkCore;
namespace DataLayer.Configurations
{
internal class CategoryLadderConfig : IEntityTypeConfiguration<CategoryLadder>
{
public void Configure(EntityTypeBuilder<CategoryLadder> entity)
{
entity.HasKey(cl => cl.CategoryLadderId);
entity.Ignore(cl => cl.Categories);
entity
.HasMany(cl => cl._categories)
.WithMany(c => c._categoryLadders);
entity
.Metadata
.FindNavigation(nameof(CategoryLadder.BooksLink))
.SetPropertyAccessMode(PropertyAccessMode.Field);
}
}
}
+6 -6
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
@@ -10,14 +10,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.2.1" /> <PackageReference Include="Dinah.Core" Version="8.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" /> <PackageReference Include="Dinah.EntityFrameworkCore" Version="8.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.5"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="8.0.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="8.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.5"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="8.0.5">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
+64 -59
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using Dinah.Core; using Dinah.Core;
using Microsoft.EntityFrameworkCore; using Microsoft.EntityFrameworkCore;
@@ -37,7 +38,7 @@ namespace DataLayer
public string Subtitle { get; private set; } public string Subtitle { get; private set; }
private string _titleWithSubtitle; private string _titleWithSubtitle;
public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}"; public string TitleWithSubtitle => _titleWithSubtitle ??= string.IsNullOrEmpty(Subtitle) ? Title : $"{Title}: {Subtitle}";
public string Description { get; private set; } public string Description { get; private set; }
public int LengthInMinutes { get; private set; } public int LengthInMinutes { get; private set; }
public ContentType ContentType { get; private set; } public ContentType ContentType { get; private set; }
public string Locale { get; private set; } public string Locale { get; private set; }
@@ -55,10 +56,6 @@ namespace DataLayer
public DateTime? DatePublished { get; private set; } public DateTime? DatePublished { get; private set; }
public string Language { get; private set; } public string Language { get; private set; }
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
public Category Category { get; private set; }
// is owned, not optional 1:1 // is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; } public UserDefinedItem UserDefinedItem { get; private set; }
@@ -77,9 +74,8 @@ namespace DataLayer
string description, string description,
int lengthInMinutes, int lengthInMinutes,
ContentType contentType, ContentType contentType,
IEnumerable<Contributor> authors, IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators, IEnumerable<Contributor> narrators,
Category category,
string localeName) string localeName)
{ {
// validate // validate
@@ -87,7 +83,7 @@ namespace DataLayer
var productId = audibleProductId.Id; var productId = audibleProductId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId)); ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
// assign as soon as possible. stuff below relies on this // assign as soon as possible. stuff below relies on this
AudibleProductId = productId; AudibleProductId = productId;
Locale = localeName; Locale = localeName;
@@ -95,43 +91,38 @@ namespace DataLayer
// non-ef-ctor init.s // non-ef-ctor init.s
UserDefinedItem = new UserDefinedItem(this); UserDefinedItem = new UserDefinedItem(this);
_contributorsLink = new HashSet<BookContributor>(); ContributorsLink = new HashSet<BookContributor>();
CategoriesLink = new HashSet<BookCategory>();
_seriesLink = new HashSet<SeriesBook>(); _seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>(); _supplements = new HashSet<Supplement>();
Category = category;
// simple assigns // simple assigns
UpdateTitle(title, subtitle); UpdateTitle(title, subtitle);
Description = description?.Trim() ?? ""; Description = description?.Trim() ?? "";
LengthInMinutes = lengthInMinutes; LengthInMinutes = lengthInMinutes;
ContentType = contentType; ContentType = contentType;
// assigns with biz logic // assigns with biz logic
ReplaceAuthors(authors); ReplaceAuthors(authors);
ReplaceNarrators(narrators); ReplaceNarrators(narrators);
} }
public void UpdateTitle(string title, string subtitle)
public void UpdateTitle(string title, string subtitle)
{ {
Title = title?.Trim() ?? ""; Title = title?.Trim() ?? "";
Subtitle = subtitle?.Trim() ?? ""; Subtitle = subtitle?.Trim() ?? "";
_titleWithSubtitle = null; _titleWithSubtitle = null;
} }
public void UpdateLengthInMinutes(int lengthInMinutes)
=> LengthInMinutes = lengthInMinutes;
#region contributors, authors, narrators #region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded internal HashSet<BookContributor> ContributorsLink { get; private set; }
private HashSet<BookContributor> _contributorsLink;
// i'd like this to be internal but migration throws this exception when i try:
// Value cannot be null.
// Parameter name: property
public IEnumerable<BookContributor> ContributorsLink
=> _contributorsLink?
.OrderBy(bc => bc.Order)
.ToList();
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList(); public IEnumerable<Contributor> Authors => ContributorsLink.ByRole(Role.Author).Select(bc => bc.Contributor).ToList();
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList(); public IEnumerable<Contributor> Narrators => ContributorsLink.ByRole(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name; public string Publisher => ContributorsLink.ByRole(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null) public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
=> replaceContributors(authors, Role.Author, context); => replaceContributors(authors, Role.Author, context);
@@ -144,47 +135,70 @@ namespace DataLayer
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors)); ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
// the edge cases of doing local-loaded vs remote-only got weird. just load it // the edge cases of doing local-loaded vs remote-only got weird. just load it
if (_contributorsLink is null) if (ContributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load(); getEntry(context).Collection(s => s.ContributorsLink).Load();
var isIdentical
= ContributorsLink
.ByRole(role)
.Select(c => c.Contributor)
.SequenceEqual(newContributors);
var roleContributions = getContributions(role);
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
if (isIdentical) if (isIdentical)
return; return;
_contributorsLink.RemoveWhere(bc => bc.Role == role); ContributorsLink.RemoveWhere(bc => bc.Role == role);
addNewContributors(newContributors, role); addNewContributors(newContributors, role);
} }
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role) private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
{ {
byte order = 0; byte order = 0;
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++)); var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
var newContributions = new HashSet<BookContributor>(newContributionsEnum); var newContributions = new HashSet<BookContributor>(newContributionsEnum);
_contributorsLink.UnionWith(newContributions); ContributorsLink.UnionWith(newContributions);
} }
private List<BookContributor> getContributions(Role role)
=> ContributorsLink
.Where(a => a.Role == role)
.OrderBy(a => a.Order)
.ToList();
#endregion #endregion
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context) private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
}
#region categories
internal HashSet<BookCategory> CategoriesLink { get; private set; }
private ReadOnlyCollection<BookCategory> _categoriesReadOnly;
public ReadOnlyCollection<BookCategory> Categories
{
get
{
if (_categoriesReadOnly?.SequenceEqual(CategoriesLink) is not true)
_categoriesReadOnly = CategoriesLink.ToList().AsReadOnly();
return _categoriesReadOnly;
}
}
public void SetCategoryLadders(IEnumerable<CategoryLadder> ladders)
{ {
ArgumentValidator.EnsureNotNull(context, nameof(context)); ArgumentValidator.EnsureNotNull(ladders, nameof(ladders));
var entry = context.Entry(this); //Replace all existing category ladders.
//Some books make have duplocate ladders
if (!entry.IsKeySet) CategoriesLink.Clear();
throw new InvalidOperationException("Could not load a valid Book from database"); CategoriesLink.UnionWith(ladders.Distinct().Select(l => new BookCategory(this, l)));
return entry;
} }
#endregion
#region series #region series
private HashSet<SeriesBook> _seriesLink; private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList(); public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
public void UpsertSeries(Series series, string order, DbContext context = null) public void UpsertSeries(Series series, string order, DbContext context = null)
@@ -234,15 +248,6 @@ namespace DataLayer
Language = language?.FirstCharToUpper() ?? Language; Language = language?.FirstCharToUpper() ?? Language;
} }
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category is null)
getEntry(context).Reference(s => s.Category).Load();
Category = category;
}
public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}";
} }
} }
@@ -0,0 +1,20 @@
using Dinah.Core;
namespace DataLayer
{
public class BookCategory
{
internal int BookId { get; private set; }
internal int CategoryLadderId { get; private set; }
public Book Book { get; private set; }
public CategoryLadder CategoryLadder { get; private set; }
private BookCategory() { }
internal BookCategory(Book book, CategoryLadder categoriesList)
{
Book = ArgumentValidator.EnsureNotNull(book, nameof(book));
CategoryLadder = ArgumentValidator.EnsureNotNull(categoriesList, nameof(categoriesList));
}
}
}
+20 -20
View File
@@ -1,9 +1,9 @@
using System; using System.Collections.Generic;
using System.Collections.Generic; using System.Collections.ObjectModel;
using System.Linq; using System.Linq;
using Dinah.Core; using Dinah.Core;
using Microsoft.EntityFrameworkCore;
#nullable enable
namespace DataLayer namespace DataLayer
{ {
public class AudibleCategoryId public class AudibleCategoryId
@@ -15,20 +15,29 @@ namespace DataLayer
Id = id; Id = id;
} }
} }
public class Category public class Category
{ {
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new() { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; } internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; } public string? AudibleCategoryId { get; private set; }
public string Name { get; private set; } public string? Name { get; internal set; }
public Category ParentCategory { get; private set; }
private Category() { } internal List<CategoryLadder> _categoryLadders = new();
private ReadOnlyCollection<CategoryLadder>? _categoryLaddersReadOnly;
public ReadOnlyCollection<CategoryLadder> CategoryLadders
{
get
{
if (_categoryLaddersReadOnly?.SequenceEqual(_categoryLadders) is not true)
_categoryLaddersReadOnly = _categoryLadders.AsReadOnly();
return _categoryLaddersReadOnly;
}
}
private Category() { }
/// <summary>special id class b/c it's too easy to get string order mixed up</summary> /// <summary>special id class b/c it's too easy to get string order mixed up</summary>
public Category(AudibleCategoryId audibleSeriesId, string name, Category parentCategory = null) public Category(AudibleCategoryId audibleSeriesId, string name)
{ {
ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId)); ArgumentValidator.EnsureNotNull(audibleSeriesId, nameof(audibleSeriesId));
var id = audibleSeriesId.Id; var id = audibleSeriesId.Id;
@@ -37,15 +46,6 @@ namespace DataLayer
AudibleCategoryId = id; AudibleCategoryId = id;
Name = name; Name = name;
UpdateParentCategory(parentCategory);
}
public void UpdateParentCategory(Category parentCategory)
{
// don't overwrite with null but not an error
if (parentCategory is not null)
ParentCategory = parentCategory;
} }
public override string ToString() => $"[{AudibleCategoryId}] {Name}"; public override string ToString() => $"[{AudibleCategoryId}] {Name}";
@@ -0,0 +1,58 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
#nullable enable
namespace DataLayer
{
public class CategoryLadder : IEquatable<CategoryLadder>
{
internal int CategoryLadderId { get; private set; }
internal List<Category> _categories;
private ReadOnlyCollection<Category>? _categoriesReadOnly;
public ReadOnlyCollection<Category> Categories
{
get
{
if (_categoriesReadOnly?.SequenceEqual(_categories) is not true)
_categoriesReadOnly = _categories.AsReadOnly();
return _categoriesReadOnly;
}
}
private HashSet<BookCategory>? _booksLink;
public IEnumerable<BookCategory>? BooksLink => _booksLink?.ToList();
private CategoryLadder() { _categories = new(); }
public CategoryLadder(List<Category> categories)
{
ArgumentValidator.EnsureNotNull(categories, nameof(categories));
ArgumentValidator.EnsureGreaterThan(categories.Count, nameof(categories), 0);
_booksLink = new HashSet<BookCategory>();
_categories = categories;
}
public override int GetHashCode()
{
HashCode hashCode = default;
foreach (var category in _categories)
hashCode.Add(category.AudibleCategoryId);
return hashCode.ToHashCode();
}
public bool Equals(CategoryLadder? other)
=> other?._categories is not null
&& Equals(other._categories.Select(c => c.AudibleCategoryId));
public bool Equals(IEnumerable<string?>? categoryIds)
=> categoryIds is not null
&& _categories.Select(c => c.AudibleCategoryId).SequenceEqual(categoryIds);
public override bool Equals(object? obj)
=> obj is CategoryLadder other && Equals(other);
public override string ToString() => string.Join(" > ", _categories.Select(c => c.Name));
}
}
+32 -10
View File
@@ -7,8 +7,13 @@ using System.Threading.Tasks;
namespace DataLayer namespace DataLayer
{ {
public static class EntityExtensions public static class EntityExtensions
{ {
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle); public static IEnumerable<BookContributor> ByRole(this IEnumerable<BookContributor> contributors, Role role)
=> contributors
.Where(a => a.Role == role)
.OrderBy(a => a.Order);
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name)); public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name)); public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
@@ -46,14 +51,31 @@ namespace DataLayer
? $"{sb.Series.Name} (#{sb.Order})" ? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name; : sb.Series.Name;
} }
public static string[] CategoriesNames(this Book book)
=> book.Category is null ? new string[0] public static string[] LowestCategoryNames(this Book book)
: book.Category.ParentCategory is null ? new[] { book.Category.Name } => book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: new[] { book.Category.ParentCategory.Name, book.Category.Name }; : book
public static string[] CategoriesIds(this Book book) .CategoriesLink
=> book.Category is null ? null .Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId } .Where(c => c is not null)
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId }; .Distinct()
.ToArray();
public static string[] AllCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public static string[] AllCategoryIds(this Book book)
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5) public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
{ {
+4 -1
View File
@@ -23,6 +23,7 @@ namespace DataLayer
public DbSet<Contributor> Contributors { get; private set; } public DbSet<Contributor> Contributors { get; private set; }
public DbSet<Series> Series { get; private set; } public DbSet<Series> Series { get; private set; }
public DbSet<Category> Categories { get; private set; } public DbSet<Category> Categories { get; private set; }
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
public static LibationContext Create(string connectionString) public static LibationContext Create(string connectionString)
{ {
@@ -39,13 +40,15 @@ namespace DataLayer
{ {
base.OnModelCreating(modelBuilder); base.OnModelCreating(modelBuilder);
modelBuilder.ApplyConfiguration(new BookConfig()); modelBuilder.ApplyConfiguration(new BookConfig());
modelBuilder.ApplyConfiguration(new ContributorConfig()); modelBuilder.ApplyConfiguration(new ContributorConfig());
modelBuilder.ApplyConfiguration(new BookContributorConfig()); modelBuilder.ApplyConfiguration(new BookContributorConfig());
modelBuilder.ApplyConfiguration(new LibraryBookConfig()); modelBuilder.ApplyConfiguration(new LibraryBookConfig());
modelBuilder.ApplyConfiguration(new SeriesConfig()); modelBuilder.ApplyConfiguration(new SeriesConfig());
modelBuilder.ApplyConfiguration(new SeriesBookConfig()); modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig()); modelBuilder.ApplyConfiguration(new CategoryConfig());
modelBuilder.ApplyConfiguration(new CategoryLadderConfig());
modelBuilder.ApplyConfiguration(new BookCategoryConfig());
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"): // views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types // https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
@@ -0,0 +1,465 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20230718214617_AddCategoryLadder")]
partial class AddCategoryLadder
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,174 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddCategoryLadder : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Books_Categories_CategoryId",
table: "Books");
migrationBuilder.DropForeignKey(
name: "FK_Categories_Categories_ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropIndex(
name: "IX_Categories_ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropIndex(
name: "IX_Books_CategoryId",
table: "Books");
migrationBuilder.DeleteData(
table: "Categories",
keyColumn: "CategoryId",
keyValue: -1);
migrationBuilder.DropColumn(
name: "ParentCategoryCategoryId",
table: "Categories");
migrationBuilder.DropColumn(
name: "CategoryId",
table: "Books");
migrationBuilder.CreateTable(
name: "CategoryLadders",
columns: table => new
{
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
});
migrationBuilder.CreateTable(
name: "BookCategory",
columns: table => new
{
BookId = table.Column<int>(type: "INTEGER", nullable: false),
CategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
table.ForeignKey(
name: "FK_BookCategory_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
column: x => x.CategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CategoryCategoryLadder",
columns: table => new
{
_categoriesCategoryId = table.Column<int>(type: "INTEGER", nullable: false),
_categoryLaddersCategoryLadderId = table.Column<int>(type: "INTEGER", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
table.ForeignKey(
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
column: x => x._categoriesCategoryId,
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCategoryLadderId",
column: x => x._categoryLaddersCategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateIndex(
name: "IX_BookCategory_BookId",
table: "BookCategory",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookCategory_CategoryLadderId",
table: "BookCategory",
column: "CategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
table: "CategoryCategoryLadder",
column: "_categoryLaddersCategoryLadderId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookCategory");
migrationBuilder.DropTable(
name: "CategoryCategoryLadder");
migrationBuilder.DropTable(
name: "CategoryLadders");
migrationBuilder.AddColumn<int>(
name: "ParentCategoryCategoryId",
table: "Categories",
type: "INTEGER",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "CategoryId",
table: "Books",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.InsertData(
table: "Categories",
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
values: new object[] { -1, "", "", null });
migrationBuilder.CreateIndex(
name: "IX_Categories_ParentCategoryCategoryId",
table: "Categories",
column: "ParentCategoryCategoryId");
migrationBuilder.CreateIndex(
name: "IX_Books_CategoryId",
table: "Books",
column: "CategoryId");
migrationBuilder.AddForeignKey(
name: "FK_Books_Categories_CategoryId",
table: "Books",
column: "CategoryId",
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
migrationBuilder.AddForeignKey(
name: "FK_Categories_Categories_ParentCategoryCategoryId",
table: "Categories",
column: "ParentCategoryCategoryId",
principalTable: "Categories",
principalColumn: "CategoryId");
}
}
}
@@ -17,6 +17,21 @@ namespace DataLayer.Migrations
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.5"); modelBuilder.HasAnnotation("ProductVersion", "7.0.5");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b => modelBuilder.Entity("DataLayer.Book", b =>
{ {
b.Property<int>("BookId") b.Property<int>("BookId")
@@ -26,9 +41,6 @@ namespace DataLayer.Migrations
b.Property<string>("AudibleProductId") b.Property<string>("AudibleProductId")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType") b.Property<int>("ContentType")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
@@ -69,11 +81,26 @@ namespace DataLayer.Migrations
b.HasIndex("AudibleProductId"); b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books"); b.ToTable("Books");
}); });
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b => modelBuilder.Entity("DataLayer.BookContributor", b =>
{ {
b.Property<int>("BookId") b.Property<int>("BookId")
@@ -109,24 +136,22 @@ namespace DataLayer.Migrations
b.Property<string>("Name") b.Property<string>("Name")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId"); b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId"); b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories"); b.ToTable("Categories");
});
b.HasData( modelBuilder.Entity("DataLayer.CategoryLadder", b =>
new {
{ b.Property<int>("CategoryLadderId")
CategoryId = -1, .ValueGeneratedOnAdd()
AudibleCategoryId = "", .HasColumnType("INTEGER");
Name = ""
}); b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
}); });
modelBuilder.Entity("DataLayer.Contributor", b => modelBuilder.Entity("DataLayer.Contributor", b =>
@@ -216,14 +241,23 @@ namespace DataLayer.Migrations
b.ToTable("SeriesBook"); b.ToTable("SeriesBook");
}); });
modelBuilder.Entity("DataLayer.Book", b => modelBuilder.Entity("CategoryCategoryLadder", b =>
{ {
b.HasOne("DataLayer.Category", "Category") b.HasOne("DataLayer.Category", null)
.WithMany() .WithMany()
.HasForeignKey("CategoryId") .HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade) .OnDelete(DeleteBehavior.Cascade)
.IsRequired(); .IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 => b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{ {
b1.Property<int>("BookId") b1.Property<int>("BookId")
@@ -324,8 +358,6 @@ namespace DataLayer.Migrations
b1.Navigation("Rating"); b1.Navigation("Rating");
}); });
b.Navigation("Category");
b.Navigation("Rating"); b.Navigation("Rating");
b.Navigation("Supplements"); b.Navigation("Supplements");
@@ -333,6 +365,25 @@ namespace DataLayer.Migrations
b.Navigation("UserDefinedItem"); b.Navigation("UserDefinedItem");
}); });
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b => modelBuilder.Entity("DataLayer.BookContributor", b =>
{ {
b.HasOne("DataLayer.Book", "Book") b.HasOne("DataLayer.Book", "Book")
@@ -352,15 +403,6 @@ namespace DataLayer.Migrations
b.Navigation("Contributor"); b.Navigation("Contributor");
}); });
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b => modelBuilder.Entity("DataLayer.LibraryBook", b =>
{ {
b.HasOne("DataLayer.Book", "Book") b.HasOne("DataLayer.Book", "Book")
@@ -393,11 +435,18 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.Book", b => modelBuilder.Entity("DataLayer.Book", b =>
{ {
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink"); b.Navigation("ContributorsLink");
b.Navigation("SeriesLink"); b.Navigation("SeriesLink");
}); });
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b => modelBuilder.Entity("DataLayer.Contributor", b =>
{ {
b.Navigation("BooksLink"); b.Navigation("BooksLink");
+1 -1
View File
@@ -34,7 +34,7 @@ namespace DataLayer
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements // owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(b => b.Category).ThenInclude(c => c.ParentCategory); .Include(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
public static bool IsProduct(this Book book) public static bool IsProduct(this Book book)
=> book.ContentType is not ContentType.Episode and not ContentType.Parent; => book.ContentType is not ContentType.Episode and not ContentType.Parent;
@@ -0,0 +1,11 @@
using Microsoft.EntityFrameworkCore;
using System.Linq;
namespace DataLayer
{
public static class CategoryQueries
{
public static IQueryable<CategoryLadder> GetCategoryLadders(this LibationContext context)
=> context.CategoryLadders.Include(c => c._categories);
}
}
@@ -55,7 +55,7 @@ namespace DataLayer
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements // owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series) .Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor) .Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory); .Include(le => le.Book).ThenInclude(b => b.CategoriesLink).ThenInclude(c => c.CategoryLadder).ThenInclude(c => c._categories);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks) public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren); => libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
+15 -16
View File
@@ -99,20 +99,6 @@ namespace DtoImporterService
.Select(n => contributorImporter.Cache[n.Name]) .Select(n => contributorImporter.Cache[n.Name])
.ToList(); .ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is also possible
// CATEGORY HACK: only use the 1st 2 categories
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var lastCategory
= item.Categories.Length == 0 ? ""
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
// 2+
: item.Categories[1].CategoryId;
var category = categoryImporter.Cache[lastCategory];
Book book; Book book;
try try
{ {
@@ -125,7 +111,6 @@ namespace DtoImporterService
contentType, contentType,
authors, authors,
narrators, narrators,
category,
importItem.LocaleName) importItem.LocaleName)
).Entity; ).Entity;
Cache.Add(book.AudibleProductId, book); Cache.Add(book.AudibleProductId, book);
@@ -140,7 +125,6 @@ namespace DtoImporterService
contentType, contentType,
QtyAuthors = authors?.Count, QtyAuthors = authors?.Count,
QtyNarrators = narrators?.Count, QtyNarrators = narrators?.Count,
Category = category?.Name,
importItem.LocaleName importItem.LocaleName
}); });
throw; throw;
@@ -165,6 +149,8 @@ namespace DtoImporterService
{ {
var item = importItem.DtoItem; var item = importItem.DtoItem;
book.UpdateLengthInMinutes(item.LengthInMinutes);
// Update the book titles, since formatting can change // Update the book titles, since formatting can change
book.UpdateTitle(item.Title, item.Subtitle); book.UpdateTitle(item.Title, item.Subtitle);
@@ -201,6 +187,19 @@ namespace DtoImporterService
book.UpsertSeries(series, seriesEntry.Sequence); book.UpsertSeries(series, seriesEntry.Sequence);
} }
} }
if (item.CategoryLadders is not null)
{
var ladders = new List<DataLayer.CategoryLadder>();
foreach (var ladder in item.CategoryLadders.Select(cl => cl.Ladder).Where(l => l?.Length > 0))
{
var categoryIds = ladder.Select(l => l.CategoryId).ToList();
ladders.Add(categoryImporter.LadderCache.Single(c => c.Equals(categoryIds)));
}
//Set all ladders at once so ladders that have been
//removed by audible can be removed from the DB
book.SetCategoryLadders(ladders);
}
} }
private static DataLayer.ContentType GetContentType(Item item) private static DataLayer.ContentType GetContentType(Item item)
+48 -38
View File
@@ -12,76 +12,86 @@ namespace DtoImporterService
{ {
protected override IValidator Validator => new CategoryValidator(); protected override IValidator Validator => new CategoryValidator();
public Dictionary<string, Category> Cache { get; private set; } = new(); private Dictionary<string, Category> CategoryCache { get; set; } = new();
public HashSet<DataLayer.CategoryLadder> LadderCache { get; private set; } = new();
public CategoryImporter(LibationContext context) : base(context) { } public CategoryImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<ImportItem> importItems) protected override int DoImport(IEnumerable<ImportItem> importItems)
{ {
// get distinct
var categoryIds = importItems
.Select(i => i.DtoItem)
.GetCategoriesDistinct()
.Select(c => c.CategoryId)
.Distinct()
.ToList();
// load db existing => .Local // load db existing => .Local
loadLocal_categories(categoryIds); loadLocal_categories();
// upsert // upsert
var categoryPairs = importItems //Import item may not have no (null) categories
.Select(i => i.DtoItem) var categoryLadders = importItems
.GetCategoryPairsDistinct() .Where(i => i.DtoItem.CategoryLadders is not null)
.SelectMany(i => i.DtoItem.CategoryLadders)
.Select(cl => cl?.Ladder)
.Where(l => l?.Length > 0)
.ToList(); .ToList();
var qtyNew = upsertCategories(categoryPairs);
var qtyNew = upsertCategories(categoryLadders);
return qtyNew; return qtyNew;
} }
private void loadLocal_categories(List<string> categoryIds) private void loadLocal_categories()
{ {
// must include default/empty/missing
categoryIds.Add(Category.GetEmpty().AudibleCategoryId);
// load existing => local // load existing => local
Cache = DbContext.Categories LadderCache = DbContext.GetCategoryLadders().ToHashSet();
.Where(c => categoryIds.Contains(c.AudibleCategoryId)) CategoryCache = LadderCache.SelectMany(cl => cl.Categories).ToDictionarySafe(c => c.AudibleCategoryId);
.ToDictionarySafe(c => c.AudibleCategoryId);
} }
// only use after loading contributors => local // only use after loading contributors => local
private int upsertCategories(List<Ladder[]> categoryPairs) private int upsertCategories(List<Ladder[]> ladders)
{ {
var qtyNew = 0; var qtyNew = 0;
foreach (var pair in categoryPairs) foreach (var ladder in ladders)
{ {
for (var i = 0; i < pair.Length; i++) var categories = new List<Category>(ladder.Length);
for (var i = 0; i < ladder.Length; i++)
{ {
// CATEGORY HACK: not yet supported: depth beyond 0 and 1 var id = ladder[i].CategoryId;
if (i > 1) var name = ladder[i].CategoryName;
break;
var id = pair[i].CategoryId; if (!CategoryCache.TryGetValue(id, out var category))
var name = pair[i].CategoryName;
Category parentCategory = null;
if (i == 1)
Cache.TryGetValue(pair[0].CategoryId, out parentCategory);
if (!Cache.TryGetValue(id, out var category))
{ {
category = addCategory(id, name); category = addCategory(id, name);
qtyNew++;
} }
category.UpdateParentCategory(parentCategory); categories.Add(category);
}
var categoryLadder = new DataLayer.CategoryLadder(categories);
if (!LadderCache.Contains(categoryLadder))
{
addCategoryLadder(categoryLadder);
qtyNew++;
} }
} }
return qtyNew; return qtyNew;
} }
private DataLayer.CategoryLadder addCategoryLadder(DataLayer.CategoryLadder categoryList)
{
try
{
var entityEntry = DbContext.CategoryLadders.Add(categoryList);
var entity = entityEntry.Entity;
LadderCache.Add(entity);
return entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList);
throw;
}
}
private Category addCategory(string id, string name) private Category addCategory(string id, string name)
{ {
try try
@@ -91,7 +101,7 @@ namespace DtoImporterService
var entityEntry = DbContext.Categories.Add(category); var entityEntry = DbContext.Categories.Add(category);
var entity = entityEntry.Entity; var entity = entityEntry.Entity;
Cache.Add(entity.AudibleCategoryId, entity); CategoryCache.Add(entity.AudibleCategoryId, entity);
return entity; return entity;
} }
catch (Exception ex) catch (Exception ex)
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+2 -1
View File
@@ -21,7 +21,8 @@ namespace FileLiberator
var series = libraryBook.Book.SeriesLink.SingleOrDefault(); var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null) if (series is not null)
{ {
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId); using var context = ApplicationServices.DbContexts.GetContext();
var seriesParent = context.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
if (seriesParent is not null) if (seriesParent is not null)
{ {
+10 -4
View File
@@ -51,18 +51,24 @@ namespace FileLiberator
var config = Configuration.Instance; var config = Configuration.Instance;
var lameConfig = GetLameOptions(config); var lameConfig = GetLameOptions(config);
var chapters = m4bBook.GetChaptersFromMetadata();
//Finishing configuring lame encoder. //Finishing configuring lame encoder.
AaxDecrypter.MpegUtil.ConfigureLameOptions( AaxDecrypter.MpegUtil.ConfigureLameOptions(
m4bBook, m4bBook,
lameConfig, lameConfig,
config.LameDownsampleMono, config.LameDownsampleMono,
config.LameMatchSourceBR); config.LameMatchSourceBR,
chapters);
using var mp3File = File.OpenWrite(Path.GetTempFileName()); if (m4bBook.AppleTags.Tracks is (int trackNum, int trackCount))
{
lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString();
}
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
try try
{ {
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig); Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate; Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
await Mp4Operation; await Mp4Operation;
+16 -14
View File
@@ -140,7 +140,7 @@ namespace FileLiberator
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions); new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
if (config.AllowLibationFixup) if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames()); converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.LowestCategoryNames());
abDownloader = converter; abDownloader = converter;
} }
@@ -160,21 +160,16 @@ namespace FileLiberator
{ {
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json"); var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
saveMetadata(libraryBook, contentLic.ContentMetadata, metadataFile); var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile);
} }
return success; return success;
} }
private void saveMetadata(LibraryBook libraryBook, ContentMetadata contentMetadata, string fileName)
{
var export = Newtonsoft.Json.Linq.JObject.FromObject(LibToDtos.ToDtos(new[] { libraryBook })[0]);
export.Add(nameof(contentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(contentMetadata.ChapterInfo));
export.Add(nameof(contentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentMetadata.ContentReference));
File.WriteAllText(fileName, export.ToString());
OnFileCreated(libraryBook, fileName);
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic) private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic)
{ {
//If DrmType != Adrm the delivered file is an unencrypted mp3. //If DrmType != Adrm the delivered file is an unencrypted mp3.
@@ -380,8 +375,15 @@ namespace FileLiberator
{ {
if (Configuration.Instance.AllowLibationFixup) if (Configuration.Instance.AllowLibationFixup)
{ {
e = OnRequestCoverArt(); try
abDownloader.SetCoverArt(e); {
e = OnRequestCoverArt();
abDownloader.SetCoverArt(e);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
}
} }
if (e is not null) if (e is not null)
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
+1 -1
View File
@@ -30,7 +30,7 @@ namespace FileLiberator
using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var nickname var nickname
= persister.AccountsSettings.Accounts = persister.AccountsSettings.Accounts
.FirstOrDefault(a => a.AccountId == libraryBook.Account) .FirstOrDefault(a => a.AccountId == libraryBook.Account && a.Locale.Name == libraryBook.Book.Locale)
?.AccountName; ?.AccountName;
return new() return new()
+7 -6
View File
@@ -5,6 +5,7 @@ using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace FileManager namespace FileManager
{ {
/// <summary> /// <summary>
@@ -16,9 +17,9 @@ namespace FileManager
public string SearchPattern { get; private set; } public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; } public SearchOption SearchOption { get; private set; }
private FileSystemWatcher fileSystemWatcher { get; set; } private FileSystemWatcher? fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; } private BlockingCollection<FileSystemEventArgs>? directoryChangesEvents { get; set; }
private Task backgroundScanner { get; set; } private Task? backgroundScanner { get; set; }
private object fsCacheLocker { get; } = new(); private object fsCacheLocker { get; } = new();
private List<LongPath> fsCache { get; } = new(); private List<LongPath> fsCache { get; } = new();
@@ -32,7 +33,7 @@ namespace FileManager
Init(); Init();
} }
public LongPath FindFile(System.Text.RegularExpressions.Regex regex) public LongPath? FindFile(System.Text.RegularExpressions.Regex regex)
{ {
lock (fsCacheLocker) lock (fsCacheLocker)
return fsCache.FirstOrDefault(s => regex.IsMatch(s)); return fsCache.FirstOrDefault(s => regex.IsMatch(s));
@@ -105,13 +106,13 @@ namespace FileManager
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e) private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
{ {
directoryChangesEvents.Add(e); directoryChangesEvents?.Add(e);
} }
#region Background Thread #region Background Thread
private void BackgroundScanner() private void BackgroundScanner()
{ {
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1)) while (directoryChangesEvents?.TryTake(out var change, -1) is true)
{ {
lock (fsCacheLocker) lock (fsCacheLocker)
UpdateLocalCache(change); UpdateLocalCache(change);
+3 -3
View File
@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dinah.Core" Version="7.2.2.1" /> <PackageReference Include="Dinah.Core" Version="8.0.0.1" />
<PackageReference Include="Polly" Version="7.2.3" /> <PackageReference Include="Polly" Version="8.4.0" />
</ItemGroup> </ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+14 -9
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
@@ -7,20 +8,20 @@ using Dinah.Core;
using Polly; using Polly;
using Polly.Retry; using Polly.Retry;
#nullable enable
namespace FileManager namespace FileManager
{ {
public static class FileUtility public static class FileUtility
{ {
/// <summary> /// <summary>
/// "txt" => ".txt" /// "txt" => ".txt"
/// <br />".txt" => ".txt" /// <br />".txt" => ".txt"
/// <br />null or whitespace => "" /// <br />null or whitespace => ""
/// </summary> /// </summary>
public static string GetStandardizedExtension(string extension) [return: NotNull]
public static string GetStandardizedExtension(string? extension)
=> string.IsNullOrWhiteSpace(extension) => string.IsNullOrWhiteSpace(extension)
? (extension ?? "")?.Trim() ? string.Empty
: '.' + extension.Trim().Trim('.'); : '.' + extension.Trim().Trim('.');
/// <summary> /// <summary>
@@ -48,18 +49,18 @@ namespace FileManager
/// <br/>- ensure uniqueness /// <br/>- ensure uniqueness
/// <br/>- enforce max file length /// <br/>- enforce max file length
/// </summary> /// </summary>
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false) public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string? fileExtension, bool returnFirstExisting = false)
{ {
ArgumentValidator.EnsureNotNull(path, nameof(path)); ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension)); ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
fileExtension = GetStandardizedExtension(fileExtension); fileExtension = GetStandardizedExtension(fileExtension);
// remove invalid chars // remove invalid chars
path = GetSafePath(path, replacements); path = GetSafePath(path, replacements);
// ensure uniqueness and check lengths // ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path); var dir = Path.GetDirectoryName(path)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var fileName = Path.GetFileName(path); var fileName = Path.GetFileName(path);
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase); var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
@@ -84,6 +85,7 @@ namespace FileManager
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements) public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
{ {
ArgumentValidator.EnsureNotNull(path, nameof(path)); ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(replacements, nameof(replacements));
var pathNoPrefix = path.PathWithoutPrefix; var pathNoPrefix = path.PathWithoutPrefix;
@@ -159,7 +161,7 @@ namespace FileManager
LongPath source, LongPath source,
LongPath destination, LongPath destination,
ReplacementCharacters replacements, ReplacementCharacters replacements,
string extension = null, string? extension = null,
bool overwrite = false) bool overwrite = false)
{ {
extension ??= Path.GetExtension(source); extension ??= Path.GetExtension(source);
@@ -213,6 +215,9 @@ namespace FileManager
SaferDelete(destination); SaferDelete(destination);
var dir = Path.GetDirectoryName(destination); var dir = Path.GetDirectoryName(destination);
if (dir is null)
throw new DirectoryNotFoundException();
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir }); Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir); Directory.CreateDirectory(dir);
+9 -6
View File
@@ -8,9 +8,10 @@ using System.Linq;
using System.Text; using System.Text;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace FileManager namespace FileManager
{ {
public sealed class LogArchiver : IAsyncDisposable public sealed class LogArchiver : IAsyncDisposable, IDisposable
{ {
public Encoding Encoding { get; set; } public Encoding Encoding { get; set; }
public string FileName { get; } public string FileName { get; }
@@ -39,28 +40,28 @@ namespace FileManager
e.Delete(); e.Delete();
} }
public async Task AddFileAsync(string name, JObject contents, string comment = null) public async Task AddFileAsync(string name, JObject contents, string? comment = null)
{ {
ArgumentValidator.EnsureNotNull(contents, nameof(contents)); ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment); await AddFileAsync(name, Encoding.GetBytes(contents.ToString(Newtonsoft.Json.Formatting.Indented)), comment);
} }
public async Task AddFileAsync(string name, string contents, string comment = null) public async Task AddFileAsync(string name, string contents, string? comment = null)
{ {
ArgumentValidator.EnsureNotNull(contents, nameof(contents)); ArgumentValidator.EnsureNotNull(contents, nameof(contents));
await AddFileAsync(name, Encoding.GetBytes(contents), comment); await AddFileAsync(name, Encoding.GetBytes(contents), comment);
} }
public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string comment = null) public Task AddFileAsync(string name, ReadOnlyMemory<byte> contents, string? comment = null)
{ {
ArgumentValidator.EnsureNotNull(name, nameof(name)); ArgumentValidator.EnsureNotNull(name, nameof(name));
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name); name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
return Task.Run(() => AddfileInternal(name, contents.Span, comment)); return Task.Run(() => AddFileInternal(name, contents.Span, comment));
} }
private readonly object lockObj = new(); private readonly object lockObj = new();
private void AddfileInternal(string name, ReadOnlySpan<byte> contents, string comment) private void AddFileInternal(string name, ReadOnlySpan<byte> contents, string? comment)
{ {
lock (lockObj) lock (lockObj)
{ {
@@ -73,5 +74,7 @@ namespace FileManager
} }
public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose); public async ValueTask DisposeAsync() => await Task.Run(archive.Dispose);
public void Dispose() => archive.Dispose();
} }
} }
+15 -13
View File
@@ -1,9 +1,11 @@
using Newtonsoft.Json; using Newtonsoft.Json;
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Runtime.InteropServices; using System.Runtime.InteropServices;
using System.Text; using System.Text;
#nullable enable
namespace FileManager namespace FileManager
{ {
public class LongPath public class LongPath
@@ -15,9 +17,9 @@ namespace FileManager
public static readonly int MaxPathLength; public static readonly int MaxPathLength;
private const int WIN_MAX_PATH = 260; private const int WIN_MAX_PATH = 260;
private const string WIN_LONG_PATH_PREFIX = @"\\?\"; private const string WIN_LONG_PATH_PREFIX = @"\\?\";
internal static readonly bool IsWindows = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Windows); internal static readonly bool IsWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
internal static readonly bool IsLinux = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.Linux); internal static readonly bool IsLinux = RuntimeInformation.IsOSPlatform(OSPlatform.Linux);
internal static readonly bool IsOSX = System.Runtime.InteropServices.RuntimeInformation.IsOSPlatform(OSPlatform.OSX); internal static readonly bool IsOSX = RuntimeInformation.IsOSPlatform(OSPlatform.OSX);
public string Path { get; } public string Path { get; }
@@ -60,7 +62,8 @@ namespace FileManager
=> IsWindows ? filename.Length => IsWindows ? filename.Length
: Encoding.UTF8.GetByteCount(filename); : Encoding.UTF8.GetByteCount(filename);
public static implicit operator LongPath(string path) [return: NotNullIfNotNull(nameof(path))]
public static implicit operator LongPath?(string? path)
{ {
if (path is null) return null; if (path is null) return null;
@@ -93,7 +96,8 @@ namespace FileManager
} }
} }
public static implicit operator string(LongPath path) => path?.Path; [return: NotNullIfNotNull(nameof(path))]
public static implicit operator string?(LongPath? path) => path?.Path;
[JsonIgnore] [JsonIgnore]
public string ShortPathName public string ShortPathName
@@ -127,8 +131,6 @@ namespace FileManager
//for newly-created entries in ther file system. Existing entries made while //for newly-created entries in ther file system. Existing entries made while
//8dot3 names were disabled will not be reachable by short paths. //8dot3 names were disabled will not be reachable by short paths.
if (Path is null) return null;
StringBuilder shortPathBuffer = new(MaxPathLength); StringBuilder shortPathBuffer = new(MaxPathLength);
GetShortPathName(Path, shortPathBuffer, MaxPathLength); GetShortPathName(Path, shortPathBuffer, MaxPathLength);
return shortPathBuffer.ToString(); return shortPathBuffer.ToString();
@@ -141,7 +143,6 @@ namespace FileManager
get get
{ {
if (!IsWindows) return Path; if (!IsWindows) return Path;
if (Path is null) return null;
StringBuilder longPathBuffer = new(MaxPathLength); StringBuilder longPathBuffer = new(MaxPathLength);
GetLongPathName(Path, longPathBuffer, MaxPathLength); GetLongPathName(Path, longPathBuffer, MaxPathLength);
@@ -156,17 +157,18 @@ namespace FileManager
{ {
if (!IsWindows) return Path; if (!IsWindows) return Path;
return return
Path?.StartsWith(WIN_LONG_PATH_PREFIX) == true ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length) Path.StartsWith(WIN_LONG_PATH_PREFIX)
:Path; ? Path.Remove(0, WIN_LONG_PATH_PREFIX.Length)
: Path;
} }
} }
public override string ToString() => Path; public override string ToString() => Path;
public override int GetHashCode() => Path.GetHashCode(); public override int GetHashCode() => Path.GetHashCode();
public override bool Equals(object obj) => obj is LongPath other && Path == other.Path; public override bool Equals(object? obj) => obj is LongPath other && Path == other.Path;
public static bool operator ==(LongPath path1, LongPath path2) => path1.Equals(path2); public static bool operator ==(LongPath? path1, LongPath? path2) => path1?.Equals(path2) is true;
public static bool operator !=(LongPath path1, LongPath path2) => !path1.Equals(path2); public static bool operator !=(LongPath? path1, LongPath? path2) => path1 is null || path2 is null || !path1.Equals(path2);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)] [DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
@@ -1,7 +1,9 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate; namespace FileManager.NamingTemplate;
internal interface IClosingPropertyTag : IPropertyTag internal interface IClosingPropertyTag : IPropertyTag
@@ -17,7 +19,7 @@ internal interface IClosingPropertyTag : IPropertyTag
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param> /// <param name="propertyTag">The registered <see cref="IPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns> /// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag); bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag);
} }
public class ConditionalTagCollection<TClass> : TagCollection public class ConditionalTagCollection<TClass> : TagCollection
@@ -37,6 +39,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
private class ConditionalTag : TagBase, IClosingPropertyTag private class ConditionalTag : TagBase, IClosingPropertyTag
{ {
public override Regex NameMatcher { get; }
public Regex NameCloseMatcher { get; } public Regex NameCloseMatcher { get; }
public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression) public ConditionalTag(ITemplateTag templateTag, RegexOptions options, Expression conditionExpression)
@@ -46,7 +49,7 @@ public class ConditionalTagCollection<TClass> : TagCollection
NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options); NameCloseMatcher = new Regex($"^<-{templateTag.TagName}>", options);
} }
public bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag propertyTag) public bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? propertyTag)
{ {
var match = NameCloseMatcher.Match(templateString); var match = NameCloseMatcher.Match(templateString);
if (match.Success) if (match.Success)
@@ -1,19 +1,21 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
#nullable enable
namespace FileManager.NamingTemplate; namespace FileManager.NamingTemplate;
public class NamingTemplate public class NamingTemplate
{ {
public string TemplateText { get; private set; } public string TemplateText { get; private set; } = string.Empty;
public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse; public IEnumerable<ITemplateTag> TagsInUse => _tagsInUse;
public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName); public IEnumerable<ITemplateTag> TagsRegistered => TagCollections.SelectMany(t => t).DistinctBy(t => t.TagName);
public IEnumerable<string> Warnings => errors.Concat(warnings); public IEnumerable<string> Warnings => errors.Concat(warnings);
public IEnumerable<string> Errors => errors; public IEnumerable<string> Errors => errors;
private Delegate templateToString; private Delegate? templateToString;
private readonly List<string> warnings = new(); private readonly List<string> warnings = new();
private readonly List<string> errors = new(); private readonly List<string> errors = new();
private readonly IEnumerable<TagCollection> TagCollections; private readonly IEnumerable<TagCollection> TagCollections;
@@ -30,6 +32,9 @@ public class NamingTemplate
/// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param> /// <param name="propertyClasses">Instances of the TClass used in <see cref="PropertyTagCollection{TClass}"/> and <see cref="ConditionalTagCollection{TClass}"/></param>
public TemplatePart Evaluate(params object[] propertyClasses) public TemplatePart Evaluate(params object[] propertyClasses)
{ {
if (templateToString is null)
throw new InvalidOperationException();
// Match propertyClasses to the arguments required by templateToString.DynamicInvoke(). // Match propertyClasses to the arguments required by templateToString.DynamicInvoke().
// First parameter is "this", so ignore it. // First parameter is "this", so ignore it.
var delegateArgTypes = templateToString.Method.GetParameters().Skip(1); var delegateArgTypes = templateToString.Method.GetParameters().Skip(1);
@@ -39,7 +44,7 @@ public class NamingTemplate
if (args.Length != delegateArgTypes.Count()) if (args.Length != delegateArgTypes.Count())
throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}"); throw new ArgumentException($"This instance of {nameof(NamingTemplate)} requires the following arguments: {string.Join(", ", delegateArgTypes.Select(t => t.Name).Distinct())}");
return ((TemplatePart)templateToString.DynamicInvoke(args)).FirstPart; return (templateToString.DynamicInvoke(args) as TemplatePart)!.FirstPart;
} }
/// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary> /// <summary>Parse a template string to a <see cref="NamingTemplate"/></summary>
@@ -69,7 +74,7 @@ public class NamingTemplate
} }
/// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary> /// <summary>Builds an <see cref="Expression"/> tree that will evaluate to a <see cref="TemplatePart"/></summary>
private static Expression GetExpressionTree(BinaryNode node) private static Expression GetExpressionTree(BinaryNode? node)
{ {
if (node is null) return TemplatePart.Blank; if (node is null) return TemplatePart.Blank;
else if (node.IsValue) return node.Expression; else if (node.IsValue) return node.Expression;
@@ -81,10 +86,10 @@ public class NamingTemplate
} }
/// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary> /// <summary>Parse a template string into a <see cref="BinaryNode"/> tree</summary>
private BinaryNode IntermediateParse(string templateString) private BinaryNode IntermediateParse(string? templateString)
{ {
if (templateString is null) if (templateString is null)
throw new NullReferenceException(ERROR_NULL_IS_INVALID); throw new ArgumentException(ERROR_NULL_IS_INVALID);
else if (string.IsNullOrEmpty(templateString)) else if (string.IsNullOrEmpty(templateString))
warnings.Add(WARNING_EMPTY); warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(templateString)) else if (string.IsNullOrWhiteSpace(templateString))
@@ -93,12 +98,12 @@ public class NamingTemplate
TemplateText = templateString; TemplateText = templateString;
BinaryNode topNode = BinaryNode.CreateRoot(); BinaryNode topNode = BinaryNode.CreateRoot();
BinaryNode currentNode = topNode; BinaryNode? currentNode = topNode;
List<char> literalChars = new(); List<char> literalChars = new();
while (templateString.Length > 0) while (templateString.Length > 0)
{ {
if (StartsWith(templateString, out string exactPropertyName, out var propertyTag, out var valueExpression)) if (StartsWith(templateString, out var exactPropertyName, out var propertyTag, out var valueExpression))
{ {
checkAndAddLiterals(); checkAndAddLiterals();
@@ -116,7 +121,7 @@ public class NamingTemplate
{ {
checkAndAddLiterals(); checkAndAddLiterals();
BinaryNode lastParenth = currentNode; BinaryNode? lastParenth = currentNode;
while (lastParenth?.IsConditional is false) while (lastParenth?.IsConditional is false)
lastParenth = lastParenth.Parent; lastParenth = lastParenth.Parent;
@@ -168,7 +173,7 @@ public class NamingTemplate
} }
} }
private bool StartsWith(string template, out string exactName, out IPropertyTag propertyTag, out Expression valueExpression) private bool StartsWith(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? valueExpression)
{ {
foreach (var pc in TagCollections) foreach (var pc in TagCollections)
{ {
@@ -182,7 +187,7 @@ public class NamingTemplate
return false; return false;
} }
private bool StartsWithClosing(string template, out string exactName, out IClosingPropertyTag closingPropertyTag) private bool StartsWithClosing(string template, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
{ {
foreach (var pc in TagCollections) foreach (var pc in TagCollections)
{ {
@@ -198,36 +203,36 @@ public class NamingTemplate
private class BinaryNode private class BinaryNode
{ {
public string Name { get; } public string Name { get; }
public BinaryNode Parent { get; private set; } public BinaryNode? Parent { get; private set; }
public BinaryNode RightChild { get; private set; } public BinaryNode? RightChild { get; private set; }
public BinaryNode LeftChild { get; private set; } public BinaryNode? LeftChild { get; private set; }
public Expression Expression { get; private init; } public Expression Expression { get; }
public bool IsConditional { get; private init; } = false; public bool IsConditional { get; private init; } = false;
public bool IsValue { get; private init; } = false; public bool IsValue { get; private init; } = false;
public static BinaryNode CreateRoot() => new("Root"); public static BinaryNode CreateRoot() => new("Root", Expression.Empty());
public static BinaryNode CreateValue(string literal) => new("Literal") public static BinaryNode CreateValue(string literal)
{ => new("Literal", TemplatePart.CreateLiteral(literal))
IsValue = true, {
Expression = TemplatePart.CreateLiteral(literal) IsValue = true
}; };
public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property) => new(templateTag.TagName) public static BinaryNode CreateValue(ITemplateTag templateTag, Expression property)
{ => new(templateTag.TagName, TemplatePart.CreateProperty(templateTag, property))
IsValue = true, {
Expression = TemplatePart.CreateProperty(templateTag, property) IsValue = true
}; };
public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property) => new(templateTag.TagName) public static BinaryNode CreateConditional(ITemplateTag templateTag, Expression property)
{ => new(templateTag.TagName, property)
IsConditional = true, {
Expression = property IsConditional = true
}; };
private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right) private static BinaryNode CreateConcatenation(BinaryNode left, BinaryNode right)
{ {
var newNode = new BinaryNode("Concatenation") var newNode = new BinaryNode("Concatenation", Expression.Empty())
{ {
LeftChild = left, LeftChild = left,
RightChild = right RightChild = right
@@ -237,7 +242,12 @@ public class NamingTemplate
return newNode; return newNode;
} }
private BinaryNode(string name) => Name = name; private BinaryNode(string name, Expression expression)
{
Name = name;
Expression = expression;
}
public override string ToString() => Name; public override string ToString() => Name;
public BinaryNode AddNewNode(BinaryNode newNode) public BinaryNode AddNewNode(BinaryNode newNode)
@@ -5,6 +5,7 @@ using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate; namespace FileManager.NamingTemplate;
public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString); public delegate string PropertyFormatter<T>(ITemplateTag templateTag, T value, string formatString);
@@ -37,7 +38,7 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property /// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default /// and a formatting string and returnes the value the formatted string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param> /// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty> formatter = null) public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty?> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
where TProperty : struct where TProperty : struct
=> RegisterWithFormatter(templateTag, propertyGetter, formatter); => RegisterWithFormatter(templateTag, propertyGetter, formatter);
@@ -59,7 +60,7 @@ public class PropertyTagCollection<TClass> : TagCollection
/// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property /// <param name="formatter">Optional formatting function that accepts the <typeparamref name="TProperty"/> property
/// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default /// and a formatting string and returnes the value formatted to string. If <see cref="null"/>, use the default
/// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param> /// <typeparamref name="TProperty"/> formatter if present, or <see cref="object.ToString"/></param>
public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty> formatter = null) public void Add<TProperty>(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TProperty>? formatter = null)
=> RegisterWithFormatter(templateTag, propertyGetter, formatter); => RegisterWithFormatter(templateTag, propertyGetter, formatter);
/// <summary> /// <summary>
@@ -72,14 +73,15 @@ public class PropertyTagCollection<TClass> : TagCollection
=> RegisterWithToString(templateTag, propertyGetter, toString); => RegisterWithToString(templateTag, propertyGetter, toString);
private void RegisterWithFormatter<TProperty, TPropertyValue> private void RegisterWithFormatter<TProperty, TPropertyValue>
(ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue> formatter) (ITemplateTag templateTag, Func<TClass, TProperty> propertyGetter, PropertyFormatter<TPropertyValue>? formatter)
{ {
ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag)); ArgumentValidator.EnsureNotNull(templateTag, nameof(templateTag));
ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter)); ArgumentValidator.EnsureNotNull(propertyGetter, nameof(propertyGetter));
var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter); var expr = Expression.Call(Expression.Constant(propertyGetter.Target), propertyGetter.Method, Parameter);
formatter ??= GetDefaultFormatter<TPropertyValue>();
if ((formatter ??= GetDefaultFormatter<TPropertyValue>()) is null) if (formatter is null)
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc)); AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, ToStringFunc));
else else
AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter)); AddPropertyTag(new PropertyTag<TPropertyValue>(templateTag, Options, expr, formatter));
@@ -97,7 +99,7 @@ public class PropertyTagCollection<TClass> : TagCollection
private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? ""; private static string ToStringFunc<T>(T propertyValue) => propertyValue?.ToString() ?? "";
private PropertyFormatter<T> GetDefaultFormatter<T>() private PropertyFormatter<T>? GetDefaultFormatter<T>()
{ {
try try
{ {
@@ -109,6 +111,7 @@ public class PropertyTagCollection<TClass> : TagCollection
private class PropertyTag<TPropertyValue> : TagBase private class PropertyTag<TPropertyValue> : TagBase
{ {
public override Regex NameMatcher { get; }
private Func<Expression, string, Expression> CreateToStringExpression { get; } private Func<Expression, string, Expression> CreateToStringExpression { get; }
public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter) public PropertyTag(ITemplateTag templateTag, RegexOptions options, Expression propertyGetter, PropertyFormatter<TPropertyValue> formatter)
+5 -3
View File
@@ -1,7 +1,9 @@
using System; using System;
using System.Diagnostics.CodeAnalysis;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate; namespace FileManager.NamingTemplate;
internal interface IPropertyTag internal interface IPropertyTag
@@ -22,13 +24,13 @@ internal interface IPropertyTag
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param> /// <param name="propertyValue">The <see cref="Expression"/> that returns the property's value</param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns> /// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
bool StartsWith(string templateString, out string exactName, out Expression propertyValue); bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue);
} }
internal abstract class TagBase : IPropertyTag internal abstract class TagBase : IPropertyTag
{ {
public ITemplateTag TemplateTag { get; } public ITemplateTag TemplateTag { get; }
public Regex NameMatcher { get; protected init; } public abstract Regex NameMatcher { get; }
public Type ReturnType => ValueExpression.Type; public Type ReturnType => ValueExpression.Type;
protected Expression ValueExpression { get; } protected Expression ValueExpression { get; }
@@ -43,7 +45,7 @@ internal abstract class TagBase : IPropertyTag
/// <param name="formatter">The optional format string in the match inside the square brackets</param> /// <param name="formatter">The optional format string in the match inside the square brackets</param>
protected abstract Expression GetTagExpression(string exactName, string formatter); protected abstract Expression GetTagExpression(string exactName, string formatter);
public bool StartsWith(string templateString, out string exactName, out Expression propertyValue) public bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out Expression? propertyValue)
{ {
var match = NameMatcher.Match(templateString); var match = NameMatcher.Match(templateString);
if (match.Success) if (match.Success)
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections; using System.Collections;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.Linq; using System.Linq;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Text.RegularExpressions; using System.Text.RegularExpressions;
#nullable enable
namespace FileManager.NamingTemplate; namespace FileManager.NamingTemplate;
/// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary> /// <summary>A collection of <see cref="IPropertyTag"/>s registered to a single <see cref="Type"/>.</summary>
@@ -32,7 +34,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param> /// <param name="propertyValue">The <see cref="Expression"/> that returns the <paramref name="propertyTag"/>'s value</param>
/// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns> /// <returns>True if the <paramref name="templateString"/> starts with a tag registered in this class.</returns>
internal bool StartsWith(string templateString, out string exactName, out IPropertyTag propertyTag, out Expression propertyValue) internal bool StartsWith(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IPropertyTag? propertyTag, [NotNullWhen(true)] out Expression? propertyValue)
{ {
foreach (var p in PropertyTags) foreach (var p in PropertyTags)
{ {
@@ -57,7 +59,7 @@ public abstract class TagCollection : IEnumerable<ITemplateTag>
/// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param> /// <param name="exactName">The <paramref name="templateString"/> substring that was matched.</param>
/// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param> /// <param name="closingPropertyTag">The registered <see cref="IClosingPropertyTag"/></param>
/// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns> /// <returns>True if the <paramref name="templateString"/> starts with this tag.</returns>
internal bool StartsWithClosing(string templateString, out string exactName, out IClosingPropertyTag closingPropertyTag) internal bool StartsWithClosing(string templateString, [NotNullWhen(true)] out string? exactName, [NotNullWhen(true)] out IClosingPropertyTag? closingPropertyTag)
{ {
foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>()) foreach (var cg in PropertyTags.OfType<IClosingPropertyTag>())
{ {
@@ -4,6 +4,7 @@ using System.Collections.Generic;
using System.Linq.Expressions; using System.Linq.Expressions;
using System.Reflection; using System.Reflection;
#nullable enable
namespace FileManager.NamingTemplate; namespace FileManager.NamingTemplate;
/// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary> /// <summary>Represents one part of an evaluated <see cref="NamingTemplate"/>.</summary>
@@ -15,13 +16,13 @@ public class TemplatePart : IEnumerable<TemplatePart>
/// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is /// <summary> The <see cref="IPropertyTag"/>'s <see cref="ITemplateTag"/> if <see cref="TemplatePart"/> is
/// a registered property, otherwise <see cref="null"/> for string literals. </summary> /// a registered property, otherwise <see cref="null"/> for string literals. </summary>
public ITemplateTag TemplateTag { get; } public ITemplateTag? TemplateTag { get; }
/// <summary>The evaluated string.</summary> /// <summary>The evaluated string.</summary>
public string Value { get; } public string Value { get; }
private TemplatePart previous; private TemplatePart? previous;
private TemplatePart next; private TemplatePart? next;
private TemplatePart(string name, string value) private TemplatePart(string name, string value)
{ {
TagName = name; TagName = name;
@@ -53,14 +54,33 @@ public class TemplatePart : IEnumerable<TemplatePart>
private static Expression CreateExpression(string name, Expression value) private static Expression CreateExpression(string name, Expression value)
=> Expression.New(constructorInfo, Expression.Constant(name), value); => Expression.New(constructorInfo, Expression.Constant(name), value);
private static readonly ConstructorInfo constructorInfo private static readonly ConstructorInfo constructorInfo;
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(string), typeof(string) }); private static readonly ConstructorInfo tagTemplateConstructorInfo;
private static readonly MethodInfo addMethodInfo;
static TemplatePart()
{
var type = typeof(TemplatePart);
private static readonly ConstructorInfo tagTemplateConstructorInfo if (type.GetConstructor(
= typeof(TemplatePart).GetConstructor(BindingFlags.NonPublic | BindingFlags.Instance, new Type[] { typeof(ITemplateTag), typeof(string) }); BindingFlags.NonPublic | BindingFlags.Instance,
new Type[] { typeof(string), typeof(string) }) is not ConstructorInfo c1)
throw new MissingMethodException(nameof(TemplatePart));
private static readonly MethodInfo addMethodInfo if (type.GetConstructor(
= typeof(TemplatePart).GetMethod(nameof(Concatenate), BindingFlags.NonPublic | BindingFlags.Static, new Type[] { typeof(TemplatePart), typeof(TemplatePart) }); BindingFlags.NonPublic | BindingFlags.Instance,
new Type[] { typeof(ITemplateTag), typeof(string) }) is not ConstructorInfo c2)
throw new MissingMethodException(nameof(TemplatePart));
if (type.GetMethod(
nameof(Concatenate),
BindingFlags.NonPublic | BindingFlags.Static,
new Type[] { typeof(TemplatePart), typeof(TemplatePart) }) is not MethodInfo m1)
throw new MissingMethodException(nameof(Concatenate));
constructorInfo = c1;
tagTemplateConstructorInfo = c2;
addMethodInfo = m1;
}
public IEnumerator<TemplatePart> GetEnumerator() public IEnumerator<TemplatePart> GetEnumerator()
{ {
+21 -18
View File
@@ -1,10 +1,12 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Diagnostics.CodeAnalysis;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
#nullable enable
namespace FileManager namespace FileManager
{ {
public class PersistentDictionary public class PersistentDictionary
@@ -13,19 +15,19 @@ namespace FileManager
public bool IsReadOnly { get; } public bool IsReadOnly { get; }
// optimize for strings. expectation is most settings will be strings and a rare exception will be something else // optimize for strings. expectation is most settings will be strings and a rare exception will be something else
private Dictionary<string, string> stringCache { get; } = new Dictionary<string, string>(); private Dictionary<string, string?> stringCache { get; } = new();
private Dictionary<string, object> objectCache { get; } = new Dictionary<string, object>(); private Dictionary<string, object?> objectCache { get; } = new();
public PersistentDictionary(string filepath, bool isReadOnly = false) public PersistentDictionary(string filepath, bool isReadOnly = false)
{ {
Filepath = filepath; Filepath = filepath;
IsReadOnly = isReadOnly; IsReadOnly = isReadOnly;
if (File.Exists(Filepath)) if (File.Exists(Filepath) || Path.GetDirectoryName(Filepath) is not string dirName)
return; return;
// will create any missing directories, incl subdirectories. if all already exist: no action // will create any missing directories, incl subdirectories. if all already exist: no action
Directory.CreateDirectory(Path.GetDirectoryName(filepath)); Directory.CreateDirectory(dirName);
if (IsReadOnly) if (IsReadOnly)
return; return;
@@ -33,13 +35,14 @@ namespace FileManager
createNewFile(); createNewFile();
} }
public string GetString(string propertyName, string defaultValue = null) [return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string propertyName, string? defaultValue = null)
{ {
if (!stringCache.ContainsKey(propertyName)) if (!stringCache.ContainsKey(propertyName))
{ {
var jObject = readFile(); var jObject = readFile();
if (jObject.ContainsKey(propertyName)) if (jObject.ContainsKey(propertyName))
stringCache[propertyName] = jObject[propertyName].Value<string>(); stringCache[propertyName] = jObject[propertyName]?.Value<string>();
else else
stringCache[propertyName] = defaultValue; stringCache[propertyName] = defaultValue;
} }
@@ -47,7 +50,8 @@ namespace FileManager
return stringCache[propertyName]; return stringCache[propertyName];
} }
public T GetNonString<T>(string propertyName, T defaultValue = default) [return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(string propertyName, T? defaultValue = default)
{ {
var obj = GetObject(propertyName); var obj = GetObject(propertyName);
@@ -72,21 +76,21 @@ namespace FileManager
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}"); throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
} }
public object GetObject(string propertyName) public object? GetObject(string propertyName)
{ {
if (!objectCache.ContainsKey(propertyName)) if (!objectCache.ContainsKey(propertyName))
{ {
var jObject = readFile(); var jObject = readFile();
if (!jObject.ContainsKey(propertyName)) if (!jObject.ContainsKey(propertyName))
return null; return null;
objectCache[propertyName] = jObject[propertyName].Value<object>(); objectCache[propertyName] = jObject[propertyName]?.Value<object>();
} }
return objectCache[propertyName]; return objectCache[propertyName];
} }
public string GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}"); public string? GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string GetStringFromJsonPath(string jsonPath) public string? GetStringFromJsonPath(string jsonPath)
{ {
if (!stringCache.ContainsKey(jsonPath)) if (!stringCache.ContainsKey(jsonPath))
{ {
@@ -96,7 +100,7 @@ namespace FileManager
var token = jObject.SelectToken(jsonPath); var token = jObject.SelectToken(jsonPath);
if (token is null) if (token is null)
return null; return null;
stringCache[jsonPath] = (string)token; stringCache[jsonPath] = token.Value<string>();
} }
catch catch
{ {
@@ -110,7 +114,7 @@ namespace FileManager
public bool Exists(string propertyName) => readFile().ContainsKey(propertyName); public bool Exists(string propertyName) => readFile().ContainsKey(propertyName);
private object locker { get; } = new object(); private object locker { get; } = new object();
public void SetString(string propertyName, string newValue) public void SetString(string propertyName, string? newValue)
{ {
// only do this check in string cache, NOT object cache // only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue) if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
@@ -122,7 +126,7 @@ namespace FileManager
writeFile(propertyName, newValue); writeFile(propertyName, newValue);
} }
public void SetNonString(string propertyName, object newValue) public void SetNonString(string propertyName, object? newValue)
{ {
// set cache // set cache
objectCache[propertyName] = newValue; objectCache[propertyName] = newValue;
@@ -160,7 +164,7 @@ namespace FileManager
return success; return success;
} }
private void writeFile(string propertyName, JToken newValue) private void writeFile(string propertyName, JToken? newValue)
{ {
if (IsReadOnly) if (IsReadOnly)
return; return;
@@ -190,7 +194,7 @@ namespace FileManager
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary> /// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
/// <returns>Value was changed</returns> /// <returns>Value was changed</returns>
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false) public bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false)
{ {
if (IsReadOnly) if (IsReadOnly)
return false; return false;
@@ -242,7 +246,7 @@ namespace FileManager
return true; return true;
} }
private static string formatValueForLog(string value) private static string formatValueForLog(string? value)
=> value is null ? "[null]" => value is null ? "[null]"
: string.IsNullOrEmpty(value) ? "[empty]" : string.IsNullOrEmpty(value) ? "[empty]"
: string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]" : string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]"
@@ -283,7 +287,6 @@ namespace FileManager
private void createNewFile() private void createNewFile()
{ {
File.WriteAllText(Filepath, "{}"); File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
} }
} }
} }
+11 -7
View File
@@ -5,6 +5,7 @@ using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
#nullable enable
namespace FileManager namespace FileManager
{ {
public record Replacement public record Replacement
@@ -59,7 +60,7 @@ namespace FileManager
[JsonConverter(typeof(ReplacementCharactersConverter))] [JsonConverter(typeof(ReplacementCharactersConverter))]
public class ReplacementCharacters public class ReplacementCharacters
{ {
public override bool Equals(object obj) public override bool Equals(object? obj)
{ {
if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count) if (obj is ReplacementCharacters second && Replacements.Count == second.Replacements.Count)
{ {
@@ -173,7 +174,7 @@ namespace FileManager
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray(); }).ToArray();
public IReadOnlyList<Replacement> Replacements { get; init; } required public IReadOnlyList<Replacement> Replacements { get; init; }
private string DefaultReplacement => Replacements[0].ReplacementString; private string DefaultReplacement => Replacements[0].ReplacementString;
private Replacement ForwardSlash => Replacements[1]; private Replacement ForwardSlash => Replacements[1];
private Replacement BackSlash => Replacements[2]; private Replacement BackSlash => Replacements[2];
@@ -298,12 +299,14 @@ namespace FileManager
public override bool CanConvert(Type objectType) public override bool CanConvert(Type objectType)
=> objectType == typeof(ReplacementCharacters); => objectType == typeof(ReplacementCharacters);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer) public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{ {
var jObj = JObject.Load(reader); var jObj = JObject.Load(reader);
var replaceArr = jObj[nameof(Replacement)]; var replaceArr = jObj[nameof(Replacement)];
IReadOnlyList<Replacement> dict = replaceArr var dict
.ToObject<Replacement[]>().ToList(); = replaceArr?.ToObject<Replacement[]>()?.ToList()
?? ReplacementCharacters.Default.Replacements;
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid. //Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
//If not, reset to default. //If not, reset to default.
@@ -325,9 +328,10 @@ namespace FileManager
return new ReplacementCharacters { Replacements = dict }; return new ReplacementCharacters { Replacements = dict };
} }
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer) public override void WriteJson(JsonWriter writer, object? value, JsonSerializer serializer)
{ {
ReplacementCharacters replacements = (ReplacementCharacters)value; if (value is not ReplacementCharacters replacements)
return;
var propertyNames = replacements.Replacements var propertyNames = replacements.Replacements
.Select(JObject.FromObject).ToList(); .Select(JObject.FromObject).ToList();
+14 -10
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 --> <!--Avalonia doesen't support TrimMode=link currently,but we are working on that https://github.com/AvaloniaUI/Avalonia/issues/6892 -->
<TrimMode>copyused</TrimMode> <TrimMode>copyused</TrimMode>
@@ -21,9 +21,13 @@
<ApplicationIcon>hangover.ico</ApplicationIcon> <ApplicationIcon>hangover.ico</ApplicationIcon>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <ItemGroup>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <RuntimeHostConfigurationOption Include="System.Net.DisableIPv6" Value="true" />
</PropertyGroup> </ItemGroup>
<PropertyGroup>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\bin\Avalonia\Debug</OutputPath> <OutputPath>..\bin\Avalonia\Debug</OutputPath>
@@ -67,13 +71,13 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.0.0" /> <PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.--> <!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.0" /> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.0.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0" /> <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" /> <ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir> <PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier> <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir> <PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier> <RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir> <PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<ImplicitUsings>enable</ImplicitUsings> <ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup> </PropertyGroup>
@@ -2,7 +2,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<EnableWindowsTargeting>true</EnableWindowsTargeting> <EnableWindowsTargeting>true</EnableWindowsTargeting>
<AssemblyName>Hangover</AssemblyName> <AssemblyName>Hangover</AssemblyName>
<UseWindowsForms>true</UseWindowsForms> <UseWindowsForms>true</UseWindowsForms>
@@ -15,6 +15,10 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Net.DisableIPv6" Value="true" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\classic</PublishDir> <PublishDir>..\bin\Publish\classic</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
+1
View File
@@ -6,6 +6,7 @@ MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
REFERENCE.txt = REFERENCE.txt REFERENCE.txt = REFERENCE.txt
Upgrading dotnet version.txt = Upgrading dotnet version.txt
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt _ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
_AvaloniaUI Primer.txt = _AvaloniaUI Primer.txt _AvaloniaUI Primer.txt = _AvaloniaUI Primer.txt
_DB_NOTES.txt = _DB_NOTES.txt _DB_NOTES.txt = _DB_NOTES.txt
+3 -3
View File
@@ -44,9 +44,9 @@
<SolidColorBrush x:Key="CancelRed" Color="#802727" /> <SolidColorBrush x:Key="CancelRed" Color="#802727" />
<SolidColorBrush x:Key="IconFill" Color="#DCE0DF" /> <SolidColorBrush x:Key="IconFill" Color="#DCE0DF" />
<SolidColorBrush x:Key="StoplightRed" Color="#5F0707" /> <SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" />
<SolidColorBrush x:Key="StoplightYellow" Color="#5F5B1A" /> <SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" />
<SolidColorBrush x:Key="StoplightGreen" Color="#174E15" /> <SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" /> <SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
@@ -107,7 +107,7 @@ namespace LibationAvalonia.Controls
if (known is Configuration.KnownDirectories.None) if (known is Configuration.KnownDirectories.None)
{ {
directoryState.CustomDir = noSubDir; directoryState.CustomDir = directory;
directoryState.CustomChecked = true; directoryState.CustomChecked = true;
} }
else else
@@ -97,7 +97,8 @@
<controls:GroupBox <controls:GroupBox
Grid.Row="2" Grid.Row="2"
Margin="5"> Margin="5"
Label="Display Settings">
<Grid <Grid
RowDefinitions="Auto,Auto" RowDefinitions="Auto,Auto"
ColumnDefinitions="Auto,Auto,*"> ColumnDefinitions="Auto,Auto,*">
@@ -115,7 +115,7 @@ Author(s): {Book.AuthorNames()}
Narrator(s): {Book.NarratorNames()} Narrator(s): {Book.NarratorNames()}
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")} Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
Audio Bitrate: {Book.AudioFormat} Audio Bitrate: {Book.AudioFormat}
Category: {string.Join(" > ", Book.CategoriesNames())} Category: {string.Join(", ", Book.LowestCategoryNames())}
Purchase Date: {libraryBook.DateAdded:d} Purchase Date: {libraryBook.DateAdded:d}
Language: {Book.Language} Language: {Book.Language}
Audible ID: {Book.AudibleProductId} Audible ID: {Book.AudibleProductId}
@@ -27,6 +27,6 @@
Margin="5" Margin="5"
Padding="30,3,30,3" Padding="30,3,30,3"
Content="Save" Content="Save"
Command="{Binding SaveButtonAsync}" /> Click="Save_Click" />
</Grid> </Grid>
</Window> </Window>
@@ -1,11 +1,9 @@
using Avalonia.Controls;
using LibationFileManager; using LibationFileManager;
using System.Collections.Generic; using System.Collections.Generic;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs namespace LibationAvalonia.Dialogs
{ {
public partial class LibationFilesDialog : Window public partial class LibationFilesDialog : DialogWindow
{ {
private class DirSelectOptions private class DirSelectOptions
{ {
@@ -18,28 +16,26 @@ namespace LibationAvalonia.Dialogs
public string Directory { get; set; } = Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile); public string Directory { get; set; } = Configuration.GetKnownDirectoryPath(Configuration.KnownDirectories.UserProfile);
} }
private DirSelectOptions dirSelectOptions;
private readonly DirSelectOptions dirSelectOptions;
public string SelectedDirectory => dirSelectOptions.Directory; public string SelectedDirectory => dirSelectOptions.Directory;
public DialogResult DialogResult { get; private set; }
public LibationFilesDialog() public LibationFilesDialog() : base(saveAndRestorePosition: false)
{ {
InitializeComponent(); InitializeComponent();
DataContext = dirSelectOptions = new(); DataContext = dirSelectOptions = new();
} }
public async Task SaveButtonAsync() public async void Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{ {
var libationDir = dirSelectOptions.Directory; if (!System.IO.Directory.Exists(SelectedDirectory))
if (!System.IO.Directory.Exists(libationDir))
{ {
await MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + libationDir, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error, saveAndRestorePosition: false); await MessageBox.Show("Not saving change to Libation Files location. This folder does not exist:\r\n" + SelectedDirectory, "Folder does not exist", MessageBoxButtons.OK, MessageBoxIcon.Error, saveAndRestorePosition: false);
return; return;
} }
DialogResult = DialogResult.OK; await SaveAndCloseAsync();
Close(DialogResult);
} }
} }
} }
@@ -2,8 +2,8 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008" xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="750" mc:Ignorable="d" d:DesignWidth="900" d:DesignHeight="200"
MinWidth="900" MinHeight="750" MinWidth="900" MinHeight="200"
Width="900" Height="750" Width="900" Height="750"
x:Class="LibationAvalonia.Dialogs.SettingsDialog" x:Class="LibationAvalonia.Dialogs.SettingsDialog"
xmlns:controls="clr-namespace:LibationAvalonia.Controls" xmlns:controls="clr-namespace:LibationAvalonia.Controls"
@@ -46,39 +46,41 @@
</TabControl.Styles> </TabControl.Styles>
<TabItem> <TabItem>
<TabItem.Header> <TabItem.Header>
<TextBlock Text="Important Settings"/> <TextBlock Text="Important Settings"/>
</TabItem.Header> </TabItem.Header>
<settings:Important DataContext="{CompiledBinding ImportantSettings}" /> <ScrollViewer VerticalScrollBarVisibility="Auto">
<settings:Important DataContext="{CompiledBinding ImportantSettings}" />
</ScrollViewer>
</TabItem> </TabItem>
<TabItem> <TabItem>
<TabItem.Header> <TabItem.Header>
<TextBlock Text="Import Library"/> <TextBlock Text="Import Library"/>
</TabItem.Header> </TabItem.Header>
<settings:Import DataContext="{CompiledBinding ImportSettings}" /> <ScrollViewer VerticalScrollBarVisibility="Auto">
<settings:Import DataContext="{CompiledBinding ImportSettings}" />
</ScrollViewer>
</TabItem> </TabItem>
<TabItem> <TabItem>
<TabItem.Header> <TabItem.Header>
<TextBlock Text="Download/Decrypt"/> <TextBlock Text="Download/Decrypt"/>
</TabItem.Header> </TabItem.Header>
<settings:DownloadDecrypt DataContext="{CompiledBinding DownloadDecryptSettings}" /> <ScrollViewer VerticalScrollBarVisibility="Auto">
<settings:DownloadDecrypt DataContext="{CompiledBinding DownloadDecryptSettings}" />
</ScrollViewer>
</TabItem> </TabItem>
<TabItem> <TabItem>
<TabItem.Header> <TabItem.Header>
<TextBlock Text="Audio File Settings"/> <TextBlock Text="Audio File Settings"/>
</TabItem.Header> </TabItem.Header>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<settings:Audio DataContext="{CompiledBinding AudioSettings}" /> <settings:Audio DataContext="{CompiledBinding AudioSettings}" />
</ScrollViewer>
</TabItem> </TabItem>
</TabControl> </TabControl>
</Grid> </Grid>
@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>WinExe</OutputType> <OutputType>WinExe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<BuiltInComInteropSupport>true</BuiltInComInteropSupport> <BuiltInComInteropSupport>true</BuiltInComInteropSupport>
<ApplicationIcon>Assets/libation.ico</ApplicationIcon> <ApplicationIcon>Assets/libation.ico</ApplicationIcon>
@@ -14,6 +14,10 @@
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath> <AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<StartupObject /> <StartupObject />
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Net.DisableIPv6" Value="true" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
@@ -69,14 +73,14 @@
<UpToDateCheckInput Remove="Controls\GroupBox.axaml" /> <UpToDateCheckInput Remove="Controls\GroupBox.axaml" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia.Diagnostics" Version="11.0.0" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" /> <PackageReference Include="Avalonia.Diagnostics" Version="11.0.5" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia" Version="11.0.0" /> <PackageReference Include="Avalonia" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.0" /> <PackageReference Include="Avalonia.Controls.DataGrid" Version="11.0.5" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.0" /> <PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.0.5" />
<PackageReference Include="Avalonia.Desktop" Version="11.0.0" /> <PackageReference Include="Avalonia.Desktop" Version="11.0.5" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.0.0" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.0.5" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.0" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.0.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+5 -2
View File
@@ -177,9 +177,12 @@ Libation.
tbx.MinWidth = vm.TextBlockMinWidth; tbx.MinWidth = vm.TextBlockMinWidth;
tbx.Text = message; tbx.Text = message;
var thisScreen = owner.Screens.ScreenFromVisual(owner);
var thisScreen = owner.Screens?.ScreenFromVisual(owner);
var maxSize = new Size(0.20 * thisScreen.Bounds.Width, 0.9 * thisScreen.Bounds.Height - 55); var maxSize
= thisScreen is null ? owner.ClientSize
: new Size(0.20 * thisScreen.Bounds.Width, 0.9 * thisScreen.Bounds.Height - 55);
var desiredMax = new Size(maxSize.Width, maxSize.Height); var desiredMax = new Size(maxSize.Width, maxSize.Height);
+7 -2
View File
@@ -36,6 +36,7 @@ namespace LibationAvalonia
} }
AppDomain.CurrentDomain.UnhandledException += (o, e) => LogError(e.ExceptionObject); AppDomain.CurrentDomain.UnhandledException += (o, e) => LogError(e.ExceptionObject);
bool loggingEnabled = false;
//***********************************************// //***********************************************//
// // // //
// do not use Configuration before this line // // do not use Configuration before this line //
@@ -55,6 +56,7 @@ namespace LibationAvalonia
// most migrations go in here // most migrations go in here
LibationScaffolding.RunPostConfigMigrations(config); LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config); LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config);
loggingEnabled = true;
//Start loading the library before loading the main form //Start loading the library before loading the main form
App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); App.LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true));
@@ -64,9 +66,12 @@ namespace LibationAvalonia
classicLifetimeTask.Result.Start(null); classicLifetimeTask.Result.Start(null);
} }
catch(Exception e) catch (Exception ex)
{ {
LogError(e); if (loggingEnabled)
Serilog.Log.Logger.Error(ex, "CRASH");
else
LogError(ex);
} }
} }
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir> <PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier> <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir> <PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier> <RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir> <PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -265,7 +265,7 @@ namespace LibationAvalonia.ViewModels
private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e) private byte[] AudioDecodable_RequestCoverArt(object sender, EventArgs e)
{ {
var quality var quality
= Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High = Configuration.Instance.FileDownloadQuality == Configuration.DownloadQuality.High && LibraryBook.Book.PictureLarge is not null
? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native) ? new PictureDefinition(LibraryBook.Book.PictureLarge, PictureSize.Native)
: new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500); : new PictureDefinition(LibraryBook.Book.PictureId, PictureSize._500x500);
@@ -1,15 +1,18 @@
using ApplicationServices; using ApplicationServices;
using AudibleUtilities; using AudibleUtilities;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Threading; using Avalonia.Threading;
using DataLayer; using DataLayer;
using LibationAvalonia.Dialogs.Login; using LibationAvalonia.Dialogs.Login;
using LibationFileManager;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using ReactiveUI; using ReactiveUI;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.ComponentModel; using System.ComponentModel;
using System.Linq; using System.Linq;
using System.Runtime.CompilerServices;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels namespace LibationAvalonia.ViewModels
@@ -27,8 +30,8 @@ namespace LibationAvalonia.ViewModels
public string FilterString { get; private set; } public string FilterString { get; private set; }
public DataGridCollectionView GridEntries { get; private set; } public DataGridCollectionView GridEntries { get; private set; }
private bool _removeColumnVisivle; private bool _removeColumnVisible;
public bool RemoveColumnVisivle { get => _removeColumnVisivle; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisivle, value); } public bool RemoveColumnVisible { get => _removeColumnVisible; private set => this.RaiseAndSetIfChanged(ref _removeColumnVisible, value); }
public List<LibraryBook> GetVisibleBookEntries() public List<LibraryBook> GetVisibleBookEntries()
=> FilteredInGridEntries? => FilteredInGridEntries?
@@ -99,10 +102,10 @@ namespace LibationAvalonia.ViewModels
var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks); var seriesEntries = await SeriesEntry<AvaloniaEntryStatus>.GetAllSeriesEntriesAsync(dbBooks);
SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
//Create the filtered-in list before adding entries to avoid a refresh //Create the filtered-in list before adding entries to avoid a refresh
FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString); FilteredInGridEntries = geList.Union(seriesEntries.SelectMany(s => s.Children)).FilterEntries(FilterString);
//Adding entries to the Source list will invoke CollectionFilter
SOURCE.AddRange(geList.Concat(seriesEntries).OrderDescending(new RowComparer(null)));
//Add all children beneath their parent //Add all children beneath their parent
foreach (var series in seriesEntries) foreach (var series in seriesEntries)
@@ -321,7 +324,7 @@ namespace LibationAvalonia.ViewModels
{ {
foreach (var item in SOURCE) foreach (var item in SOURCE)
item.PropertyChanged -= GridEntry_PropertyChanged; item.PropertyChanged -= GridEntry_PropertyChanged;
RemoveColumnVisivle = false; RemoveColumnVisible = false;
} }
public async Task RemoveCheckedBooksAsync() public async Task RemoveCheckedBooksAsync()
@@ -376,7 +379,7 @@ namespace LibationAvalonia.ViewModels
item.PropertyChanged += GridEntry_PropertyChanged; item.PropertyChanged += GridEntry_PropertyChanged;
} }
RemoveColumnVisivle = true; RemoveColumnVisible = true;
RemovableCountChanged?.Invoke(this, 0); RemovableCountChanged?.Invoke(this, 0);
try try
@@ -421,5 +424,44 @@ namespace LibationAvalonia.ViewModels
} }
#endregion #endregion
#region Column Widths
public DataGridLength TitleWidth { get => getColumnWidth("Title", 200); set => setColumnWidth("Title", value); }
public DataGridLength AuthorsWidth { get => getColumnWidth("Authors", 100); set => setColumnWidth("Authors", value); }
public DataGridLength NarratorsWidth { get => getColumnWidth("Narrators", 100); set => setColumnWidth("Narrators", value); }
public DataGridLength LengthWidth { get => getColumnWidth("Length", 80); set => setColumnWidth("Length", value); }
public DataGridLength SeriesWidth { get => getColumnWidth("Series", 100); set => setColumnWidth("Series", value); }
public DataGridLength SeriesOrderWidth { get => getColumnWidth("SeriesOrder", 60); set => setColumnWidth("SeriesOrder", value); }
public DataGridLength DescriptionWidth { get => getColumnWidth("Description", 100); set => setColumnWidth("Description", value); }
public DataGridLength CategoryWidth { get => getColumnWidth("Category", 100); set => setColumnWidth("Category", value); }
public DataGridLength ProductRatingWidth { get => getColumnWidth("ProductRating", 95); set => setColumnWidth("ProductRating", value); }
public DataGridLength PurchaseDateWidth { get => getColumnWidth("PurchaseDate", 75); set => setColumnWidth("PurchaseDate", value); }
public DataGridLength MyRatingWidth { get => getColumnWidth("MyRating", 95); set => setColumnWidth("MyRating", value); }
public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); }
public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); }
public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); }
private static DataGridLength getColumnWidth(string columnName, double defaultWidth)
=> Configuration.Instance.GridColumnsWidths.TryGetValue(columnName, out var val)
? new DataGridLength(val)
: new DataGridLength(defaultWidth);
private void setColumnWidth(string columnName, DataGridLength width, [CallerMemberName] string propertyName = "")
{
var dictionary = Configuration.Instance.GridColumnsWidths;
var newValue = (int)width.DisplayValue;
var valueSame = dictionary.TryGetValue(columnName, out var val) && val == newValue;
dictionary[columnName] = newValue;
if (!valueSame)
{
Configuration.Instance.GridColumnsWidths = dictionary;
this.RaisePropertyChanged(propertyName);
}
}
#endregion
} }
} }
@@ -21,7 +21,7 @@ namespace LibationAvalonia.Views
InitializeComponent(); InitializeComponent();
Configure_Upgrade(); Configure_Upgrade();
Loaded += MainWindow_Loaded; Opened += MainWindow_Opened;
Closing += MainWindow_Closing; Closing += MainWindow_Closing;
LibraryLoaded += MainWindow_LibraryLoaded; LibraryLoaded += MainWindow_LibraryLoaded;
@@ -35,7 +35,7 @@ namespace LibationAvalonia.Views
} }
} }
private async void MainWindow_Loaded(object sender, EventArgs e) private async void MainWindow_Opened(object sender, EventArgs e)
{ {
if (Configuration.Instance.FirstLaunch) if (Configuration.Instance.FirstLaunch)
{ {
@@ -17,6 +17,7 @@
AutoGenerateColumns="False" AutoGenerateColumns="False"
ItemsSource="{Binding GridEntries}" ItemsSource="{Binding GridEntries}"
CanUserSortColumns="True" BorderThickness="3" CanUserSortColumns="True" BorderThickness="3"
CanUserResizeColumns="True"
CanUserReorderColumns="True"> CanUserReorderColumns="True">
<DataGrid.Styles> <DataGrid.Styles>
@@ -45,10 +46,11 @@
</DataGrid.Styles> </DataGrid.Styles>
<DataGrid.Columns> <DataGrid.Columns>
<DataGridTemplateColumn <DataGridTemplateColumn
CanUserSort="True" CanUserSort="True"
IsVisible="{Binding RemoveColumnVisivle}" CanUserResize="False"
IsVisible="{Binding RemoveColumnVisible}"
PropertyChanged="RemoveColumn_PropertyChanged" PropertyChanged="RemoveColumn_PropertyChanged"
Header="Remove" Header="Remove"
IsReadOnly="False" IsReadOnly="False"
@@ -65,7 +67,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn> </DataGridTemplateColumn>
<controls:DataGridTemplateColumnExt CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}"> <controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="True" Header="Liberate" SortMemberPath="Liberate" ClipboardContentBinding="{Binding Liberate.ToolTip}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<views:LiberateStatusButton <views:LiberateStatusButton
@@ -80,15 +82,15 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}"> <controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" /> <Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
</DataTemplate> </DataTemplate>
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="150" Width="2*" Header="Title" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}"> <controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -98,7 +100,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Authors" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}"> <controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -108,7 +110,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Narrators" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}"> <controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -118,7 +120,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="90" Header="Length" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}"> <controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -128,7 +130,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="80" Width="1*" Header="Series" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}"> <controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -138,7 +140,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="Auto" Header="Series&#xA;Order" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}"> <controls:DataGridTemplateColumnExt Header="Series&#xA;Order" MinWidth="10" Width="{Binding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -148,7 +150,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt MinWidth="100" Width="1*" Header="Description" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding LongDescription}"> <controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" > <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
@@ -158,7 +160,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="100" Header="Category" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}"> <controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -172,14 +174,14 @@
x:DataType="uibase:IGridEntry" x:DataType="uibase:IGridEntry"
Header="Product&#xA;Rating" Header="Product&#xA;Rating"
IsReadOnly="true" IsReadOnly="true"
Width="115" MinWidth="10" Width="{Binding ProductRatingWidth, Mode=TwoWay}"
SortMemberPath="ProductRating" CanUserSort="True" SortMemberPath="ProductRating" CanUserSort="True"
OpacityBinding="{CompiledBinding Liberate.Opacity}" OpacityBinding="{CompiledBinding Liberate.Opacity}"
BackgroundBinding="{CompiledBinding Liberate.BackgroundBrush}" BackgroundBinding="{CompiledBinding Liberate.BackgroundBrush}"
ClipboardContentBinding="{CompiledBinding ProductRating}" ClipboardContentBinding="{CompiledBinding ProductRating}"
Binding="{CompiledBinding ProductRating}" /> Binding="{CompiledBinding ProductRating}" />
<controls:DataGridTemplateColumnExt Width="90" Header="Purchase&#xA;Date" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}"> <controls:DataGridTemplateColumnExt Header="Purchase&#xA;Date" MinWidth="10" Width="{Binding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -191,16 +193,16 @@
<controls:DataGridMyRatingColumn <controls:DataGridMyRatingColumn
x:DataType="uibase:IGridEntry" x:DataType="uibase:IGridEntry"
Header="My Rating" Header="My Rating"
IsReadOnly="false" IsReadOnly="false"
Width="115" MinWidth="10" Width="{Binding MyRatingWidth, Mode=TwoWay}"
SortMemberPath="MyRating" CanUserSort="True" SortMemberPath="MyRating" CanUserSort="True"
OpacityBinding="{CompiledBinding Liberate.Opacity}" OpacityBinding="{CompiledBinding Liberate.Opacity}"
BackgroundBinding="{CompiledBinding Liberate.BackgroundBrush}" BackgroundBinding="{CompiledBinding Liberate.BackgroundBrush}"
ClipboardContentBinding="{CompiledBinding MyRating}" ClipboardContentBinding="{CompiledBinding MyRating}"
Binding="{CompiledBinding MyRating, Mode=TwoWay}" /> Binding="{CompiledBinding MyRating, Mode=TwoWay}" />
<controls:DataGridTemplateColumnExt Width="135" Header="Misc" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}"> <controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{Binding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}">
@@ -210,7 +212,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Width="102" Header="Last&#xA;Download" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}"> <controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{Binding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick"> <Panel Opacity="{CompiledBinding Liberate.Opacity}" Background="{CompiledBinding Liberate.BackgroundBrush}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
@@ -220,7 +222,7 @@
</DataGridTemplateColumn.CellTemplate> </DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt> </controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserSort="True" Width="100" Header="Tags" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}"> <controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate> <DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:IGridEntry"> <DataTemplate x:DataType="uibase:IGridEntry">
<Button <Button
@@ -11,6 +11,7 @@ using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels; using LibationAvalonia.ViewModels;
using LibationFileManager; using LibationFileManager;
using LibationUiBase.GridView; using LibationUiBase.GridView;
using ReactiveUI;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
@@ -172,126 +173,107 @@ namespace LibationAvalonia.Views
public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args) public void ProductsGrid_CellContextMenuStripNeeded(object sender, DataGridCellContextMenuStripNeededEventArgs args)
{ {
var entry = args.GridEntry;
var ctx = new GridContextMenu(entry, '_');
if (args.Column.SortMemberPath is not "Liberate" and not "Cover") if (args.Column.SortMemberPath is not "Liberate" and not "Cover")
{ {
var menuItem = new MenuItem { Header = "_Copy Cell Contents" }; args.ContextMenuItems.Add(new MenuItem
{
menuItem.Click += async (s, e) Header = ctx.CopyCellText,
=> await App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents); Command = ReactiveCommand.CreateFromTask(() => App.MainWindow.Clipboard.SetTextAsync(args.CellClipboardContents))
});
args.ContextMenuItems.Add(menuItem);
args.ContextMenuItems.Add(new Separator()); args.ContextMenuItems.Add(new Separator());
} }
var entry = args.GridEntry;
#region Liberate all Episodes #region Liberate all Episodes
if (entry.Liberate.IsSeries) if (entry.Liberate.IsSeries)
{ {
var liberateEpisodesMenuItem = new MenuItem() args.ContextMenuItems.Add(new MenuItem()
{ {
Header = "_Liberate All Episodes", Header = ctx.LiberateEpisodesText,
IsEnabled = ((ISeriesEntry)entry).Children.Any(c => c.Liberate.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload) IsEnabled = ctx.LiberateEpisodesEnabled,
}; Command = ReactiveCommand.Create(() => LiberateSeriesClicked?.Invoke(this, (ISeriesEntry)entry))
});
args.ContextMenuItems.Add(liberateEpisodesMenuItem);
liberateEpisodesMenuItem.Click += (_, _) => LiberateSeriesClicked?.Invoke(this, ((ISeriesEntry)entry));
} }
#endregion #endregion
#region Set Download status to Downloaded #region Set Download status to Downloaded
var setDownloadMenuItem = new MenuItem() args.ContextMenuItems.Add(new MenuItem()
{ {
Header = "Set Download status to '_Downloaded'", Header = ctx.SetDownloadedText,
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated || entry.Liberate.IsSeries IsEnabled = ctx.SetDownloadedEnabled,
}; Command = ReactiveCommand.Create(ctx.SetDownloaded)
});
args.ContextMenuItems.Add(setDownloadMenuItem);
if (entry.Liberate.IsSeries)
setDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.Liberated);
else
setDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.Liberated);
#endregion #endregion
#region Set Download status to Not Downloaded #region Set Download status to Not Downloaded
var setNotDownloadMenuItem = new MenuItem() args.ContextMenuItems.Add(new MenuItem()
{ {
Header = "Set Download status to '_Not Downloaded'", Header = ctx.SetNotDownloadedText,
IsEnabled = entry.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated || entry.Liberate.IsSeries IsEnabled = ctx.SetNotDownloadedEnabled,
}; Command = ReactiveCommand.Create(ctx.SetNotDownloaded)
});
args.ContextMenuItems.Add(setNotDownloadMenuItem);
if (entry.Liberate.IsSeries)
setNotDownloadMenuItem.Click += (_, __) => ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).UpdateBookStatus(LiberatedStatus.NotLiberated);
else
setNotDownloadMenuItem.Click += (_, __) => entry.LibraryBook.UpdateBookStatus(LiberatedStatus.NotLiberated);
#endregion #endregion
#region Remove from library #region Remove from library
var removeMenuItem = new MenuItem() { Header = "_Remove from library" }; args.ContextMenuItems.Add(new MenuItem
{
args.ContextMenuItems.Add(removeMenuItem); Header = ctx.RemoveText,
Command = ReactiveCommand.CreateFromTask(ctx.RemoveAsync)
if (entry.Liberate.IsSeries) });
removeMenuItem.Click += async (_, __) => await ((ISeriesEntry)entry).Children.Select(c => c.LibraryBook).RemoveBooksAsync();
else
removeMenuItem.Click += async (_, __) => await Task.Run(entry.LibraryBook.RemoveBook);
#endregion #endregion
if (!entry.Liberate.IsSeries) if (!entry.Liberate.IsSeries)
{ {
#region Locate file #region Locate file
var locateFileMenuItem = new MenuItem() { Header = "_Locate file..." };
args.ContextMenuItems.Add(locateFileMenuItem); args.ContextMenuItems.Add(new MenuItem
locateFileMenuItem.Click += async (_, __) =>
{ {
try Header = ctx.LocateFileText,
Command = ReactiveCommand.CreateFromTask(async () =>
{ {
var window = this.GetParentWindow(); try
var openFileDialogOptions = new FilePickerOpenOptions
{ {
Title = $"Locate the audio file for '{entry.Book.TitleWithSubtitle}'", var window = this.GetParentWindow();
AllowMultiple = false,
SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix), var openFileDialogOptions = new FilePickerOpenOptions
FileTypeFilter = new FilePickerFileType[]
{ {
new("All files (*.*)") { Patterns = new[] { "*" } }, Title = ctx.LocateFileDialogTitle,
} AllowMultiple = false,
}; SuggestedStartLocation = await window.StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix),
FileTypeFilter = new FilePickerFileType[]
{
new("All files (*.*)") { Patterns = new[] { "*" } },
}
};
var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions); var selectedFiles = await window.StorageProvider.OpenFilePickerAsync(openFileDialogOptions);
var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath(); var selectedFile = selectedFiles.SingleOrDefault()?.TryGetLocalPath();
if (selectedFile is not null) if (selectedFile is not null)
FilePathCache.Insert(entry.AudibleProductId, selectedFile); FilePathCache.Insert(entry.AudibleProductId, selectedFile);
} }
catch (Exception ex) catch (Exception ex)
{ {
var msg = "Error saving book's location"; await MessageBox.ShowAdminAlert(null, ctx.LocateFileErrorMessage, ctx.LocateFileErrorMessage, ex);
await MessageBox.ShowAdminAlert(null, msg, msg, ex); }
} })
}; });
#endregion #endregion
#region Convert to Mp3 #region Convert to Mp3
var convertToMp3MenuItem = new MenuItem args.ContextMenuItems.Add(new MenuItem
{ {
Header = "_Convert to Mp3", Header = ctx.ConvertToMp3Text,
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated IsEnabled = ctx.ConvertToMp3Enabled,
}; Command = ReactiveCommand.Create(() => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook))
args.ContextMenuItems.Add(convertToMp3MenuItem); });
convertToMp3MenuItem.Click += (_, _) => ConvertToMp3Clicked?.Invoke(this, entry.LibraryBook);
#endregion #endregion
} }
@@ -299,34 +281,72 @@ namespace LibationAvalonia.Views
#region Force Re-Download #region Force Re-Download
if (!entry.Liberate.IsSeries) if (!entry.Liberate.IsSeries)
{ {
var reDownloadMenuItem = new MenuItem() args.ContextMenuItems.Add(new MenuItem()
{ {
Header = "Re-download this audiobook", Header = ctx.ReDownloadText,
IsEnabled = entry.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated IsEnabled = ctx.ReDownloadEnabled,
}; Command = ReactiveCommand.Create(() =>
{
args.ContextMenuItems.Add(reDownloadMenuItem); //No need to persist this change. It only needs to last long for the file to start downloading
reDownloadMenuItem.Click += (s, _) => entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated;
{ LiberateClicked?.Invoke(this, entry.LibraryBook);
//No need to persist this change. It only needs to last long for the file to start downloading })
entry.Book.UserDefinedItem.BookStatus = LiberatedStatus.NotLiberated; });
LiberateClicked?.Invoke(s, entry.LibraryBook);
};
} }
#endregion #endregion
args.ContextMenuItems.Add(new Separator()); args.ContextMenuItems.Add(new Separator());
#region Edit Templates
async Task editTemplate<T>(LibraryBook libraryBook, string existingTemplate, Action<string> setNewTemplate)
where T : Templates, LibationFileManager.ITemplate, new()
{
var template = ctx.CreateTemplateEditor<T>(libraryBook, existingTemplate);
var form = new EditTemplateDialog(template);
if (await form.ShowDialog<DialogResult>(this.GetParentWindow()) == DialogResult.OK)
{
setNewTemplate(template.EditingTemplate.TemplateText);
}
}
if (!entry.Liberate.IsSeries)
{
args.ContextMenuItems.Add(new MenuItem
{
Header = ctx.EditTemplatesText,
ItemsSource = new[]
{
new MenuItem
{
Header = ctx.FolderTemplateText,
Command = ReactiveCommand.CreateFromTask(() => editTemplate<Templates.FolderTemplate>(entry.LibraryBook, Configuration.Instance.FolderTemplate, t => Configuration.Instance.FolderTemplate = t))
},
new MenuItem
{
Header = ctx.FileTemplateText,
Command = ReactiveCommand.CreateFromTask(() => editTemplate<Templates.FileTemplate>(entry.LibraryBook, Configuration.Instance.FileTemplate, t => Configuration.Instance.FileTemplate = t))
},
new MenuItem
{
Header = ctx.MultipartTemplateText,
Command = ReactiveCommand.CreateFromTask(() => editTemplate<Templates.ChapterFileTemplate>(entry.LibraryBook, Configuration.Instance.ChapterFileTemplate, t => Configuration.Instance.ChapterFileTemplate = t))
}
}
});
args.ContextMenuItems.Add(new Separator());
}
#endregion
#region View Bookmarks/Clips #region View Bookmarks/Clips
if (!entry.Liberate.IsSeries) if (!entry.Liberate.IsSeries)
{ {
args.ContextMenuItems.Add(new MenuItem
var bookRecordMenuItem = new MenuItem { Header = "View _Bookmarks/Clips" }; {
Header = ctx.ViewBookmarksText,
args.ContextMenuItems.Add(bookRecordMenuItem); Command = ReactiveCommand.CreateFromTask(() => new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window))
});
bookRecordMenuItem.Click += async (_, _) => await new BookRecordsDialog(entry.LibraryBook).ShowDialog(VisualRoot as Window);
} }
#endregion #endregion
@@ -334,12 +354,11 @@ namespace LibationAvalonia.Views
if (entry.Book.SeriesLink.Any()) if (entry.Book.SeriesLink.Any())
{ {
var header = entry.Liberate.IsSeries ? "View All Episodes in Series" : "View All Books in Series"; args.ContextMenuItems.Add(new MenuItem
var viewSeriesMenuItem = new MenuItem { Header = header }; {
Header = ctx.ViewSeriesText,
args.ContextMenuItems.Add(viewSeriesMenuItem); Command = ReactiveCommand.Create(() => new SeriesViewDialog(entry.LibraryBook).Show())
});
viewSeriesMenuItem.Click += (_, _) => new SeriesViewDialog(entry.LibraryBook).Show();
} }
#endregion #endregion
@@ -536,7 +555,7 @@ namespace LibationAvalonia.Views
var displayWindow = new DescriptionDisplayDialog var displayWindow = new DescriptionDisplayDialog
{ {
SpawnLocation = new Point(pt.X, pt.Y), SpawnLocation = new Point(pt.X, pt.Y),
DescriptionText = gEntry.LongDescription, DescriptionText = gEntry.Description,
}; };
void CloseWindow(object o, DataGridRowEventArgs e) void CloseWindow(object o, DataGridRowEventArgs e)
+17 -18
View File
@@ -3,7 +3,6 @@ using AudibleUtilities;
using Avalonia.Controls; using Avalonia.Controls;
using Avalonia.Controls.Presenters; using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives; using Avalonia.Controls.Primitives;
using Avalonia.Interactivity;
using Avalonia.Media; using Avalonia.Media;
using Avalonia.Styling; using Avalonia.Styling;
using Dinah.Core.StepRunner; using Dinah.Core.StepRunner;
@@ -13,7 +12,6 @@ using LibationFileManager;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using static Avalonia.Threading.Dispatcher;
namespace LibationAvalonia namespace LibationAvalonia
{ {
@@ -36,12 +34,13 @@ namespace LibationAvalonia
AutoScan = Configuration.Instance.AutoScan; AutoScan = Configuration.Instance.AutoScan;
Configuration.Instance.AutoScan = false; Configuration.Instance.AutoScan = false;
MainForm = mainForm; MainForm = mainForm;
sequence[nameof(ShowAccountDialog)] = () => UIThread.InvokeAsync(ShowAccountDialog); var uiDispatcher = Avalonia.Threading.Dispatcher.UIThread;
sequence[nameof(ShowSettingsDialog)] = () => UIThread.InvokeAsync(ShowSettingsDialog); sequence[nameof(ShowAccountDialog)] = () => uiDispatcher.InvokeAsync(ShowAccountDialog);
sequence[nameof(ShowAccountScanning)] = () => UIThread.InvokeAsync(ShowAccountScanning); sequence[nameof(ShowSettingsDialog)] = () => uiDispatcher.InvokeAsync(ShowSettingsDialog);
sequence[nameof(ShowSearching)] = () => UIThread.InvokeAsync(ShowSearching); sequence[nameof(ShowAccountScanning)] = () => uiDispatcher.InvokeAsync(ShowAccountScanning);
sequence[nameof(ShowQuickFilters)] = () => UIThread.InvokeAsync(ShowQuickFilters); sequence[nameof(ShowSearching)] = () => uiDispatcher.InvokeAsync(ShowSearching);
sequence[nameof(ShowTourComplete)] = () => UIThread.InvokeAsync(ShowTourComplete); sequence[nameof(ShowQuickFilters)] = () => uiDispatcher.InvokeAsync(ShowQuickFilters);
sequence[nameof(ShowTourComplete)] = () => uiDispatcher.InvokeAsync(ShowTourComplete);
} }
public async Task RunAsync() public async Task RunAsync()
@@ -60,7 +59,7 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.accountsToolStripMenuItem); await displayControlAsync(MainForm.accountsToolStripMenuItem);
var accountSettings = new AccountsDialog(); var accountSettings = new AccountsDialog();
accountSettings.Loaded += async (_, _) => await MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account"); accountSettings.Opened += async (_, _) => await MessageBox.Show(accountSettings, "Add your Audible account(s), then save.", "Add an Account");
await accountSettings.ShowDialog(MainForm); await accountSettings.ShowDialog(MainForm);
return true; return true;
} }
@@ -74,14 +73,14 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.settingsToolStripMenuItem); await displayControlAsync(MainForm.settingsToolStripMenuItem);
await displayControlAsync(MainForm.basicSettingsToolStripMenuItem); await displayControlAsync(MainForm.basicSettingsToolStripMenuItem);
var settingsDialog = await UIThread.InvokeAsync(() => new SettingsDialog()); var settingsDialog = new SettingsDialog();
var tabsToVisit = settingsDialog.tabControl.Items.OfType<TabItem>().ToList(); var tabsToVisit = settingsDialog.tabControl.Items.OfType<TabItem>().ToList();
foreach (var tab in tabsToVisit) foreach (var tab in tabsToVisit)
tab.PropertyChanged += TabControl_PropertyChanged; tab.PropertyChanged += TabControl_PropertyChanged;
settingsDialog.Loaded += SettingsDialog_Loaded; settingsDialog.Opened += SettingsDialog_Opened;
settingsDialog.Closing += SettingsDialog_FormClosing; settingsDialog.Closing += SettingsDialog_FormClosing;
settingsDialog.saveBtn.Content = "Next Tab"; settingsDialog.saveBtn.Content = "Next Tab";
@@ -103,7 +102,7 @@ namespace LibationAvalonia
settingTabMessages.Remove(header.Text); settingTabMessages.Remove(header.Text);
} }
async void SettingsDialog_Loaded(object sender, RoutedEventArgs e) async void SettingsDialog_Opened(object sender, System.EventArgs e)
{ {
await ShowTabPageMessageBoxAsync(tabsToVisit[0]); await ShowTabPageMessageBoxAsync(tabsToVisit[0]);
} }
@@ -227,7 +226,7 @@ namespace LibationAvalonia
await displayControlAsync(editQuickFiltersToolStripMenuItem); await displayControlAsync(editQuickFiltersToolStripMenuItem);
var editQuickFilters = new EditQuickFilters(); var editQuickFilters = new EditQuickFilters();
editQuickFilters.Loaded += async (_, _) => await MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters"); editQuickFilters.Opened += async (_, _) => await MessageBox.Show(editQuickFilters, "From here you can edit, delete, and change the order of Quick Filters", "Editing Quick Filters");
await editQuickFilters.ShowDialog(MainForm); await editQuickFilters.ShowDialog(MainForm);
return true; return true;
@@ -247,12 +246,12 @@ namespace LibationAvalonia
private async Task displayControlAsync(TemplatedControl control) private async Task displayControlAsync(TemplatedControl control)
{ {
await UIThread.InvokeAsync(() => control.IsEnabled = false); control.IsEnabled = false;
await UIThread.InvokeAsync(() => MainForm.productsDisplay.Focus()); MainForm.productsDisplay.Focus();
await UIThread.InvokeAsync(() => flashControlAsync(control)); await flashControlAsync(control);
if (control is MenuItem menuItem) await UIThread.InvokeAsync(menuItem.Open); if (control is MenuItem menuItem) menuItem.Open();
await Task.Delay(500); await Task.Delay(500);
await UIThread.InvokeAsync(() => control.IsEnabled = true); control.IsEnabled = true;
} }
private static async Task flashControlAsync(TemplatedControl control, int flashCount = 3) private static async Task flashControlAsync(TemplatedControl control, int flashCount = 3)
+5 -1
View File
@@ -3,7 +3,7 @@
<PropertyGroup> <PropertyGroup>
<OutputType>Exe</OutputType> <OutputType>Exe</OutputType>
<TargetFramework>net7.0-windows</TargetFramework> <TargetFramework>net8.0-windows7.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<PublishReadyToRun>true</PublishReadyToRun> <PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath> <AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
@@ -11,6 +11,10 @@
<IsPublishable>True</IsPublishable> <IsPublishable>True</IsPublishable>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<RuntimeHostConfigurationOption Include="System.Net.DisableIPv6" Value="true" />
</ItemGroup>
<PropertyGroup> <PropertyGroup>
<SatelliteResourceLanguages>en</SatelliteResourceLanguages> <SatelliteResourceLanguages>en</SatelliteResourceLanguages>
</PropertyGroup> </PropertyGroup>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir> <PublishDir>..\bin\Publish\Linux-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>linux-x64</RuntimeIdentifier> <RuntimeIdentifier>linux-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir> <PublishDir>..\bin\Publish\MacOS-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>osx-x64</RuntimeIdentifier> <RuntimeIdentifier>osx-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
@@ -8,7 +8,7 @@ https://go.microsoft.com/fwlink/?LinkID=208121.
<Platform>Any CPU</Platform> <Platform>Any CPU</Platform>
<PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir> <PublishDir>..\bin\Publish\Windows-chardonnay</PublishDir>
<PublishProtocol>FileSystem</PublishProtocol> <PublishProtocol>FileSystem</PublishProtocol>
<TargetFramework>net7.0</TargetFramework> <TargetFramework>net8.0</TargetFramework>
<RuntimeIdentifier>win-x64</RuntimeIdentifier> <RuntimeIdentifier>win-x64</RuntimeIdentifier>
<SelfContained>true</SelfContained> <SelfContained>true</SelfContained>
<PublishSingleFile>false</PublishSingleFile> <PublishSingleFile>false</PublishSingleFile>
+131 -94
View File
@@ -8,19 +8,21 @@ using Dinah.Core;
using System.Threading.Tasks; using System.Threading.Tasks;
using System.Threading; using System.Threading;
using FileManager; using FileManager;
using AaxDecrypter;
#nullable enable
namespace LibationFileManager namespace LibationFileManager
{ {
public abstract class AudibleFileStorage public abstract class AudibleFileStorage
{ {
protected abstract LongPath GetFilePathCustom(string productId); protected abstract LongPath? GetFilePathCustom(string productId);
protected abstract List<LongPath> GetFilePathsCustom(string productId); protected abstract List<LongPath> GetFilePathsCustom(string productId);
#region static #region static
public static LongPath DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName; public static LongPath DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
public static LongPath DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName; public static LongPath DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
static AudibleFileStorage() static AudibleFileStorage()
{ {
//Clean up any partially-decrypted files from previous Libation instances. //Clean up any partially-decrypted files from previous Libation instances.
//Do no clean DownloadsInProgressDirectory because those files are resumable //Do no clean DownloadsInProgressDirectory because those files are resumable
@@ -30,138 +32,164 @@ namespace LibationFileManager
private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage(); private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage();
public static bool AaxcExists(string productId) => AAXC.Exists(productId); public static bool AaxcExists(string productId) => AAXC.Exists(productId);
public static AudioFileStorage Audio { get; } = new AudioFileStorage(); public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static LongPath BooksDirectory public static LongPath BooksDirectory
{ {
get get
{ {
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books)) if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books"); Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
return Directory.CreateDirectory(Configuration.Instance.Books).FullName; return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
} }
} }
#endregion #endregion
#region instance #region instance
private FileType FileType { get; } private FileType FileType { get; }
private string regexTemplate { get; } private string regexTemplate { get; }
protected AudibleFileStorage(FileType fileType) protected AudibleFileStorage(FileType fileType)
{ {
FileType = fileType; FileType = fileType;
var extAggr = FileTypes.GetExtensions(FileType).Aggregate((a, b) => $"{a}|{b}"); var extAggr = FileTypes.GetExtensions(FileType).Aggregate((a, b) => $"{a}|{b}");
regexTemplate = $@"{{0}}.*?\.({extAggr})$"; regexTemplate = $@"{{0}}.*?\.({extAggr})$";
} }
protected LongPath GetFilePath(string productId) protected LongPath? GetFilePath(string productId)
{ {
// primary lookup // primary lookup
var cachedFile = FilePathCache.GetFirstPath(productId, FileType); var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
if (cachedFile is not null && File.Exists(cachedFile)) if (cachedFile is not null && File.Exists(cachedFile))
return cachedFile; return cachedFile;
// secondary lookup attempt // secondary lookup attempt
var firstOrNull = GetFilePathCustom(productId); var firstOrNull = GetFilePathCustom(productId);
if (firstOrNull is not null) if (firstOrNull is not null)
FilePathCache.Insert(productId, firstOrNull); FilePathCache.Insert(productId, firstOrNull);
return firstOrNull; return firstOrNull;
} }
public List<LongPath> GetPaths(string productId) public List<LongPath> GetPaths(string productId)
=> GetFilePathsCustom(productId); => GetFilePathsCustom(productId);
protected Regex GetBookSearchRegex(string productId) protected Regex GetBookSearchRegex(string productId)
{ {
var pattern = string.Format(regexTemplate, productId); var pattern = string.Format(regexTemplate, productId);
return new Regex(pattern, RegexOptions.IgnoreCase); return new Regex(pattern, RegexOptions.IgnoreCase);
} }
#endregion #endregion
} }
internal class AaxcFileStorage : AudibleFileStorage internal class AaxcFileStorage : AudibleFileStorage
{ {
internal AaxcFileStorage() : base(FileType.AAXC) { } internal AaxcFileStorage() : base(FileType.AAXC) { }
protected override LongPath GetFilePathCustom(string productId) protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault(); => GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId) protected override List<LongPath> GetFilePathsCustom(string productId)
{ {
var regex = GetBookSearchRegex(productId); var regex = GetBookSearchRegex(productId);
return FileUtility return FileUtility
.SaferEnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories) .SaferEnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
.Where(s => regex.IsMatch(s)).ToList(); .Where(s => regex.IsMatch(s)).ToList();
} }
public bool Exists(string productId) => GetFilePath(productId) is not null; public bool Exists(string productId) => GetFilePath(productId) is not null;
} }
public class AudioFileStorage : AudibleFileStorage public class AudioFileStorage : AudibleFileStorage
{ {
internal AudioFileStorage() : base(FileType.Audio) internal AudioFileStorage() : base(FileType.Audio)
=> BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories); => BookDirectoryFiles ??= newBookDirectoryFiles();
private static BackgroundFileSystem BookDirectoryFiles { get; set; } private static BackgroundFileSystem? BookDirectoryFiles { get; set; }
private static object bookDirectoryFilesLocker { get; } = new(); private static object bookDirectoryFilesLocker { get; } = new();
private static EnumerationOptions enumerationOptions { get; } = new() private static EnumerationOptions enumerationOptions { get; } = new()
{ {
RecurseSubdirectories = true, RecurseSubdirectories = true,
IgnoreInaccessible = true, IgnoreInaccessible = true,
MatchCasing = MatchCasing.CaseInsensitive AttributesToSkip = FileAttributes.Hidden,
}; };
protected override LongPath GetFilePathCustom(string productId) protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault(); => GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId) private static BackgroundFileSystem newBookDirectoryFiles()
{ => new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
// If user changed the BooksDirectory: reinitialize
lock (bookDirectoryFilesLocker)
if (BooksDirectory != BookDirectoryFiles.RootDirectory)
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
var regex = GetBookSearchRegex(productId); protected override List<LongPath> GetFilePathsCustom(string productId)
{
// If user changed the BooksDirectory: reinitialize
lock (bookDirectoryFilesLocker)
if (BooksDirectory != BookDirectoryFiles?.RootDirectory)
BookDirectoryFiles = newBookDirectoryFiles();
var regex = GetBookSearchRegex(productId);
//Find all extant files matching the productId //Find all extant files matching the productId
//using both the file system and the file path cache //using both the file system and the file path cache
return return
FilePathCache FilePathCache
.GetFiles(productId) .GetFiles(productId)
.Where(c => c.fileType == FileType.Audio && File.Exists(c.path)) .Where(c => c.fileType == FileType.Audio && File.Exists(c.path))
.Select(c => c.path) .Select(c => c.path)
.Union(BookDirectoryFiles.FindFiles(regex)) .Union(BookDirectoryFiles.FindFiles(regex))
.ToList(); .ToList();
} }
public void Refresh() => BookDirectoryFiles.RefreshFiles(); public void Refresh()
{
if (BookDirectoryFiles is null)
lock (bookDirectoryFilesLocker)
BookDirectoryFiles = newBookDirectoryFiles();
else
BookDirectoryFiles?.RefreshFiles();
}
public LongPath GetPath(string productId) => GetFilePath(productId); public LongPath? GetPath(string productId) => GetFilePath(productId);
public static async IAsyncEnumerable<FilePathCache.CacheEntry> FindAudiobooksAsync(LongPath searchDirectory, [EnumeratorCancellation] CancellationToken cancellationToken) public static async IAsyncEnumerable<FilePathCache.CacheEntry> FindAudiobooksAsync(LongPath searchDirectory, [EnumeratorCancellation] CancellationToken cancellationToken)
{ {
ArgumentValidator.EnsureNotNull(searchDirectory, nameof(searchDirectory)); ArgumentValidator.EnsureNotNull(searchDirectory, nameof(searchDirectory));
foreach (LongPath path in Directory.EnumerateFiles(searchDirectory, "*.M4B", enumerationOptions)) foreach (LongPath path in Directory.EnumerateFiles(searchDirectory, "*.*", enumerationOptions))
{ {
if (cancellationToken.IsCancellationRequested) if (cancellationToken.IsCancellationRequested)
yield break; yield break;
FilePathCache.CacheEntry audioFile = default; if (getFormatByExtension(path) is not OutputFormat format)
continue;
FilePathCache.CacheEntry? audioFile = default;
try try
{ {
using var fileStream = File.OpenRead(path); if (format is OutputFormat.M4b)
{
var tags = await Task.Run(() => AAXClean.AppleTags.FromFile(path));
var mp4File = await Task.Run(() => new AAXClean.Mp4File(fileStream), cancellationToken); if (tags?.Asin is not null)
audioFile = new FilePathCache.CacheEntry(tags.Asin, FileType.Audio, path);
}
else
{
using var fileStream = File.OpenRead(path);
var id3 = await Task.Run(() => NAudio.Lame.ID3.Id3Tag.Create(fileStream));
if (mp4File?.AppleTags?.Asin is not null) var asin = id3?.Children
audioFile = new FilePathCache.CacheEntry(mp4File.AppleTags.Asin, FileType.Audio, path); .OfType<NAudio.Lame.ID3.TXXXFrame>()
.FirstOrDefault(f => f.FieldName == "AUDIBLE_ASIN")
?.FieldValue;
if (!string.IsNullOrWhiteSpace(asin))
audioFile = new FilePathCache.CacheEntry(asin, FileType.Audio, path);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -175,6 +203,15 @@ namespace LibationFileManager
if (audioFile is not null) if (audioFile is not null)
yield return audioFile; yield return audioFile;
} }
static OutputFormat? getFormatByExtension(string path)
{
var ext = Path.GetExtension(path).ToLower();
return ext == ".mp3" ? OutputFormat.Mp3
: ext == ".m4b" ? OutputFormat.M4b
: null;
}
} }
} }
} }
Loaded 100 of 170 files, more files were not shown because too many files have changed in this diff. Show more