Compare commits

..
212 Commits
Author SHA1 Message Date
rmcrackan 26ccc77b47 incr ver 2025-07-22 07:24:26 -04:00
rmcrackan 64fb2ccf7c Merge pull request #1308 from Mbucari/master
Refactors, bug fixes, and performance improvements.
2025-07-22 07:22:35 -04:00
MBucari 890747a902 Do library scan on background thread 2025-07-22 00:20:16 -06:00
Michael Bucari-Tovo 1fdcea929f Form thread safety 2025-07-21 22:52:17 -06:00
Michael Bucari-Tovo 7848366818 Write logs to text .log file instead of .zip file
The ZipFile sink could cause program hangs. Additionally, the only reason it was ever used was to package verbose AudibleApi account login errors, saving the returned Html page as a file. Otherwise, the zip file only contains a .log text file.

- Removed Serilog.Sinks.ZipFile
- Add Serilog configuration migration
- Added a custom destructure to handle logging files. If any files are logged, they will be written to "LogyyyyMM_AdditionalFiles.zip"
2025-07-21 22:19:55 -06:00
Michael Bucari-Tovo 40b4915b65 Improve download/decrypt cancellation 2025-07-21 15:56:41 -06:00
Michael Bucari-Tovo 80b86086ca Consolidate process queue view models
Remove classic and chardonnay-specific implementations
Refactor TrackedQueue into an IList with INotifyCollectionChanged
2025-07-21 15:56:30 -06:00
Michael Bucari-Tovo bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00
Mbucari 657a7bb6bc Improve podcast episode GridEntry creation performance.
Tested on a library with ~5000 podcast episodes on an AMD Ryzen 7700X. Startup time decreases by ~400 ms in Release mode.
2025-07-21 09:49:25 -06:00
rmcrackan f0d7a7bf64 incr ver 2025-07-18 07:19:09 -04:00
rmcrackan 8bc098e7bd Merge pull request #1303 from Mbucari/master
Fix upgrade bug when Libation's working dir isn't program files dir
2025-07-18 07:16:46 -04:00
Michael Bucari-Tovo 9280b29512 Fix upgrade bug when Libation's working dir isn't program files dir
Add MockUpgrader for testing the Upgrade process.
Fixes issue #1302
2025-07-17 13:10:42 -06:00
rmcrackan d8e9b9c505 incr ver 2025-07-17 08:07:08 -04:00
rmcrackan 554b308364 Merge pull request #1299 from Mbucari/master
Bugfixes and minor improvements
2025-07-17 08:04:43 -04:00
MBucari 8d7872a376 UI tweak and optimization 2025-07-16 23:31:34 -06:00
MBucari 747451d243 Refactor Classic process queue
The queue is now more MVVM-like.
2025-07-16 22:58:03 -06:00
MBucari 7e79e98771 Fix possible cross-threading errors with MessageBoxBase 2025-07-16 22:57:25 -06:00
Michael Bucari-Tovo 4b7939541a Code cleanup and refactoring for clarity 2025-07-16 22:55:57 -06:00
MBucari a3734c76b1 Use SynchronizeInvoker's Invoke() method. 2025-07-15 23:22:42 -06:00
MBucari ced4ea6c17 Improve sorting by Liberate status by grouping books with PDFs 2025-07-15 22:50:53 -06:00
MBucari 35ca6f2621 Use built-in comparer and ReactiveObject types 2025-07-15 22:50:28 -06:00
MBucari 4dab16837e Move ProcessQueueViewModel logic into LibationUiBase
Fix UI bug in classic when queue is in popped-out mode.
2025-07-15 22:31:17 -06:00
MBucari 1cf889eed7 Move ProcessBookViewModel logic into LiationUiBase 2025-07-15 15:05:33 -06:00
MBucari b65b1e819b Consolidate queue commands into UI base 2025-07-15 13:32:42 -06:00
MBucari 3d50643ab0 Fix visible book counts being incorrect on startup
If quick filters are applied on startup, a race condition was created between the initial library load book counting and the visible books counting. Only display results of the latest book count.
2025-07-15 11:49:20 -06:00
MBucari abd18d74b0 Fix crash when setting drive root as custom directory (#1300) 2025-07-15 11:44:45 -06:00
MBucari 0e49df06b8 Add message box handler to LibationUiBase 2025-07-15 11:40:01 -06:00
MBucari 38cc3e9725 Revert change to release title 2025-07-15 08:54:22 -06:00
MBucari c9af2bba4b Reduce GitHub API calls when no upgrades are available 2025-07-14 14:43:48 -06:00
MBucari 2191c1536d Prepare Libation for win-arm64 releases
Also add support for four-part version numbers in releases.
2025-07-14 14:20:57 -06:00
MBucari 5b9bf2fbb0 Remove duplicate tests 2025-07-14 12:53:47 -06:00
MBucari 9b1ce8c1d7 Update dependencies 2025-07-14 12:43:53 -06:00
MBucari 9f8075041b Only remove a LibraryBook from queue if we are trying to re-download. 2025-07-14 12:42:05 -06:00
MBucari 944645379e Fix message box text truncation when there is no icon (#1294) 2025-07-14 12:19:26 -06:00
Mbucari cc72517284 Merge branch 'rmcrackan:master' into master 2025-07-14 11:45:44 -06:00
rmcrackan 0044820415 Update README.md 2025-07-07 16:31:09 -04:00
rmcrackan 9f24027de1 Update README.md 2025-07-07 16:29:46 -04:00
rmcrackan 24f95cb03d Update GettingStarted.md 2025-07-07 16:27:59 -04:00
rmcrackan 3aeea54615 Update FrequentlyAskedQuestions.md 2025-07-07 16:26:10 -04:00
rmcrackan f511041781 Create a cue sheet: default false 2025-06-25 12:43:50 -04:00
rmcrackan da9dc91469 incr ver for docker enhancement 2025-06-25 06:58:14 -04:00
rmcrackan e04e70d333 Merge pull request #1265 from vipervire/master
Update Books directory to use LIBATION_BOOKS_DIR if populated
2025-06-25 06:57:01 -04:00
rmcrackan e0b566ee60 Merge pull request #1277 from dev-nicolaos/patch-1
Update deb/rpm Installation Instructions
2025-06-24 07:50:35 -04:00
Nicolaos Skimas bf15d7302e Update Deb/RHEL/Fed Installation Instructions 2025-06-23 22:39:45 -07:00
rmcrackan 8f01c644c0 Update bug_report.md 2025-06-19 07:21:21 -04:00
Mbucari ebd2cc96c5 Merge branch 'rmcrackan:master' into master 2025-06-18 12:13:14 -06:00
rmcrackan 0d1cc42ca7 Bugfix #1269 : Chardonnay. Bad filter string causes infinite loop 2025-06-16 13:19:48 -04:00
vipervire e126dd09ce Update Books directory to use LIBATION_BOOKS_DIR if populated 2025-06-05 23:26:52 +00:00
Michael Bucari-Tovo ec497f4f81 Use virtualized list to improve large queue performance 2025-05-19 10:40:41 -06:00
rmcrackan 248fdfd2bc Probably unnecessary paranoid incr ver. Everything looks correct but I've never actually released relying on the ver's 4th part. I'm incrementing just in case 2025-05-10 16:53:04 -04:00
MBucari 35862d619a Increment version 2025-05-09 21:10:38 -06:00
Mbucari ac2c67985d Merge pull request #1253 from Mbucari/master
Fix download error (#1252 )

I'm merging this one and releasing ASAP.
2025-05-09 21:07:59 -06:00
MBucari f8ae303417 Fix download error (#1252 ) 2025-05-09 21:07:01 -06:00
rmcrackan 0d24caeac2 incr ver 2025-05-09 21:10:19 -04:00
rmcrackan 7f1b357c52 Merge pull request #1250 from Mbucari/master
Bug fixes and a change to license request logic
2025-05-09 21:08:19 -04:00
Michael Bucari-Tovo ef67ae9d6a Ask users to clear the accounts when enabling widevine (#1249) 2025-05-09 17:52:14 -06:00
Michael Bucari-Tovo f35c82d59d Change ApiExtended to always allow provide login option
Previously, only some calls to ApiExtended.CreateAsync() would prompt users to login if necessary. Other calls would only work if the account already had a valid identity, and they would throw exceptions otherwise.

Changed ApiExtended so that the UI registers a static ILoginChoiceEager factory delegate that ApiExtended will use in the event that a login is required.
2025-05-09 17:32:12 -06:00
Michael Bucari-Tovo 10c01f4147 Fix occasional error of audio downloads hanging. 2025-05-09 16:32:59 -06:00
Michael Bucari-Tovo 9366b3baca Default to E-AC-3 spatial audio format. 2025-05-09 13:39:59 -06:00
Michael Bucari-Tovo 20e792c589 Always change the last chapter's length to coincide with the end of the audio file. 2025-05-09 13:36:07 -06:00
Michael Bucari-Tovo dfb63d3275 Add contributor 2025-05-09 13:15:18 -06:00
Michael Bucari-Tovo 19db226f5a Use Libation settings to decide which DRM is downloaded. 2025-05-09 13:13:39 -06:00
Mbucari 203ab00865 Merge branch 'rmcrackan:master' into master 2025-05-08 12:15:26 -06:00
MBucari b11a4887d7 Pad final chapter to prevent tuncation from incorrect chapter info (#1246) 2025-05-08 12:13:55 -06:00
rmcrackan e73fc5e1eb Merge pull request #1247 from starry-shivam/patch-2
Small typo fix in DownloadOptions.Factory.cs
2025-05-08 07:12:42 -04:00
Stɑrry Shivɑm 8561a15061 Small typo fix in DownloadOptions.Factory.cs 2025-05-08 10:48:02 +05:30
MBucari 28ba62aead Fix dash files not being saved (#1236) 2025-05-07 23:15:44 -06:00
rmcrackan 176294cc55 Merge pull request #1245 from Mbucari/master
Minor bugfixes
2025-05-07 19:28:45 -04:00
Michael Bucari-Tovo 152b0e362d Update message box icons 2025-05-07 16:10:03 -06:00
Michael Bucari-Tovo 4600d029dc Re-add converter resource inadvertantly removed in 0df17a22 2025-05-07 14:23:58 -06:00
Michael Bucari-Tovo 1a5684799c Update Hangover styles and behaviors 2025-05-07 13:16:44 -06:00
Michael Bucari-Tovo 0df17a2296 Remove retired ItemsRepeater control 2025-05-07 13:12:12 -06:00
Michael Bucari-Tovo 45472abd1f Update dependencies 2025-05-07 11:15:32 -06:00
Mbucari f2ea4539f2 Merge branch 'rmcrackan:master' into master 2025-05-07 11:13:32 -06:00
Michael Bucari-Tovo 52d3b9cb67 Disable warning 2025-05-07 11:13:26 -06:00
rmcrackan 3d87f2cd9b Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-07 12:39:10 -04:00
rmcrackan e4a3d2ac79 better logging for api errors #1240 2025-05-07 12:39:02 -04:00
Michael Bucari-Tovo 8aa157f2f6 Re-add completed audiobooks to queue (#1219) 2025-05-06 15:43:58 -06:00
Michael Bucari-Tovo 5ab6c1fe70 Update AAXClean to fix metadata reader (#1243 ) 2025-05-06 15:33:38 -06:00
Michael Bucari-Tovo b23c46f79f Fix incorrect chapters in some audiobooks (#1210) 2025-05-06 15:32:59 -06:00
Mbucari 0e987eef00 Fix error in download speed throttle (#1242) 2025-05-06 14:48:40 -06:00
rmcrackan ace3d80e41 Merge pull request #1241 from cherez/patch-1
Fixed doubled first name in templates
2025-05-06 16:29:29 -04:00
Mbucari 4bfb4e73ce Fix aax file getting inadvertently deleted (#1236) 2025-05-06 12:45:43 -06:00
Steven Wallace 7805a3ef11 Fixed broken single word name test
This expected the name duplication that the previous commit fixed to be the behavior, changed to expect the single word to be the last name.
2025-05-06 09:58:09 -05:00
Steven Wallace 08ca2a2db3 Fixed doubled first name in templates
v12.3.0 caused a regression with contributors with a single word name, causing the name to be doubled. This was caused by using that name as both the first and last name, so swap the first name with the (blank) last name rather than duplicate them.
2025-05-05 10:37:28 -05:00
rmcrackan 64a85b6aab Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-02 22:15:36 -04:00
rmcrackan 1a38273d5f incr ver 2025-05-02 22:15:32 -04:00
rmcrackan 303dd7c471 Merge pull request #1233 from Mbucari/master
Bugfixes and Feature Requests
2025-05-02 22:14:33 -04:00
MBucari 313e3846c3 Remove AudioFormat from library book exporter (5f455182) 2025-05-02 15:39:47 -06:00
Michael Bucari-Tovo 422c86345e Add logging 2025-05-02 14:50:33 -06:00
Michael Bucari-Tovo ce952417fb Don't replace library properties in queued item with null/empty 2025-05-02 13:07:53 -06:00
Michael Bucari-Tovo 5f4551822b Remove Book.AudioFormat property
This property was set to the highest quality returned by the library scan. Since adding quality option settings, it is no longer guaranteed to reflect the file that is downloaded. Also, the library scan qualities don't contain spatial audio or widevine-specific qualities., only ADRM.
2025-05-02 12:39:12 -06:00
Michael Bucari-Tovo 3aebc7c885 Improve download performance. 2025-05-02 12:19:32 -06:00
Michael Bucari-Tovo 3982edd0f1 Add codec tag and use real bitrate/samplerate (#1227) 2025-05-02 11:20:58 -06:00
Michael Bucari-Tovo f4dafac28f Try to solve #1226 2025-05-01 13:19:03 -06:00
Michael Bucari-Tovo 1090d29f74 Add fine-grained options for downloading widevine content 2025-05-01 13:03:03 -06:00
rmcrackan 1c336e1fe9 bug fix 2025-04-28 18:55:20 -04:00
rmcrackan c7e9e9ac1e incr ver 2025-04-28 13:36:26 -04:00
rmcrackan 8232b2b5e5 Merge pull request #1223 from Mbucari/master
New features, including spatial audio support
2025-04-28 13:34:40 -04:00
MBucari 9ca879cc3d Revert "Allow re-adding completed queued items"
This reverts commit e2aae85fd7.
2025-04-27 14:31:21 -06:00
MBucari ece48eb6d7 Add spatial audio support 2025-04-27 14:31:14 -06:00
MBucari bffaea6026 Add CDM API url list 2025-04-27 13:15:50 -06:00
MBucari e2aae85fd7 Allow re-adding completed queued items 2025-04-25 19:54:19 -06:00
MBucari 1777dc5a7e Update AAXClean.Codecs and dependencies 2025-04-25 19:52:51 -06:00
Mbucari 2dfe00f428 Merge branch 'rmcrackan:master' into master 2025-04-15 00:36:11 -06:00
rmcrackan 2cd0a022ff bug fix #1212 : fix window title 2025-04-03 08:20:31 -04:00
Michael Bucari-Tovo 5d7ac699e6 Mark unreleased books as unavailable (#1079) 2025-03-25 12:35:18 -06:00
Michael Bucari-Tovo 7d806e0f3e Increase tag template options for contributor and series types
- Add template tag support for multiple series
- Add series ID and contributor ID to template tags
- <first author> and <first narrator> are now name types with name formatter support
- Properly import contributor IDs into database
- Updated docs
2025-03-25 09:34:57 -06:00
Michael Bucari-Tovo 0a9e489f48 Move contributors to UI Base 2025-03-24 13:29:02 -06:00
rmcrackan 17612dacd2 incr ver 2025-03-22 06:35:30 -04:00
rmcrackan e61ad41d5a Merge pull request #1202 from Mbucari/master
Add multiselect feature and a bugfix
2025-03-22 06:31:33 -04:00
Michael Bucari-Tovo c77f2e2162 Add multi-select context menu support (rmcrackan/Libation#1195) 2025-03-21 16:49:21 -06:00
Michael Bucari-Tovo bfcd226795 Fix libation hanging on first inport of large libraries 2025-03-21 11:08:36 -06:00
rmcrackan 0af7c4d90a fix ver 2025-03-21 09:19:03 -04:00
rmcrackan e4826388be incr ver 2025-03-21 09:18:48 -04:00
rmcrackan 98a1fa4dda Merge pull request #1193 from Mbucari/master
Add support for custom themes in chardonnay
2025-03-21 09:12:35 -04:00
Michael Bucari-Tovo 81e9ab7fb2 Fix theme not resetting properly
Change button foreground color
2025-03-20 16:30:08 -06:00
Mbucari 9c82d34ba4 Merge branch 'rmcrackan:master' into master 2025-03-20 15:30:18 -06:00
Mbucari a384bceab0 Update Readme for Chardonnay Themes 2025-03-20 15:29:46 -06:00
Michael Bucari-Tovo 545540d9a4 Improve Libation glass icons for use with dark mode. 2025-03-20 15:04:22 -06:00
MBucari f402912a92 Mark resource as dynamic and delete unused resource 2025-03-19 22:43:50 -06:00
Michael Bucari-Tovo aab4f1d9d6 Add theme import and export function 2025-03-19 21:47:24 -06:00
Michael Bucari-Tovo f183b587b8 Revert all changes if window is closed by user. 2025-03-19 16:38:58 -06:00
Michael Bucari-Tovo 733a091ebd Add theme preview dialog 2025-03-19 16:26:14 -06:00
Mbucari 9043ea6334 Merge branch 'rmcrackan:master' into master 2025-03-19 14:21:51 -06:00
Michael Bucari-Tovo 40890f242a Fix spelling errors 2025-03-19 14:16:32 -06:00
rmcrackan 6c03f525bf Update InstallOnMac.md -- minimum OS supported 2025-03-19 16:01:40 -04:00
Michael Bucari-Tovo dcda1a0cc2 Add contributors to about page 2025-03-18 21:18:25 -06:00
Michael Bucari-Tovo e509f842e4 Remove unused windows forms buttons and streamline dialogs 2025-03-18 21:18:25 -06:00
Mbucari faa2e04b9f Merge branch 'rmcrackan:master' into master 2025-03-18 21:17:30 -06:00
Robert McRackan 71afb5c9f4 incr ver 2025-03-18 21:09:27 -04:00
Michael Bucari-Tovo d90ef3f4d4 Mark IconFill as a dynamic resource 2025-03-18 12:33:01 -06:00
Michael Bucari-Tovo f84bb753e9 Revert custom window border on Windows 2025-03-13 16:44:16 -06:00
Michael Bucari-Tovo b34970bd47 Add support for custom themes in chardonnay 2025-03-13 16:05:32 -06:00
Robert McRackan a37eb383cd Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-03-10 19:11:22 -04:00
Robert McRackan 614965e1ab incr ver 2025-03-10 19:09:44 -04:00
rmcrackan 52d611a74c Merge pull request #1189 from Mbucari/master
Minor bug fixes.
2025-03-10 17:30:24 -04:00
Michael Bucari-Tovo 653381b1df Fix Auto download not working sometimes (#1183) 2025-03-10 12:57:52 -06:00
Michael Bucari-Tovo 4e067f5b5b Remove inadvertently committed debugging code 2025-03-10 10:46:36 -06:00
Michael Bucari-Tovo ee05ca4eb2 Handle corrupted LibraryScans.zip file (#1185) 2025-03-10 09:49:22 -06:00
Michael Bucari-Tovo 65e12d9a8f Add default window dimensions 2025-03-10 09:07:35 -06:00
rmcrackan 5dc1bafb94 Update Docker.md 2025-03-07 10:12:05 -05:00
rmcrackan 3010f80834 Merge pull request #1181 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.7
Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
2025-03-06 20:49:34 -05:00
dependabot[bot] 6c20b85200 Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.6...v3.1.7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-06 22:29:43 +00:00
rmcrackan bf87180fe9 Merge pull request #1179 from Mbucari/master
UI tweak and Linux command updates
2025-03-05 17:41:47 -05:00
Michael Bucari-Tovo ae9aac789f Use polkit (#1176) and dnf5/dnf (#1177) 2025-03-05 14:22:51 -07:00
Mbucari e6cd182872 Merge branch 'rmcrackan:master' into master 2025-03-05 11:25:50 -07:00
Michael Bucari-Tovo 7eeb2dcd7f Move encoding options above to the mp3 settings column (#764)
Additionally, add more tool tips for cryptic options.
2025-03-05 11:17:47 -07:00
rmcrackan 71bb0571d1 Merge pull request #1178 from RokeJulianLockhart/patch-1
Add instructions for Fedora to `InstallOnLinux.md`.
2025-03-05 11:32:10 -05:00
Mr. Beedell, Roke Julian Lockhart 7bb5b2968e Add instructions for Fedora to InstallOnLinux.md
Partially improves upon https://github.com/rmcrackan/Libation/issues/1177#issue-2897597653.
2025-03-05 15:17:49 +00:00
rmcrackan b051283fca Update bug_report.md 2025-03-05 09:30:43 -05:00
rmcrackan 53af2ee39e Update feature_request.md 2025-03-05 09:30:18 -05:00
rmcrackan fab32a1744 Update bug_report.md 2025-03-05 09:29:04 -05:00
rmcrackan e2dabc8a53 Update feature_request.md 2025-03-05 09:26:47 -05:00
rmcrackan fd82f7ae5a Update feature_request.md 2025-03-05 09:26:19 -05:00
rmcrackan df9535a83d Update FrequentlyAskedQuestions.md 2025-03-05 08:20:42 -05:00
rmcrackan 85b6792468 Merge pull request #1175 from Mbucari/master
Additional null safety
2025-03-04 21:32:35 -05:00
Michael Bucari-Tovo e37abbf276 Fix dark theme text color in DataGridTextColumn 2025-03-04 16:18:06 -07:00
Michael Bucari-Tovo c3938c49a9 Additional null safety 2025-03-04 15:41:26 -07:00
Michael Bucari-Tovo 7658f21d7c Fix tags font color in dark mode 2025-03-04 15:07:37 -07:00
Michael Bucari-Tovo c4827fc761 Add error logging 2025-03-04 12:54:05 -07:00
rmcrackan 649b52af1d Merge pull request #1174 from Mbucari/master
Null safety & UI tweak
2025-03-04 13:10:51 -05:00
Michael Bucari-Tovo da06511951 Only show buttons on mouse over 2025-03-04 10:34:09 -07:00
Michael Bucari-Tovo 88d3e5ff0c Null safety checks. 2025-03-04 10:33:29 -07:00
rmcrackan 5f99e594d8 Merge pull request #1172 from Mbucari/master
Thread safety and AccountSettings.json error handling
2025-03-03 19:27:45 -05:00
Michael Bucari-Tovo 981a183992 Add a border around dialogs with CanResize=true 2025-03-03 15:20:58 -07:00
Michael Bucari-Tovo ac036f65f1 Handle and notify users of invalid account settings file 2025-03-03 14:41:56 -07:00
Michael Bucari-Tovo b36e110b49 Add thread safety and comments re threading 2025-03-03 10:11:17 -07:00
Robert McRackan ef3c71a939 Incr ver 2025-03-02 20:34:45 -05:00
rmcrackan b2af93bed9 Merge pull request #1169 from Mbucari/master
12.0.1 Bug Fixes
2025-03-02 20:33:59 -05:00
Mbucari 1f427919e6 Try to solve threadding issues (#1168) 2025-03-02 11:59:35 -07:00
Mbucari c9c5bbb687 Fix errorre rmoving entries from the cache (#1167) 2025-03-02 10:55:26 -07:00
Robert McRackan efbefa2784 Increm ver 2025-03-01 10:44:49 -05:00
rmcrackan 51aabe5dd4 Merge pull request #1161 from Mbucari/master
Performance and UI tweaks
2025-02-28 22:48:49 -05:00
Michael Bucari-Tovo 1c668adff8 Deliminated category names in library exports with semicolon (#1107) 2025-02-28 17:35:58 -07:00
Michael Bucari-Tovo 4170dcc1d5 Chardonnay UI bug fixes and improvements
- Theme changes do not require restart
- Fix some text appearing black in dark mode
- Fix dialog boxes not appearing correctly on Windows
- Fix queue vertical scroll bar overlapping items
2025-02-28 17:34:55 -07:00
Michael Bucari-Tovo 68cfae1d58 Fix ever-widening Form1 when form size is restored. 2025-02-28 12:04:49 -07:00
Michael Bucari-Tovo a790c7535c Changes to default directories for file storage (#1112)
- Add My Music and Local Application Data to known directories
- Make %localappdata%\Libation the default settings folder on *nix machines
- Make %MyMusic%\Libation\Books the default books folder on *nix machines
2025-02-28 12:01:58 -07:00
Michael Bucari-Tovo 3b7d5a354f Re-add books to queue that failed or were cancelled. 2025-02-28 10:47:26 -07:00
MBucari a9375f1520 Improve file cache performance and add migration
LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size.

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

Fix initial backup counts
2025-02-27 13:11:28 -07:00
Michael Bucari-Tovo 2a25b7e0ad Load MainWindow before library finishes loading like Classic 2025-02-27 11:17:18 -07:00
Michael Bucari-Tovo 4766ea7372 Improve NRE safety for quick filters 2025-02-27 10:10:26 -07:00
Michael Bucari-Tovo d195dd07dc Remove upload-release-assets package (out of support) 2025-02-27 09:30:08 -07:00
Michael Bucari-Tovo bcbb7610ad Refactor GetAllEntries methods for clarity and maintainability
d
2025-02-27 09:00:26 -07:00
Michael Bucari-Tovo 6c5773df24 Fix stack overflow exception when updating large databases (#1158) 2025-02-26 14:41:59 -07:00
rmcrackan 211f15af25 Update InstallOnMac.md 2025-02-25 10:03:11 -05:00
Robert McRackan e3b0f80016 incr ver: 12.0 2025-02-24 12:05:24 -05:00
Robert McRackan 7b2c7e49e5 fix unit tests 2025-02-23 11:11:25 -05:00
Robert McRackan 6a40d19393 Fix unit tests 2025-02-23 10:28:09 -05:00
Robert McRackan 3167744111 #1151 : default character replacement for colon is _ 2025-02-23 10:13:15 -05:00
Robert McRackan b6a3ba335a Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-22 22:54:12 -05:00
Robert McRackan fa1ddc726a #1151 - unicode colon is causing issues with Windows Chardonnay 2025-02-22 22:54:00 -05:00
rmcrackan b3b0662dec Update InstallOnMac.md
Apple can’t check app for malicious software
2025-02-19 17:09:26 -05:00
Robert McRackan 5cb22cfd24 Update AAXClean.Codecs 2025-02-17 20:07:50 -05:00
Michael Bucari-Tovo e911344850 Workaround for DataGridView filtering internal NullReferenceException 2025-02-10 10:20:18 -07:00
Michael Bucari-Tovo 8ec7e5a9d2 Revert "DataGridView filtering internal NullReferenceException **HACK**"
This reverts commit e1f749c3da.
2025-02-10 10:19:24 -07:00
Robert McRackan e1f749c3da DataGridView filtering internal NullReferenceException **HACK** 2025-02-10 11:23:27 -05:00
Robert McRackan ba060d15aa Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-10 09:12:32 -05:00
Robert McRackan 93fde236c8 format for clarity 2025-02-10 09:12:29 -05:00
Michael Bucari-Tovo 13aad1a7cb Restrict audio sample rate settings to allowed values (#1116) 2025-01-16 10:35:58 -07:00
rmcrackan 65c64c4504 Update Docker.md 2025-01-13 09:28:55 -05:00
rmcrackan 14ba04c28b Update Docker.md 2025-01-13 09:26:52 -05:00
rmcrackan 96e886d207 Update FrequentlyAskedQuestions.md
Brazil login
2025-01-08 10:04:55 -05:00
Robert McRackan c7279574a9 Upgrade avalonia 2025-01-07 08:00:35 -05:00
rmcrackan a522e1ff7e Merge pull request #1072 from shuvashish76/patch-1
Update InstallOnLinux.md with repology badge
2024-12-08 08:56:32 -05:00
shuvashish76 9ebc4444bd Update InstallOnLinux.md 2024-12-07 06:24:34 +00:00
shuvashish76 525afdf050 Update InstallOnLinux.md 2024-12-07 06:03:46 +00:00
shuvashish76 983cdf6ad5 Update InstallOnLinux.md with repology badge 2024-12-07 05:38:35 +00:00
338 changed files with 22864 additions and 5679 deletions

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
{
"CdmUrls": [
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
]
}
+7 -8
View File
@@ -6,10 +6,10 @@ labels: bug
assignees: '' assignees: ''
--- ---
**Describe the bug** **Describe the bug**
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**To Reproduce** **To Reproduce**
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
@@ -17,15 +17,14 @@ Steps to reproduce the behavior:
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
**Expected behavior** **Expected behavior**
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Screenshots** **Screenshots**
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Platform** **Platform**
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)] [e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files** **Log Files**
Attach your Libation log file here. Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
+16 -4
View File
@@ -6,14 +6,26 @@ labels: enhancement
assignees: '' assignees: ''
--- ---
**Is your feature request related to a problem? Please describe.** **No-go ideas**
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
* comprehensive api/cli
* aax/audiobook import
* bulk rename of existing files
* general metadata/tag editor
* playback features
* web gui
* supporting non-audible vendors
* official docker support
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...] A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like** **Describe the solution you'd like**
A clear and concise description of what you want to happen. A clear and concise description of what you want to happen.
**Describe alternatives you've considered** **Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered. A clear and concise description of any alternative solutions or features you've considered.
**Additional context** **Additional context**
Add any other context or screenshots about the feature request here. Add any other context or screenshots about the feature request here.
+18 -15
View File
@@ -15,6 +15,10 @@ on:
description: "Skip running unit tests" description: "Skip running unit tests"
required: false required: false
default: true default: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env: env:
DOTNET_CONFIGURATION: "Release" DOTNET_CONFIGURATION: "Release"
@@ -22,8 +26,11 @@ env:
jobs: jobs:
build: build:
name: "${{ matrix.os }}-${{ matrix.release_name }}" name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
runs-on: windows-latest runs-on: windows-latest
env:
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
RUNTIME_ID: "win-${{ inputs.architecture }}"
strategy: strategy:
matrix: matrix:
os: [Windows] os: [Windows]
@@ -63,46 +70,42 @@ jobs:
run: | run: |
dotnet publish ` dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj ` Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` --output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish ` dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj ` LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` --output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml -p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish ` dotnet publish `
LibationCli/LibationCli.csproj ` LibationCli/LibationCli.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` --output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:DefineConstants="${{ matrix.release_name }}" ` -p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish ` dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj ` Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} ` --configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` --output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact - name: Zip artifact
id: zip id: zip
working-directory: ./Source/bin/Publish working-directory: ./Source/bin/Publish
run: | run: |
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\" $bin_dir = "${{ env.OUTPUT_NAME }}\"
$delfiles = @( $delfiles = @(
"libmp3lame.x64.so",
"libmp3lame.arm64.so",
"libmp3lame.x64.dylib",
"libmp3lame.arm64.dylib",
"ffmpegaac.x64.so",
"ffmpegaac.arm64.so",
"ffmpegaac.x64.dylib",
"ffmpegaac.arm64.dylib",
"WindowsConfigApp.exe", "WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json", "WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json" "WindowsConfigApp.deps.json"
) )
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } } foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}" $artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
"artifact=$artifact" >> $env:GITHUB_OUTPUT "artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip" Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
+4
View File
@@ -18,10 +18,14 @@ on:
jobs: jobs:
windows: windows:
strategy:
matrix:
architecture: [x64]
uses: ./.github/workflows/build-windows.yml uses: ./.github/workflows/build-windows.yml
with: with:
version_override: ${{ inputs.version_override }} version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }} run_unit_tests: ${{ inputs.run_unit_tests }}
architecture: ${{ matrix.architecture }}
linux: linux:
strategy: strategy:
+3 -8
View File
@@ -51,13 +51,8 @@ jobs:
with: with:
name: Libation ${{ needs.prerelease.outputs.version }} name: Libation ${{ needs.prerelease.outputs.version }}
body: <Put a body here> body: <Put a body here>
token: ${{ secrets.GITHUB_TOKEN }}
draft: true draft: true
prerelease: false prerelease: false
files: |
- name: Upload release assets artifacts/*/*
uses: dwenegar/upload-release-assets@v2
env:
GITHUB_TOKEN: "${{ secrets.GITHUB_TOKEN }}"
with:
release_id: "${{ steps.release.outputs.id }}"
assets_path: ./artifacts
+8 -8
View File
@@ -1,10 +1,10 @@
{ {
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip", "WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip", "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb", "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm", "LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz", "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb", "LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm", "LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz" "MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
} }
+1 -1
View File
@@ -124,7 +124,7 @@ main() {
init_config_file Settings.json init_config_file Settings.json
info "loading settings" info "loading settings"
update_settings Settings.json Books /data update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
update_settings Settings.json InProgress /tmp update_settings Settings.json InProgress /tmp
info "loading database" info "loading database"
+23
View File
@@ -11,6 +11,7 @@
- [Settings](#settings) - [Settings](#settings)
- [Custom File Naming](NamingTemplates.md) - [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface) - [Command Line Interface](#command-line-interface)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
@@ -86,3 +87,25 @@ CLI: Full library. No prompt
libationcli set-status -n libationcli set-status -n
libationcli set-status -d -n libationcli set-status -d -n
``` ```
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
#### Theme Editor Window
The theme editor has a list of style names and their currently assigned colors. To change a style color, click on the color swatch in the left-hand column to open the color editor for that style. Observe the color changes in real-time on the built-in preview panel on the right-hand side of the theme editor.
You may import or export themes using the buttons at the bottom-left of the theme editor.
"Cancel" or closing the window will revert any changes you've made in the theme editor.
"Reset" will reset any changes you've made in the theme editor.
"Defaults" will restore the application default colors for the active theme ("Light" or "Dark")
"Save" will save the theme colors to the ChardonnayTheme.json file and close the editor.
Note: you may only edit the currently applied theme ("Light" or "Dark").
#### Video Walkthrough
The below video demonstrates using the theme editor to make changes to the Dark theme color pallet.
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
+12 -3
View File
@@ -5,7 +5,7 @@
> [!WARNING] > [!WARNING]
> ## Breaking Changes > ## Breaking Changes
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them. > * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior. > * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable. > * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
@@ -49,7 +49,9 @@ sudo docker run -d \
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. | | LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
### User ### User
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following: If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
@@ -63,5 +65,12 @@ sudo docker run -d \
rmcrackan/libation:latest rmcrackan/libation:latest
``` ```
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
### Advanced Database Options ### Advanced Database Options
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there. The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
### Getting help
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.
@@ -7,6 +7,10 @@
# Frequently Asked Questions # Frequently Asked Questions
## Q: Where can I get help for my specific problem?
**A:** [You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
## Q: What's the difference between 'Classic' and 'Chardonnay'? ## Q: What's the difference between 'Classic' and 'Chardonnay'?
**A:** First and most importantly: Classic and Chardonnay have the exact same features. **A:** First and most importantly: Classic and Chardonnay have the exact same features.
@@ -31,6 +35,10 @@ Self-hosting online:
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/) * [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) * [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
## Q: I'm having trouble loggin into my Brazil account.
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
## Q: How do I use Libation with a South Africa account? ## 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. **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.
+5
View File
@@ -15,6 +15,7 @@
- [Download PDF attachments](#download-pdf-attachments) - [Download PDF attachments](#download-pdf-attachments)
- [Details of downloaded files](#details-of-downloaded-files) - [Details of downloaded files](#details-of-downloaded-files)
- [Export your library](#export-your-library) - [Export your library](#export-your-library)
- [I still need help](#i-still-need-help)
@@ -148,3 +149,7 @@ When you set up Libation, you'll specify a Books directory. Libation looks insid
![Export](images/Export.png) ![Export](images/Export.png)
Export your library to Excel, CSV, or JSON Export your library to Excel, CSV, or JSON
### I still need help
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
+44 -8
View File
@@ -4,25 +4,61 @@
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Packaging status
# Install and Run Libation on Ubuntu [![Packaging status](https://repology.org/badge/vertical-allrepos/libation.svg)](https://repology.org/project/libation/versions)
New Libation releases are automatically packed into .deb and .rpm package and are available from the Libation repository's releases page. New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the [Libation repository's releases page](https://github.com/rmcrackan/Libation/releases).
Run these commands in your terminal to download and install Libation. **Make sure you replace** `X.X.X` with the latest Libation version and `ARCH` with your CPU's architechture (either `amd64` or `arm64`).
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url: ### Debian
- Debian
```Console ```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb && wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.deb
sudo apt install ./libation.deb sudo apt install ./libation.deb
``` ```
- Redhat and CentOS ### Redhat and CentOS
```Console ```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm && wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
sudo yum install ./libation.rpm sudo yum install ./libation.rpm
``` ```
### Fedora
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
sudo dnf5 install ./libation.rpm
```
---
### Arch Linux
```Console
yay -S libation
```
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
### NixOS
- Install via `nix-shell`
```Console
nix-shell -p libation
```
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
- Install via NixOS configuration
```Console
environment.systemPackages = [
pkgs.libation
];
```
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
- On NixOS via via `nix-env`
```Console
nix-env -iA nixos.libation
```
- On Non NixOS via `nix-env`
```Console
nix-env -iA nixpkgs.libation
```
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
If your desktop uses gtk, you should now see Libation among your applications. If your desktop uses gtk, you should now see Libation among your applications.
+11 -2
View File
@@ -8,12 +8,12 @@
# 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.
## Supports macOS 10.15 (Catalina) and above ## Supports macOS 13 (Ventura) and above
## Install Libation ## Install Libation
- Download the file from the latest release and extract it. - Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.9.4.2-macOS-chardonnay-`**arm64**`.tgz` - Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz` - Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder. - Move the extracted Libation app bundle to your applications folder.
- Right-click on Libation and then click on open - Right-click on Libation and then click on open
@@ -40,6 +40,15 @@ You can add Libation as a safe app without touching Gatekeeper.
* Close the terminal and use Libation! * Close the terminal and use Libation!
## "Apple can't check app for malicious software"
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
* Go to Security, then click Open.
* Click Open Anyway. This button is available for about an hour after you try to open the app.
* Enter your login password, then click OK.
## Troubleshooting ## Troubleshooting
If Libation fails to start after completing the above steps, try the following: If Libation fails to start after completing the above steps, try the following:
+47 -26
View File
@@ -17,6 +17,9 @@ These templates apply to both GUI and CLI.
- [Conditional Tags](#conditional-tags) - [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters) - [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters) - [Text Formatters](#text-formatters)
- [Series Formatters](#series-formatters)
- [Series List Formatters](#series-list-formatters)
- [Name Formatters](#name-formatters)
- [Name List Formatters](#name-list-formatters) - [Name List Formatters](#name-list-formatters)
- [Number Formatters](#number-formatters) - [Number Formatters](#number-formatters)
- [Date Formatters](#date-formatters) - [Date Formatters](#date-formatters)
@@ -32,32 +35,33 @@ These tags will be replaced in the template with the audiobook's values.
|Tag|Description|Type| |Tag|Description|Type|
|-|-|-| |-|-|-|
|\<id\> **†**|Audible book ID (ASIN)|Text| |\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title with subtitle|Text| |\<title\>|Full title with subtitle|[Text](#text-formatters)|
|\<title short\>|Title. Stop at first colon|Text| |\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|\<audible title\>|Audible's title (does not include subtitle)|Text| |\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|\<audible subtitle\>|Audible's subtitle|Text| |\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|\<author\>|Author(s)|Name List| |\<author\>|Author(s)|[Name List](#name-list-formatters)|
|\<first author\>|First author|Text| |\<first author\>|First author|[Name](#name-formatters)|
|\<narrator\>|Narrator(s)|Name List| |\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|\<first narrator\>|First narrator|Text| |\<first narrator\>|First narrator|[Name](#name-formatters)|
|\<series\>|Name of series|Text| |\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|\<series#\>|Number order in series|Number| |\<first series\>|First series|[Series](#series-formatters)|
|\<bitrate\>|File's original bitrate (Kbps)|Number| |\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|\<samplerate\>|File's original audio sample rate|Number| |\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|\<channels\>|Number of audio channels|Number| |\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|\<account\>|Audible account of this book|Text| |\<channels\>|Number of audio channels|[Number](#number-formatters)|
|\<account nickname\>|Audible account nickname of this book|Text| |\<account\>|Audible account of this book|[Text](#text-formatters)|
|\<locale\>|Region/country|Text| |\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|\<year\>|Year published|Number| |\<locale\>|Region/country|[Text](#text-formatters)|
|\<language\>|Book's language|Text| |\<year\>|Year published|[Number](#number-formatters)|
|\<language\>|Book's language|[Text](#text-formatters)|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text| |\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|DateTime| |\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|\<pub date\>|Audiobook publication date|DateTime| |\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|\<date added\>|Date the book added to your Audible account|DateTime| |\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|\<ch count\> **‡**|Number of chapters|Number| |\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|\<ch title\> **‡**|Chapter title|Text| |\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|\<ch#\> **‡**|Chapter number|Number| |\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Number| |\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
**†** Does not support custom formatting **†** Does not support custom formatting
@@ -95,11 +99,28 @@ As an example, this folder template will place all Liberated podcasts into a "Po
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel| |L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET| |U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Series Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1, B08376S3R2|
## Series List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<author[format({L}, {ID}) separator(; )]>`|Sherlock Holmes, 1; Some Other Series, 1<hr>herlock Holmes, B08376S3R2; Some Other Series, B000000000|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
## Name Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
## Name List Formatters ## Name List Formatters
|Formatter|Description|Example Usage|Example Result| |Formatter|Description|Example Usage|Example Result|
|-|-|-|-| |-|-|-|-|
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry| |separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<author[format({L}, {F})`<br>`separator(; )]>`|Doyle, Arthur; Fry, Stephen| |format(\{T \| F \| M \| L \| S \| ID\})|Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above.|`<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle| |sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle| |max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
+28 -26
View File
@@ -1,34 +1,36 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 524 524" enable-background="new 0 0 524 524"> <svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<defs> <defs>
<g id="glass"> <g id="glass">
<path fill-rule="evenodd" d= <path transform="translate(16 16)" fill-rule="evenodd" d=
"M262,8 "M177,16
h-117 H79
a 192,200 0 0 0 -36,82 A 32.0781 63.7932 -1.5106 0 0 66 80
a 222,334 41 0 0 138,236 A 158.789 471.1259 41.9466 0 0 90 131
v158 A 81.7197 122.0515 35.3745 0 0 128 143.3484
h-81 A 81.7197 122.0515 -35.3745 0 0 166 131
a 16,16 0 0 0 0,32 A 158.789 471.1259 -41.9466 0 0 190 80
h192 A 32.0781 63.7932 1.5106 0 0 177 16
a 16 16 0 0 0 0,-32 L 184 0
h-81 A 44.7901 78.5247 1.1521 0 1 194 122
v-158 A 97.0039 135.3148 -36.2124 0 1 136 159
a 222,334 -41 0 0 138,-236 V 240
a 192,200 0 0 0 -36,-82 H 176
h-117 A 8 8 0 0 1 176 256
m-99,30 H 80
a 192,200 0 0 0 -26,95 A 8 8 0 0 1 80 240
a 187.5,334 35 0 0 125,159 H 120
a 187.5,334 -35 0 0 125,-159 V 159
a 192,200 0 0 0 -26,-95 A 97.0039 135.3148 36.2124 0 1 62 122
h-198 A 44.7901 78.5247 -1.1521 0 1 72 0
H184
z"/> z"/>
</g> </g>
<g id="wine-level"> <g transform="translate(16 16)" id="wine-level">
<path d= <path d=
"M158,136 "M182,64
a 168,305 35 0 0 104,136 H 74
a 168,305 -35 0 0 104,-136 A 115.9979 308.8033 38.9474 0 0 128 134.4277
A 115.9979 308.8033 -38.9474 0 0 182,64
z"/> z"/>
</g> </g>
</defs> </defs>

Before

Width:  |  Height:  |  Size: 968 B

After

Width:  |  Height:  |  Size: 1.2 KiB

+30 -29
View File
@@ -1,30 +1,31 @@
<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" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<g>
<g transform="translate(0 80) rotate(90 256,256)"> <path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
<path id="glass" d= "M177,16
"M139,2 H79
A 192,200 0 0 0 103,84 A 32.0781 63.7932 -1.5106 0 0 66 80
A 222,334 41 0 0 241,320 A 158.789 471.1259 41.9466 0 0 90 131
V478 A 81.7197 122.0515 35.3745 0 0 128 143.3484
H160 A 81.7197 122.0515 -35.3745 0 0 166 131
A 16,16 0 0 0 160,510 A 158.789 471.1259 -41.9466 0 0 190 80
H352 A 32.0781 63.7932 1.5106 0 0 177 16
A16 16 0 0 0 352,478 L 184 0
H271 A 44.7901 78.5247 1.1521 0 1 194 122
V320 A 97.0039 135.3148 -36.2124 0 1 136 159
A 222,334 -41 0 0 409,84 V 240
A 192,200 0 0 0 373,2 H 176
M355,32 A 8 8 0 0 1 176 256
A 192,200 0 0 1 381,127 H 80
A 187.5,334 -35 0 1 256,286 A 8 8 0 0 1 80 240
A 187.5,334 35 0 1 131,127 H 120
A 192,200 0 0 1 157,32 V 159
H355 A 97.0039 135.3148 36.2124 0 1 62 122
z" /> A 44.7901 78.5247 -1.1521 0 1 72 0
<path id="wine-level" d= H184
"M345,44 M170,115
A 192,184 0 0 1 366,126 V24
A 320,180 55 0 1 345,226 A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
z"/> A 19.5181 45.9183 3.3549 0 1 170 115
</g> z"/>
</g>
</svg> </svg>

Before

Width:  |  Height:  |  Size: 638 B

After

Width:  |  Height:  |  Size: 936 B

+2
View File
@@ -22,6 +22,7 @@
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments) - [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files) - [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
- [Export your library](Documentation/GettingStarted.md#export-your-library) - [Export your library](Documentation/GettingStarted.md#export-your-library)
- If you still need help, [you can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
- [Searching and filtering](Documentation/SearchingAndFiltering.md) - [Searching and filtering](Documentation/SearchingAndFiltering.md)
- [Tags](Documentation/SearchingAndFiltering.md#tags) - [Tags](Documentation/SearchingAndFiltering.md#tags)
- [Searches](Documentation/SearchingAndFiltering.md#searches) - [Searches](Documentation/SearchingAndFiltering.md#searches)
@@ -32,6 +33,7 @@
- [Settings](Documentation/Advanced.md#settings) - [Settings](Documentation/Advanced.md#settings)
- [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)
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
- [Docker](Documentation/Docker.md) - [Docker](Documentation/Docker.md)
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md) - [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
+1 -7
View File
@@ -53,13 +53,7 @@ if [ $? -ne 0 ]
fi fi
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json') delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "arm64" ]]
then
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
else
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
fi
for n in "${delfiles[@]}" for n in "${delfiles[@]}"
do do
+1 -12
View File
@@ -82,18 +82,7 @@ echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=( 'libmp3lame.arm64.so' 'libmp3lame.x64.so' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.so' 'ffmpegaac.x64.so' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json') delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "arm64" ]]
then
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
mv $BUNDLE_MACOS/ffmpegaac.arm64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
mv $BUNDLE_MACOS/libmp3lame.arm64.dylib $BUNDLE_MACOS/libmp3lame.dylib
else
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
mv $BUNDLE_MACOS/ffmpegaac.x64.dylib $BUNDLE_MACOS/ffmpegaac.dylib
mv $BUNDLE_MACOS/libmp3lame.x64.dylib $BUNDLE_MACOS/libmp3lame.dylib
fi
for n in "${delfiles[@]}" for n in "${delfiles[@]}"
do do
+1 -3
View File
@@ -38,14 +38,12 @@ fi
BASEDIR=$(pwd) BASEDIR=$(pwd)
delfiles=('libmp3lame.arm64.dylib' 'libmp3lame.x64.dylib' 'libmp3lame.x64.dll' 'libmp3lame.x86.dll' 'ffmpegaac.arm64.dylib' 'ffmpegaac.x64.dylib' 'ffmpegaac.x64.dll' 'ffmpegaac.x86.dll' 'LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json') delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "x64" ]] if [[ "$ARCH" == "x64" ]]
then then
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
ARCH_RPM="x86_64" ARCH_RPM="x86_64"
ARCH="amd64" ARCH="amd64"
else else
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
ARCH_RPM="aarch64" ARCH_RPM="aarch64"
fi fi
+1 -1
View File
@@ -13,7 +13,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="1.1.3" /> <PackageReference Include="AAXClean.Codecs" Version="2.0.1.3" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+43 -12
View File
@@ -1,5 +1,6 @@
using AAXClean; using AAXClean;
using System; using System;
using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace AaxDecrypter namespace AaxDecrypter
@@ -8,7 +9,7 @@ namespace AaxDecrypter
{ {
public event EventHandler<AppleTags> RetrievedMetadata; public event EventHandler<AppleTags> RetrievedMetadata;
protected AaxFile AaxFile { get; private set; } public Mp4File AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; } protected Mp4Operation AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
@@ -24,19 +25,51 @@ namespace AaxDecrypter
public override async Task CancelAsync() public override async Task CancelAsync()
{ {
IsCanceled = true; await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask); await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload(); }
private Mp4File Open()
{
if (DownloadOptions.InputType is FileType.Dash)
{
//We may have multiple keys , so use the key whose key ID matches
//the dash files default Key ID.
var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
var dash = new DashFile(InputFileStream);
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
if (kidIndex == -1)
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex];
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1;
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2;
dash.SetDecryptionKey(keyId, key);
return dash;
}
else if (DownloadOptions.InputType is FileType.Aax)
{
var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
return aax;
}
else if (DownloadOptions.InputType is FileType.Aaxc)
{
var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
return aax;
}
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
} }
protected bool Step_GetMetadata() protected bool Step_GetMetadata()
{ {
AaxFile = new AaxFile(InputFileStream); AaxFile = Open();
if (DownloadOptions.AudibleKey?.Length == 8 && DownloadOptions.AudibleIV is null) RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey);
else
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
if (DownloadOptions.StripUnabridged) if (DownloadOptions.StripUnabridged)
{ {
@@ -83,12 +116,10 @@ namespace AaxDecrypter
OnInitialized(); OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged); OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]"); OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]"); OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
OnRetrievedCoverArt(AaxFile.AppleTags.Cover); OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
return !IsCanceled; return !IsCanceled;
} }
@@ -114,7 +114,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
DownloadOptions.LameConfig DownloadOptions.LameConfig
); );
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback) void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
{ {
MultiConvertFileProperties props = new() MultiConvertFileProperties props = new()
{ {
@@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
{ {
return Mp4File.RelocateMoovAsync(filename); return Mp4File.RelocateMoovAsync(filename);
} }
else return Mp4Operation.CompletedOperation; else return Mp4Operation.FromCompleted(AaxFile);
} }
} }
} }
+41 -11
View File
@@ -23,7 +23,7 @@ namespace AaxDecrypter
public bool IsCanceled { get; protected set; } public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new(); protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputFileName { get; } protected string OutputFileName { get; }
protected IDownloadOptions DownloadOptions { get; } public IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream; protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position; protected virtual long InputFilePosition => InputFileStream.Position;
private bool downloadFinished; private bool downloadFinished;
@@ -73,11 +73,16 @@ namespace AaxDecrypter
AsyncSteps[$"Cleanup"] = CleanupAsync; AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync(); (bool success, var elapsed) = await AsyncSteps.RunAsync();
//Stop the downloader so it doesn't keep running in the background.
if (!success)
nfsPersister.Dispose();
await progressTask; await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed; var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
nfsPersister.Dispose();
return success; return success;
async Task reportProgress() async Task reportProgress()
@@ -115,7 +120,12 @@ namespace AaxDecrypter
} }
} }
public abstract Task CancelAsync(); public virtual Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync(); protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt) { } public virtual void SetCoverArt(byte[] coverArt) { }
@@ -177,20 +187,39 @@ namespace AaxDecrypter
FileUtility.SaferDelete(jsonDownloadState); FileUtility.SaferDelete(jsonDownloadState);
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) && if (DownloadOptions.DecryptionKeys != null &&
DownloadOptions.RetainEncryptedFile) DownloadOptions.RetainEncryptedFile &&
DownloadOptions.InputType is AAXClean.FileType fileType)
{ {
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
FileUtility.SaferMove(tempFilePath, aaxPath);
//Write aax decryption key //Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key"); string keyPath = Path.ChangeExtension(tempFilePath, ".key");
FileUtility.SaferDelete(keyPath); FileUtility.SaferDelete(keyPath);
string aaxPath;
if (string.IsNullOrEmpty(DownloadOptions.AudibleIV)) if (fileType is AAXClean.FileType.Aax)
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={DownloadOptions.AudibleKey}"); {
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
}
else if (fileType is AAXClean.FileType.Aaxc)
{
await File.WriteAllTextAsync(keyPath,
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
}
else if (fileType is AAXClean.FileType.Dash)
{
await File.WriteAllTextAsync(keyPath,
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
}
else else
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}"); throw new InvalidOperationException($"Unknown file type: {fileType}");
if (tempFilePath != aaxPath)
FileUtility.SaferMove(tempFilePath, aaxPath);
OnFileCreated(aaxPath); OnFileCreated(aaxPath);
OnFileCreated(keyPath); OnFileCreated(keyPath);
@@ -217,6 +246,7 @@ namespace AaxDecrypter
} }
catch catch
{ {
nfsp?.Target?.Dispose();
FileUtility.SaferDelete(jsonDownloadState); FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(tempFilePath); FileUtility.SaferDelete(tempFilePath);
return nfsp = newNetworkFilePersister(); return nfsp = newNetworkFilePersister();
+2 -2
View File
@@ -105,7 +105,7 @@ public class AverageSpeed
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { } public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
/// <param name="slowWindow">Total moving average time window</param> /// <param name="slowWindow">Total moving average time window</param>
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param> /// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param> /// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param> /// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance) public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
@@ -119,7 +119,7 @@ public class AverageSpeed
/// <summary>Add a new position to the moving average</summary> /// <summary>Add a new position to the moving average</summary>
public void AddPosition(double position) public void AddPosition(double position)
{ {
var now = DateTime.Now; var now = DateTime.UtcNow;
if (start == default) if (start == default)
start = now; start = now;
+31 -10
View File
@@ -2,15 +2,35 @@
using System; using System;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
public interface IDownloadOptions public class KeyData
{
public byte[] KeyPart1 { get; }
public byte[]? KeyPart2 { get; }
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
{
KeyPart1 = keyPart1;
KeyPart2 = keyPart2;
}
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
KeyPart1 = Convert.FromHexString(keyPart1);
if (keyPart2 != null)
KeyPart2 = Convert.FromHexString(keyPart2);
}
}
public interface IDownloadOptions
{ {
event EventHandler<long> DownloadSpeedChanged; event EventHandler<long> DownloadSpeedChanged;
string DownloadUrl { get; } string DownloadUrl { get; }
string UserAgent { get; } string UserAgent { get; }
string AudibleKey { get; } KeyData[]? DecryptionKeys { get; }
string AudibleIV { get; }
TimeSpan RuntimeLength { get; } TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; } OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; } bool TrimOutputToChapterLength { get; }
@@ -21,19 +41,20 @@ namespace AaxDecrypter
long DownloadSpeedBps { get; } long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; } ChapterInfo ChapterInfo { get; }
bool FixupFile { get; } bool FixupFile { get; }
string AudibleProductId { get; } string? AudibleProductId { get; }
string Title { get; } string? Title { get; }
string Subtitle { get; } string? Subtitle { get; }
string Publisher { get; } string? Publisher { get; }
string Language { get; } string? Language { get; }
string SeriesName { get; } string? SeriesName { get; }
float? SeriesNumber { get; } float? SeriesNumber { get; }
NAudio.Lame.LameConfig LameConfig { get; } NAudio.Lame.LameConfig? LameConfig { get; }
bool Downsample { get; } bool Downsample { get; }
bool MatchSourceBitrate { get; } bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; } bool MoveMoovToBeginning { get; }
string GetMultipartFileName(MultiConvertFileProperties props); string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitle(MultiConvertFileProperties props); string GetMultipartTitle(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarksAsync(string fileName); Task<string> SaveClipsAndBookmarksAsync(string fileName);
public FileType? InputType { get; }
} }
} }
+98 -28
View File
@@ -55,18 +55,20 @@ namespace AaxDecrypter
private CancellationTokenSource _cancellationSource { get; } = new(); private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle _downloadedPiece { get; set; } private EventWaitHandle _downloadedPiece { get; set; }
private DateTime NextUpdateTime { get; set; }
#endregion #endregion
#region Constants #region Constants
//Download buffer size //Download memory buffer size
private const int DOWNLOAD_BUFF_SZ = 32 * 1024; private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
//NetworkFileStream will flush all data in _writeFile to disk after every //NetworkFileStream will flush all data in _writeFile to disk after every
//DATA_FLUSH_SZ bytes are written to the file stream. //DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024; private const int DATA_FLUSH_SZ = 1024 * 1024;
//Number of times per second the download rate is checkd and throttled //Number of times per second the download rate is checked and throttled
private const int THROTTLE_FREQUENCY = 8; private const int THROTTLE_FREQUENCY = 8;
//Minimum throttle rate. The minimum amount of data that can be throttled //Minimum throttle rate. The minimum amount of data that can be throttled
@@ -108,12 +110,18 @@ namespace AaxDecrypter
#region Downloader #region Downloader
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary> /// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
private void OnUpdate() private void OnUpdate(bool waitForWrite = false)
{ {
RequestHeaders["Range"] = $"bytes={WritePosition}-";
try try
{ {
Updated?.Invoke(this, EventArgs.Empty); if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
{
Updated?.Invoke(this, EventArgs.Empty);
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
//If an update is called less than 100 ms since the last update, persister will
//sleep the thread until 100 ms has elapsed.
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
}
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -135,7 +143,6 @@ namespace AaxDecrypter
throw new InvalidOperationException("Cannot change Uri after download has started."); throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile; Uri = uriToSameFile;
RequestHeaders["Range"] = $"bytes={WritePosition}-";
} }
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary> /// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
@@ -151,39 +158,103 @@ namespace AaxDecrypter
if (ContentLength != 0 && WritePosition > ContentLength) if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10})."); throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var request = new HttpRequestMessage(HttpMethod.Get, Uri); //Initiate connection with the first request block and
//get the total content length before returning.
var client = new HttpClient();
var response = await RequestNextByteRangeAsync(client);
if (ContentLength != 0 && ContentLength != response.FileSize)
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
ContentLength = response.FileSize;
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Hand off the client and the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
}
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
{
try
{
long startPosition = WritePosition;
while (WritePosition < ContentLength && !IsCancelled)
{
try
{
await DownloadToFile(blockResponse);
}
catch (HttpIOException e)
when (e.HttpRequestError is HttpRequestError.ResponseEnded
&& WritePosition != startPosition
&& WritePosition < ContentLength && !IsCancelled)
{
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
//the download made *some* progress since the last attempt.
//Try again to complete the download from where it left off.
//Make sure to rewind file to last flush position.
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
finally
{
_writeFile.Dispose();
blockResponse.Dispose();
client.Dispose();
}
}
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
{
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
//Just in case it snuck in the saved json (Issue #1232)
RequestHeaders.Remove("Range");
foreach (var header in RequestHeaders) foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value); request.Headers.Add(header.Key, header.Value);
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token); request.Headers.Add("Range", $"bytes={WritePosition}-");
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
if (response.StatusCode != HttpStatusCode.PartialContent) if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}."); throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
//Content length is the length of the range request, and it is only equal var totalSize = response.Content.Headers.ContentRange?.Length ??
//to the complete file length if requesting Range: bytes=0- throw new WebException("The response did not contain a total content length.");
if (WritePosition == 0)
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token); var rangeSize = response.Content.Headers.ContentLength ??
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset); throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
//Download the file in the background. return new BlockResponse(response, rangeSize, totalSize);
}
DownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token); private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
{
public void Dispose() => Response?.Dispose();
} }
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary> /// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
private async Task DownloadFile(Stream networkStream) private async Task DownloadToFile(BlockResponse block)
{ {
var endPosition = WritePosition + block.BlockSize;
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
var downloadPosition = WritePosition; var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ; var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ]; var buff = new byte[DOWNLOAD_BUFF_SZ];
try try
{ {
DateTime startTime = DateTime.Now; DateTime startTime = DateTime.UtcNow;
long bytesReadSinceThrottle = 0; long bytesReadSinceThrottle = 0;
int bytesRead; int bytesRead;
do do
@@ -208,24 +279,25 @@ namespace AaxDecrypter
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY) if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{ {
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds; var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
if (delayMS > 0) if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token); await Task.Delay(delayMS, _cancellationSource.Token);
startTime = DateTime.Now; startTime = DateTime.UtcNow;
bytesReadSinceThrottle = 0; bytesReadSinceThrottle = 0;
} }
#endregion #endregion
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0); } while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition; WritePosition = downloadPosition;
if (!IsCancelled && WritePosition < ContentLength) if (!IsCancelled && WritePosition < endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10})."); throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength) if (WritePosition > endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10})."); throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
} }
catch (TaskCanceledException) catch (TaskCanceledException)
@@ -234,10 +306,8 @@ namespace AaxDecrypter
} }
finally finally
{ {
networkStream.Close();
_writeFile.Close();
_downloadedPiece.Set(); _downloadedPiece.Set();
OnUpdate(); OnUpdate(waitForWrite: true);
} }
} }
@@ -334,7 +404,7 @@ namespace AaxDecrypter
_cancellationSource?.Dispose(); _cancellationSource?.Dispose();
_readFile.Dispose(); _readFile.Dispose();
_writeFile.Dispose(); _writeFile.Dispose();
OnUpdate(); OnUpdate(waitForWrite: true);
} }
disposed = true; disposed = true;
@@ -17,13 +17,6 @@ namespace AaxDecrypter
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync; AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
} }
public override Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync() protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{ {
await InputFileStream.DownloadTask; await InputFileStream.DownloadTask;
+4 -5
View File
@@ -2,15 +2,14 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net9.0</TargetFramework>
<Version>11.6.3.1</Version> <Version>12.4.8.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Octokit" Version="13.0.1" /> <PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks --> <!-- Do not remove unused Serilog.Sinks -->
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. --> <!-- Only File 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.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.File" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.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" />
+127 -31
View File
@@ -89,7 +89,9 @@ namespace AppScaffolding
Migrations.migrate_to_v6_6_9(config); Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_to_v11_5_0(config); Migrations.migrate_to_v11_5_0(config);
} Migrations.migrate_to_v11_6_5(config);
Migrations.migrate_to_v12_0_1(config);
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary> /// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Variety variety, Configuration config) public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
@@ -113,11 +115,22 @@ namespace AppScaffolding
{ {
if (config.GetObject("Serilog") is JObject serilog) if (config.GetObject("Serilog") is JObject serilog)
{ {
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink) bool fileChanged = false;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
{ {
fileSink["Name"] = "ZipFile"; zipFileSink["Name"] = "File";
config.SetNonString(serilog.DeepClone(), "Serilog"); fileChanged = true;
} }
var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}";
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
{
fileSinkArgs["hooks"] = hooks;
fileChanged = true;
}
if (fileChanged)
config.SetNonString(serilog.DeepClone(), "Serilog");
return; return;
} }
@@ -127,17 +140,17 @@ namespace AppScaffolding
{ "WriteTo", new JArray { "WriteTo", new JArray
{ {
// ABOUT SINKS // ABOUT SINKS
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. // Only File 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
{ {
{ "Name", "ZipFile" }, { "Name", "File" },
{ "Args", { "Args",
new JObject new JObject
{ {
// for this sink to work, a path must be provided. we override this below // for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") }, { "path", Path.Combine(config.LibationFiles, "Log.log") },
{ "rollingInterval", "Month" }, { "rollingInterval", "Month" },
// Serilog template formatting examples // Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -272,46 +285,40 @@ namespace AppScaffolding
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value), disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
}); });
if (InteropFactory.InteropFunctionsType is null) if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null"); Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
} }
private static void wireUpSystemEvents(Configuration configuration) private static void wireUpSystemEvents(Configuration configuration)
{ {
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex(); LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books); => SearchEngineCommands.FullReIndex(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
=> SearchEngineCommands.UpdateBooks(books);
} }
public static UpgradeProperties GetLatestRelease() public static UpgradeProperties GetLatestRelease()
{ {
// timed out // timed out
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10)); (var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null || zip is null) if (version is null || latest is null || zip is null)
return null;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return null;
// we're up to date
if (latestRelease <= BuildVersion)
return null; return null;
// we have an update // we have an update
var zipUrl = zip?.BrowserDownloadUrl; var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new Log.Logger.Information("Update available: {@DebugInfo}", new
{ {
latestRelease = latestRelease.ToString(), latestRelease = version.ToString(),
latest.HtmlUrl, latest.HtmlUrl,
zipUrl zipUrl
}); });
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body); return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body);
} }
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout) private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
{ {
try try
{ {
@@ -325,15 +332,23 @@ namespace AppScaffolding
{ {
Log.Logger.Error(aggEx, "Checking for new version too often"); Log.Logger.Error(aggEx, "Checking for new version too often");
} }
return (null, null); return (null, null, null);
} }
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease() private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
{ {
const string ownerAccount = "rmcrackan"; const string ownerAccount = "rmcrackan";
const string repoName = "Libation"; const string repoName = "Libation";
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName)); var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
//Ensure that latest release is greater than the current version
var latestVersionString = latestRelease.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
return (null, null, null);
//Download the release index //Download the release index
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json"); var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts)); var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
@@ -351,10 +366,7 @@ namespace AppScaffolding
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
} }
} }
@@ -413,7 +425,91 @@ namespace AppScaffolding
public List<string> Filters { get; set; } = new(); public List<string> Filters { get; set; } = new();
} }
public static void migrate_to_v11_5_0(Configuration config) public static void migrate_to_v12_0_1(Configuration config)
{
#nullable enable
//Migrate from version 1 file cache to the dictionary-based version 2 cache
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{
try
{
//FilePathCache loads the cache in its static constructor,
//so perform migration without using FilePathCache.CacheEntry
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
return;
Dictionary<string, JArray> cache = new();
//Convert to c# objects to speed up searching by ID inside the iterator
var allItems
= v1Cache
.Select(i => new
{
Id = i["Id"]?.Value<string>(),
Path = i["Path"]?["Path"]?.Value<string>()
}).Where(i => i.Id != null)
.ToArray();
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
{
//Use this opportunity to purge non-existent files and re-classify file types
//(due to *.aax files previously not being classified as FileType.AAXC)
var items = allItems
.Where(i => i.Id == id && File.Exists(i.Path))
.Select(i => new JObject
{
{ "Id", i.Id },
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) },
{ "Path", new JObject{ { "Path", i.Path } } }
})
.ToArray();
if (items.Length == 0)
continue;
cache[id] = new JArray(items);
}
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
var cacheFileText = cacheJson.ToString(Formatting.Indented);
void migrate()
{
File.WriteAllText(jsonFileV2, cacheFileText);
File.Delete(jsonFileV1);
}
try { migrate(); }
catch (IOException)
{
try { migrate(); }
catch (IOException)
{
migrate();
}
}
}
catch { /* eat */ }
}
#nullable restore
}
public static void migrate_to_v11_6_5(Configuration config)
{
//Settings migration for unsupported sample rates (#1116)
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
}
public static void migrate_to_v11_5_0(Configuration config)
{ {
// Read file, but convert old format to new (with Name field) as necessary. // Read file, but convert old format to new (with Name field) as necessary.
if (!File.Exists(QuickFilters.JsonFile)) if (!File.Exists(QuickFilters.JsonFile))
@@ -5,8 +5,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="NPOI" Version="2.7.2" /> <PackageReference Include="NPOI" Version="2.7.4" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+84 -46
View File
@@ -15,12 +15,13 @@ using Newtonsoft.Json.Linq;
using Serilog; using Serilog;
using static DtoImporterService.PerfLogger; using static DtoImporterService.PerfLogger;
#nullable enable
namespace ApplicationServices namespace ApplicationServices
{ {
public static class LibraryCommands public static class LibraryCommands
{ {
public static event EventHandler<int> ScanBegin; public static event EventHandler<int>? ScanBegin;
public static event EventHandler<int> ScanEnd; public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; } public static bool Scanning { get; private set; }
private static object _lock { get; } = new(); private static object _lock { get; } = new();
@@ -31,7 +32,7 @@ namespace ApplicationServices
ScanEnd += (_, __) => Scanning = false; ScanEnd += (_, __) => Scanning = false;
} }
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts) public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{ {
logRestart(); logRestart();
@@ -57,7 +58,7 @@ namespace ApplicationServices
try try
{ {
logTime($"pre {nameof(scanAccountsAsync)} all"); logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all"); logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count; var totalCount = libraryItems.Count;
@@ -100,7 +101,7 @@ namespace ApplicationServices
} }
#region FULL LIBRARY scan and import #region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts) public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
{ {
logRestart(); logRestart();
@@ -130,7 +131,7 @@ namespace ApplicationServices
| LibraryOptions.ResponseGroupOptions.IsFinished, | LibraryOptions.ResponseGroupOptions.IsFinished,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215 ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
}; };
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions); var importItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all"); logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count; var totalCount = importItems.Count;
@@ -222,7 +223,7 @@ namespace ApplicationServices
{ {
int qtyChanged = await Task.Run(() => SaveContext(context)); int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0) if (qtyChanged > 0)
await Task.Run(finalizeLibrarySizeChange); await Task.Run(() => finalizeLibrarySizeChange(context));
return qtyChanged; return qtyChanged;
} }
catch (Exception ex) catch (Exception ex)
@@ -232,13 +233,42 @@ namespace ApplicationServices
} }
} }
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions) private static LogArchiver? openLogArchive(string? archivePath)
{
if (string.IsNullOrWhiteSpace(archivePath))
return null;
try
{
return new LogArchiver(archivePath);
}
catch (System.IO.InvalidDataException)
{
try
{
Log.Logger.Warning($"Deleting corrupted {nameof(LogArchiver)} at {archivePath}");
FileUtility.SaferDelete(archivePath);
return new LogArchiver(archivePath);
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
{ {
var tasks = new List<Task<List<ImportItem>>>(); var tasks = new List<Task<List<ImportItem>>>();
await using LogArchiver archiver await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled() = Log.Logger.IsDebugEnabled()
? new LogArchiver(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip")) ? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
: default; : default;
archiver?.DeleteAllButNewestN(20); archiver?.DeleteAllButNewestN(20);
@@ -248,7 +278,7 @@ namespace ApplicationServices
try try
{ {
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll) // get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account); var apiExtended = await ApiExtended.CreateAsync(account);
// add scanAccountAsync as a TASK: do not await // add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver)); tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
@@ -266,13 +296,13 @@ namespace ApplicationServices
return importItems; return importItems;
} }
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver archiver) private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
{ {
ArgumentValidator.EnsureNotNull(account, nameof(account)); ArgumentValidator.EnsureNotNull(account, nameof(account));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{ {
Account = account?.MaskedLogEntry ?? "[null]" Account = account.MaskedLogEntry ?? "[null]"
}); });
logTime($"pre scanAccountAsync {account.AccountName}"); logTime($"pre scanAccountAsync {account.AccountName}");
@@ -294,7 +324,7 @@ namespace ApplicationServices
throw new AggregateException(ex.InnerExceptions); throw new AggregateException(ex.InnerExceptions);
} }
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception> exceptions = null) async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception>? exceptions = null)
{ {
if (archiver is not null) if (archiver is not null)
{ {
@@ -329,7 +359,7 @@ namespace ApplicationServices
// this is any changes at all to the database, not just new books // this is any changes at all to the database, not just new books
if (qtyChanges > 0) if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange()); await Task.Run(() => finalizeLibrarySizeChange(context));
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange"); logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount; return newCount;
@@ -369,16 +399,16 @@ namespace ApplicationServices
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges() // Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks) foreach (var lb in removeLibraryBooks)
{ {
lb.IsDeleted = true; lb.IsDeleted = true;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
} }
var qtyChanges = context.SaveChanges(); var qtyChanges = context.SaveChanges();
if (qtyChanges > 0) if (qtyChanges > 0)
finalizeLibrarySizeChange(); finalizeLibrarySizeChange(context);
return qtyChanges; return qtyChanges;
} }
@@ -398,16 +428,16 @@ namespace ApplicationServices
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges() // Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks) foreach (var lb in libraryBooks)
{ {
lb.IsDeleted = false; lb.IsDeleted = false;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
} }
var qtyChanges = context.SaveChanges(); var qtyChanges = context.SaveChanges();
if (qtyChanges > 0) if (qtyChanges > 0)
finalizeLibrarySizeChange(); finalizeLibrarySizeChange(context);
return qtyChanges; return qtyChanges;
} }
@@ -432,7 +462,7 @@ namespace ApplicationServices
var qtyChanges = context.SaveChanges(); var qtyChanges = context.SaveChanges();
if (qtyChanges > 0) if (qtyChanges > 0)
finalizeLibrarySizeChange(); finalizeLibrarySizeChange(context);
return qtyChanges; return qtyChanges;
} }
@@ -445,31 +475,35 @@ namespace ApplicationServices
#endregion #endregion
// call this whenever books are added or removed from library // call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null); private static void finalizeLibrarySizeChange(LibationContext context)
{
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
LibrarySizeChanged?.Invoke(null, library);
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary> /// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged; public static event EventHandler<List<LibraryBook>>? LibrarySizeChanged;
/// <summary> /// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted. /// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary> /// </summary>
public static event EventHandler<IEnumerable<LibraryBook>> BookUserDefinedItemCommitted; public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details #region Update book details
public static int UpdateUserDefinedItem( public static int UpdateUserDefinedItem(
this LibraryBook lb, this LibraryBook lb,
string tags = null, string? tags = null,
LiberatedStatus? bookStatus = null, LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null, LiberatedStatus? pdfStatus = null,
Rating rating = null) Rating? rating = null)
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating); => new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem( public static int UpdateUserDefinedItem(
this IEnumerable<LibraryBook> lb, this IEnumerable<LibraryBook> lb,
string tags = null, string? tags = null,
LiberatedStatus? bookStatus = null, LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null, LiberatedStatus? pdfStatus = null,
Rating rating = null) Rating? rating = null)
=> updateUserDefinedItem( => updateUserDefinedItem(
lb, lb,
udi => { udi => {
@@ -518,17 +552,19 @@ namespace ApplicationServices
if (libraryBooks is null || !libraryBooks.Any()) if (libraryBooks is null || !libraryBooks.Any())
return 0; return 0;
foreach (var book in libraryBooks)
action?.Invoke(book.Book.UserDefinedItem);
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges() // Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks) foreach (var book in libraryBooks)
{ {
context.Attach(book.Book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified; action?.Invoke(book.Book.UserDefinedItem);
context.Attach(book.Book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
} var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges(); var qtyChanges = context.SaveChanges();
if (qtyChanges > 0) if (qtyChanges > 0)
@@ -593,13 +629,15 @@ namespace ApplicationServices
return sb.ToString(); return sb.ToString();
} }
} }
public static LibraryStats GetCounts(IEnumerable<LibraryBook> libraryBooks = null)
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
{ {
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(); libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks var results = libraryBooks
.AsParallel() .AsParallel()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) }) .WithoutParents()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.ToList(); .ToList();
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated); var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
@@ -104,9 +104,6 @@ namespace ApplicationServices
[Name("Content Type")] [Name("Content Type")]
public string ContentType { get; set; } public string ContentType { get; set; }
[Name("Audio Format")]
public string AudioFormat { get; set; }
[Name("Language")] [Name("Language")]
public string Language { get; set; } public string Language { get; set; }
@@ -144,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.LowestCategoryNames().Any() ? a.Book.LowestCategoryNames().Aggregate((a, b) => $"{a}, {b}") : "", CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
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,
@@ -152,7 +149,6 @@ namespace ApplicationServices
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(), BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(), PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(), ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language, Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded, LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "", LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
@@ -228,7 +224,6 @@ namespace ApplicationServices
nameof(ExportDto.BookStatus), nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus), nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType), nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language), nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded), nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion), nameof(ExportDto.LastDownloadedVersion),
@@ -299,7 +294,6 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.BookStatus); row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus); row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType); row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language); row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue) if (dto.LastDownloaded.HasValue)
@@ -48,6 +48,8 @@ namespace ApplicationServices
} }
public static void FullReIndex() => performSafeCommand(fullReIndex); public static void FullReIndex() => performSafeCommand(fullReIndex);
public static void FullReIndex(List<LibraryBook> libraryBooks)
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e => internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
{ {
@@ -94,8 +96,11 @@ namespace ApplicationServices
private static void fullReIndex(SearchEngine engine) private static void fullReIndex(SearchEngine engine)
{ {
var library = DbContexts.GetLibrary_Flat_NoTracking(); var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library); fullReIndex(engine, library);
} }
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
=> engine.CreateNewIndex(libraryBooks);
#endregion #endregion
} }
} }
@@ -47,6 +47,22 @@ namespace AudibleUtilities
update_no_validate(); update_no_validate();
} }
} }
private string _cdm;
[JsonProperty]
public string Cdm
{
get => _cdm;
set
{
if (value is null)
return;
_cdm = value;
update_no_validate();
}
}
[JsonIgnore] [JsonIgnore]
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly(); public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
#endregion #endregion
+32 -36
View File
@@ -11,11 +11,13 @@ using Polly;
using Polly.Retry; using Polly.Retry;
using System.Threading; using System.Threading;
#nullable enable
namespace AudibleUtilities namespace AudibleUtilities
{ {
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary> /// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public class ApiExtended public class ApiExtended
{ {
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
public Api Api { get; private set; } public Api Api { get; private set; }
private const int MaxConcurrency = 10; private const int MaxConcurrency = 10;
@@ -24,52 +26,46 @@ namespace AudibleUtilities
private ApiExtended(Api api) => Api = api; private ApiExtended(Api api) => Api = api;
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary> /// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginChoiceEager,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(Account account) public static async Task<ApiExtended> CreateAsync(Account account)
{ {
ArgumentValidator.EnsureNotNull(account, nameof(account)); ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
Serilog.Log.Logger.Information("{@DebugInfo}", new try
{ {
AccountMaskedLogEntry = account.MaskedLogEntry Serilog.Log.Logger.Information("{@DebugInfo}", new
}); {
AccountMaskedLogEntry = account.MaskedLogEntry
});
return await CreateAsync(account.AccountId, account.Locale.Name); var api = await EzApiCreator.GetApiAsync(
} account.Locale,
AudibleApiStorage.AccountsSettingsFile,
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary> account.GetIdentityTokensJsonPath());
public static async Task<ApiExtended> CreateAsync(string username, string localeName) return new ApiExtended(api);
{ }
Serilog.Log.Logger.Information("{@DebugInfo}", new catch
{ {
Username = username.ToMask(), if (LoginChoiceFactory is null)
LocaleName = localeName, throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
});
var api = await EzApiCreator.GetApiAsync( Serilog.Log.Logger.Information("{@DebugInfo}", new
Localization.Get(localeName), {
LoginType = nameof(ILoginChoiceEager),
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = account.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
LoginChoiceFactory(account),
account.Locale,
AudibleApiStorage.AccountsSettingsFile, AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName)); account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
} return new ApiExtended(api);
}
}
private static AsyncRetryPolicy policy { get; } private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>() = Policy.Handle<Exception>()
+41 -2
View File
@@ -5,19 +5,58 @@ using Newtonsoft.Json;
namespace AudibleUtilities namespace AudibleUtilities
{ {
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
{
/// <summary>
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// The file path of the AccountsSettings.json file
/// </summary>
public string SettingsFilePath { get; }
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
: base(exception)
{
SettingsFilePath = path;
}
}
public static class AudibleApiStorage public static class AudibleApiStorage
{ {
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json"); public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
public static void EnsureAccountsSettingsFileExists() public static void EnsureAccountsSettingsFileExists()
{ {
// saves. BEWARE: this will overwrite an existing file // saves. BEWARE: this will overwrite an existing file
if (!File.Exists(AccountsSettingsFile)) if (!File.Exists(AccountsSettingsFile))
_ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile); {
//Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved
//are not fired. There's no need to fire those events on an empty AccountsSettings file.
var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings();
File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings));
}
} }
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary> /// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile); public static AccountsSettingsPersister GetAccountsSettingsPersister()
{
try
{
return new AccountsSettingsPersister(AccountsSettingsFile);
}
catch (Exception ex)
{
var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex);
LoadError?.Invoke(null, args);
if (args.Handled)
return GetAccountsSettingsPersister();
throw;
}
}
public static string GetIdentityTokensJsonPath(this Account account) public static string GetIdentityTokensJsonPath(this Account account)
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name); => GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
@@ -5,7 +5,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AudibleApi" Version="9.3.0.1" /> <PackageReference Include="AudibleApi" Version="9.4.1.1" />
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
@@ -20,4 +21,9 @@
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Compile Update="Widevine\Cdm.*.cs">
<DependentUpon>Cdm.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project> </Project>
+189
View File
@@ -0,0 +1,189 @@
using AudibleApi;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Net.Http;
using AudibleApi.Cryptography;
using Newtonsoft.Json.Linq;
using Dinah.Core.Net.Http;
using System.Text.Json.Nodes;
#nullable enable
namespace AudibleUtilities.Widevine;
public partial class Cdm
{
/// <summary>
/// Get a <see cref="Cdm"/> from <see cref="AccountsSettings"/> or from the API.
/// </summary>
/// <returns>A <see cref="Cdm"/> if successful, otherwise <see cref="null"/></returns>
public static async Task<Cdm?> GetCdmAsync()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
//Check if there are any Android accounts. If not, we can't use Widevine.
if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
return null;
if (!string.IsNullOrEmpty(persister.Target.Cdm))
{
try
{
var cdm = Convert.FromBase64String(persister.Target.Cdm);
return new Cdm(new Device(cdm));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings.");
persister.Target.Cdm = string.Empty;
//Clear the stored Cdm and try getting a fresh one from the server.
}
}
if (string.IsNullOrEmpty(persister.Target.Cdm))
{
using var client = new HttpClient();
if (await GetCdmUris(client) is not Uri[] uris)
return null;
//try to get a CDM file for any account that's registered as an android device.
//CDMs are not account-specific, so it doesn't matter which account we're successful with.
foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
{
try
{
var requestMessage = CreateApiRequest(account);
await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } });
//Try all CDM URIs until a CDM has been retrieved successfully
foreach (var uri in uris)
{
try
{
var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content);
if (!resp.IsSuccessStatusCode)
{
var message = await resp.Content.ReadAsStringAsync();
throw new ApiErrorException(uri, null, message);
}
var cdmBts = await resp.Content.ReadAsByteArrayAsync();
var device = new Device(cdmBts);
persister.Target.Cdm = Convert.ToBase64String(cdmBts);
return new Cdm(device);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri);
//try the next URI
}
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry);
//try the next Account
}
}
}
return null;
}
/// <summary>
/// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file.
/// </summary>
/// <returns>If successful, an array of URIs to try. Otherwise null</returns>
private static async Task<Uri[]?> GetCdmUris(HttpClient httpClient)
{
const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json";
try
{
var fileContents = await httpClient.GetStringAsync(CdmUrlListFile);
var releaseIndex = JObject.Parse(fileContents);
var urlArray = releaseIndex["CdmUrls"] as JArray;
if (urlArray is null)
throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents);
var uris = urlArray.Select(u => u.Value<string>()).OfType<string>().Select(u => new Uri(u)).ToArray();
if (uris.Length == 0)
throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents);
return uris;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error getting CDM URLs");
return null;
}
}
static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"];
//Ensure that the request can be made successfully before sending it to the API
//The API uses System.Text.Json, so perform test with same.
private static async Task TestApiRequest(HttpClient client, JsonObject input)
{
if (input["body"]?.GetValue<string>() is not string body
|| JsonNode.Parse(body) is not JsonNode bodyJson)
throw new Exception("Api request doesn't contain a body");
if (bodyJson?["Url"]?.GetValue<string>() is not string url
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new Exception("Api request doesn't contain a url");
if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower()))
throw new Exception($"Unknown Audible Api domain: {uri.Host}");
if (bodyJson?["Headers"] is not JsonObject headers)
throw new Exception($"Api request doesn't contain any headers");
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
Dictionary<string, string>? headersDict = null;
try
{
headersDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(headers);
}
catch (Exception ex)
{
throw new Exception("Failed to read Audible Api headers.", ex);
}
if (headersDict is null)
throw new Exception("Failed to read Audible Api headers.");
foreach (var kvp in headersDict)
request.Headers.Add(kvp.Key, kvp.Value);
using var resp = await client.SendAsync(request);
resp.EnsureSuccessStatusCode();
}
/// <summary>
/// Create a request body to send to the API
/// </summary>
/// <param name="account">An authenticated account</param>
private static JObject CreateApiRequest(Account account)
{
const string ACCOUNT_INFO_PATH = "/1.0/account/information";
var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH);
message.SignRequest(
DateTime.UtcNow,
account.IdentityTokens.AdpToken,
account.IdentityTokens.PrivateKey);
return new JObject
{
{ "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) },
{ "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) }
};
}
}
+300
View File
@@ -0,0 +1,300 @@
using Google.Protobuf;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
#nullable enable
namespace AudibleUtilities.Widevine;
public enum KeyType
{
/// <summary>
/// Exactly one key of this type must appear.
/// </summary>
Signing = 1,
/// <summary>
/// Content key.
/// </summary>
Content = 2,
/// <summary>
/// Key control block for license renewals. No key.
/// </summary>
KeyControl = 3,
/// <summary>
/// wrapped keys for auxiliary crypto operations.
/// </summary>
OperatorSession = 4,
/// <summary>
/// Entitlement keys.
/// </summary>
Entitlement = 5,
/// <summary>
/// Partner-specific content key.
/// </summary>
OemContent = 6,
}
public interface ISession : IDisposable
{
string? GetLicenseChallenge(MpegDash dash);
WidevineKey[] ParseLicense(string licenseMessage);
}
public class WidevineKey
{
public Guid Kid { get; }
public KeyType Type { get; }
public byte[] Key { get; }
internal WidevineKey(Guid kid, License.Types.KeyContainer.Types.KeyType type, byte[] key)
{
Kid = kid;
Type = (KeyType)type;
Key = key;
}
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
}
public partial class Cdm
{
public static Guid WidevineContentProtection { get; } = new("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
private const int MAX_NUM_OF_SESSIONS = 16;
internal Device Device { get; }
private ConcurrentDictionary<Guid, Session> Sessions { get; } = new(-1, MAX_NUM_OF_SESSIONS);
internal Cdm(Device device)
{
Device = device;
}
public ISession OpenSession()
{
if (Sessions.Count == MAX_NUM_OF_SESSIONS)
throw new Exception("Too Many Sessions");
var session = new Session(Sessions.Count + 1, this);
var ddd = Sessions.TryAdd(session.Id, session);
return session;
}
#region Session
internal class Session : ISession
{
public Guid Id { get; } = Guid.NewGuid();
private int SessionNumber { get; }
private Cdm Cdm { get; }
private byte[]? EncryptionContext { get; set; }
private byte[]? AuthenticationContext { get; set; }
public Session(int number, Cdm cdm)
{
SessionNumber = number;
Cdm = cdm;
}
private string GetRequestId()
=> $"{RandomUint():x8}00000000{Convert.ToHexString(BitConverter.GetBytes((long)SessionNumber)).ToLowerInvariant()}";
public void Dispose()
{
if (Cdm.Sessions.ContainsKey(Id))
Cdm.Sessions.TryRemove(Id, out var session);
}
public string? GetLicenseChallenge(MpegDash dash)
{
if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh))
return null;
var licRequest = new LicenseRequest
{
ClientId = Cdm.Device.ClientId,
ContentId = new()
{
WidevinePsshData = new()
{
LicenseType = LicenseType.Offline,
RequestId = ByteString.CopyFrom(GetRequestId(), Encoding.ASCII)
}
},
Type = LicenseRequest.Types.RequestType.New,
RequestTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
ProtocolVersion = ProtocolVersion.Version21,
KeyControlNonce = RandomUint()
};
licRequest.ContentId.WidevinePsshData.PsshData.Add(ByteString.CopyFrom(pssh.InitData));
var licRequestBts = licRequest.ToByteArray();
EncryptionContext = CreateContext("ENCRYPTION", 128, licRequestBts);
AuthenticationContext = CreateContext("AUTHENTICATION", 512, licRequestBts);
var signedMessage = new SignedMessage
{
Type = SignedMessage.Types.MessageType.LicenseRequest,
Msg = ByteString.CopyFrom(licRequestBts),
Signature = ByteString.CopyFrom(Cdm.Device.SignMessage(licRequestBts))
};
return Convert.ToBase64String(signedMessage.ToByteArray());
}
public WidevineKey[] ParseLicense(string licenseMessage)
{
if (EncryptionContext is null || AuthenticationContext is null)
throw new InvalidOperationException($"{nameof(GetLicenseChallenge)}() must be called before calling {nameof(ParseLicense)}()");
var signedMessage = SignedMessage.Parser.ParseFrom(Convert.FromBase64String(licenseMessage));
if (signedMessage.Type != SignedMessage.Types.MessageType.License)
throw new InvalidDataException("Invalid license");
var sessionKey = Cdm.Device.DecryptSessionKey(signedMessage.SessionKey.ToByteArray());
if (!VerifySignature(signedMessage, AuthenticationContext, sessionKey))
throw new InvalidDataException("Message signature is invalid");
var license = License.Parser.ParseFrom(signedMessage.Msg);
var keyToTheKeys = DeriveKey(sessionKey, EncryptionContext, 1);
return DecryptKeys(keyToTheKeys, license.Key);
}
private static WidevineKey[] DecryptKeys(byte[] keyToTheKeys, IList<License.Types.KeyContainer> licenseKeys)
{
using var aes = Aes.Create();
aes.Key = keyToTheKeys;
var keys = new WidevineKey[licenseKeys.Count];
for (int i = 0; i < licenseKeys.Count; i++)
{
var keyContainer = licenseKeys[i];
var keyBytes = aes.DecryptCbc(keyContainer.Key.ToByteArray(), keyContainer.Iv.ToByteArray(), PaddingMode.PKCS7);
var id = keyContainer.Id.ToByteArray();
if (id.Length > 16)
{
var tryB64 = new byte[id.Length * 3 / 4];
if (Convert.TryFromBase64String(Encoding.ASCII.GetString(id), tryB64, out int bytesWritten))
{
id = tryB64;
}
Array.Resize(ref id, 16);
}
else if (id.Length < 16)
{
id = id.Append(new byte[16 - id.Length]);
}
keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes);
}
return keys;
}
private static bool VerifySignature(SignedMessage signedMessage, byte[] authContext, byte[] sessionKey)
{
var mac_key_server = DeriveKey(sessionKey, authContext, 1).Append(DeriveKey(sessionKey, authContext, 2));
var hmacData = (signedMessage.OemcryptoCoreMessage?.ToByteArray() ?? []).Append(signedMessage.Msg?.ToByteArray() ?? []);
var computed_signature = HMACSHA256.HashData(mac_key_server, hmacData);
return computed_signature.SequenceEqual(signedMessage.Signature);
}
private static byte[] DeriveKey(byte[] session_key, byte[] context, int counter)
{
var data = new byte[context.Length + 1];
Array.Copy(context, 0, data, 1, context.Length);
data[0] = (byte)counter;
return AESCMAC(session_key, data);
}
private static byte[] AESCMAC(byte[] key, byte[] data)
{
using var aes = Aes.Create();
aes.Key = key;
// SubKey generation
// step 1, AES-128 with key K is applied to an all-zero input block.
byte[] subKey = aes.EncryptCbc(new byte[16], new byte[16], PaddingMode.None);
nextSubKey();
// MAC computing
if ((data.Length == 0) || (data.Length % 16 != 0))
{
// If the size of the input message block is not equal to a positive
// multiple of the block size (namely, 128 bits), the last block shall
// be padded with 10^i
nextSubKey();
var padLen = 16 - data.Length % 16;
Array.Resize(ref data, data.Length + padLen);
data[^padLen] = 0x80;
}
// the last block shall be exclusive-OR'ed with K1 before processing
for (int j = 0; j < subKey.Length; j++)
data[data.Length - 16 + j] ^= subKey[j];
// The result of the previous process will be the input of the last encryption.
byte[] encResult = aes.EncryptCbc(data, new byte[16], PaddingMode.None);
byte[] HashValue = new byte[16];
Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length);
return HashValue;
void nextSubKey()
{
const byte const_Rb = 0x87;
if (Rol(subKey) != 0)
subKey[15] ^= const_Rb;
static int Rol(byte[] b)
{
int carry = 0;
for (int i = b.Length - 1; i >= 0; i--)
{
ushort u = (ushort)(b[i] << 1);
b[i] = (byte)((u & 0xff) + carry);
carry = (u & 0xff00) >> 8;
}
return carry;
}
}
}
private static byte[] CreateContext(string label, int keySize, byte[] licRequestBts)
{
var contextSize = label.Length + 1 + licRequestBts.Length + sizeof(int);
var context = new byte[contextSize];
var numChars = Encoding.ASCII.GetBytes(label.AsSpan(), context);
Array.Copy(licRequestBts, 0, context, numChars + 1, licRequestBts.Length);
var numBts = BitConverter.GetBytes(keySize);
if (BitConverter.IsLittleEndian)
Array.Reverse(numBts);
Array.Copy(numBts, 0, context, context.Length - sizeof(int), sizeof(int));
return context;
}
private static uint RandomUint()
{
var bts = new byte[4];
new Random().NextBytes(bts);
return BitConverter.ToUInt32(bts, 0);
}
}
#endregion
}
@@ -0,0 +1,73 @@
using System;
using System.IO;
using System.Security.Cryptography;
#nullable enable
namespace AudibleUtilities.Widevine;
internal enum DeviceTypes : byte
{
Unknown = 0,
Chrome = 1,
Android = 2
}
internal class Device
{
public DeviceTypes Type { get; }
public int FileVersion { get; }
public int SecurityLevel { get; }
public int Flags { get; }
public RSA CdmKey { get; }
internal ClientIdentification ClientId { get; }
public Device(Span<byte> fileData)
{
if (fileData.Length < 7 || fileData[0] != 'W' || fileData[1] != 'V' || fileData[2] != 'D')
throw new InvalidDataException();
FileVersion = fileData[3];
Type = (DeviceTypes)fileData[4];
SecurityLevel = fileData[5];
Flags = fileData[6];
if (FileVersion != 2)
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
if (Type != DeviceTypes.Android)
throw new InvalidDataException($"Unknown CDM Type: '{Type}'");
if (SecurityLevel != 3)
throw new InvalidDataException($"Unknown CDM Security Level: '{SecurityLevel}'");
var privateKeyLength = (fileData[7] << 8) | fileData[8];
if (privateKeyLength <= 0 || fileData.Length < 9 + privateKeyLength + 2)
throw new InvalidDataException($"Invalid private key length: '{privateKeyLength}'");
var clientIdLength = (fileData[9 + privateKeyLength] << 8) | fileData[10 + privateKeyLength];
if (clientIdLength <= 0 || fileData.Length < 11 + privateKeyLength + clientIdLength)
throw new InvalidDataException($"Invalid client id length: '{clientIdLength}'");
ClientId = ClientIdentification.Parser.ParseFrom(fileData.Slice(11 + privateKeyLength));
CdmKey = RSA.Create();
CdmKey.ImportRSAPrivateKey(fileData.Slice(9, privateKeyLength), out _);
}
public byte[] SignMessage(byte[] message)
{
using var sha1 = SHA1.Create();
var digestion = sha1.ComputeHash(message);
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
}
public bool VerifyMessage(byte[] message, byte[] signature)
{
using var sha1 = SHA1.Create();
var digestion = sha1.ComputeHash(message);
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
}
public byte[] DecryptSessionKey(byte[] sessionKey)
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
}
@@ -0,0 +1,15 @@
using System;
#nullable enable
namespace AudibleUtilities.Widevine;
internal static class Extensions
{
public static T[] Append<T>(this T[] message, T[] appendData)
{
var origLength = message.Length;
Array.Resize(ref message, origLength + appendData.Length);
Array.Copy(appendData, 0, message, origLength, appendData.Length);
return message;
}
}
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,70 @@
using Mpeg4Lib.Boxes;
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
#nullable enable
namespace AudibleUtilities.Widevine;
public class MpegDash
{
private const string MpegDashNamespace = "urn:mpeg:dash:schema:mpd:2011";
private const string CencNamespace = "urn:mpeg:cenc:2013";
private const string UuidPreamble = "urn:uuid:";
private XElement DashMpd { get; }
private static XmlNamespaceManager NamespaceManager { get; } = new(new NameTable());
static MpegDash()
{
NamespaceManager.AddNamespace("dash", MpegDashNamespace);
NamespaceManager.AddNamespace("cenc", CencNamespace);
}
public MpegDash(Stream contents)
{
DashMpd = XElement.Load(contents);
}
public bool TryGetUri(Uri baseUri, [NotNullWhen(true)] out Uri? fileUri)
{
foreach (var baseUrl in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:Representation/dash:BaseURL", NamespaceManager))
{
try
{
fileUri = new Uri(baseUri, baseUrl.Value);
return true;
}
catch
{
fileUri = null;
return false;
}
}
fileUri = null;
return false;
}
public bool TryGetPssh(Guid protectionSystemId, [NotNullWhen(true)] out PsshBox? pssh)
{
foreach (var psshEle in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:ContentProtection/cenc:pssh", NamespaceManager))
{
if (psshEle?.Value?.Trim() is string psshStr
&& psshEle.Parent?.Attribute(XName.Get("schemeIdUri")) is XAttribute scheme
&& scheme.Value is string uuid
&& uuid.Equals(UuidPreamble + protectionSystemId.ToString(), StringComparison.OrdinalIgnoreCase))
{
Span<byte> buffer = new byte[psshStr.Length * 3 / 4];
if (Convert.TryFromBase64String(psshStr, buffer, out var written))
{
using var ms = new MemoryStream(buffer.Slice(0, written).ToArray());
pssh = BoxFactory.CreateBox(ms, null) as PsshBox;
return pssh is not null;
}
}
}
pssh = null;
return false;
}
}
@@ -19,7 +19,6 @@ namespace DataLayer.Configurations
// //
entity.Ignore(nameof(Book.Authors)); entity.Ignore(nameof(Book.Authors));
entity.Ignore(nameof(Book.Narrators)); entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
entity.Ignore(nameof(Book.TitleWithSubtitle)); entity.Ignore(nameof(Book.TitleWithSubtitle));
entity.Ignore(b => b.Categories); entity.Ignore(b => b.Categories);
+4 -4
View File
@@ -10,14 +10,14 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" /> <PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" /> <PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.7">
<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="9.0.0" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.0"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.7">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
-65
View File
@@ -1,65 +0,0 @@
using System;
namespace DataLayer
{
internal enum AudioFormatEnum : long
{
//Defining the enum this way ensures that when comparing:
//LC_128_44100_stereo > LC_64_44100_stereo > LC_64_22050_stereo > LC_64_22050_stereo
//This matches how audible interprets these codecs when specifying quality using AudibleApi.DownloadQuality
//I've never seen mono formats.
Unknown = 0,
LC_32_22050_stereo = (32L << 18) | (22050 << 2) | 2,
LC_64_22050_stereo = (64L << 18) | (22050 << 2) | 2,
LC_64_44100_stereo = (64L << 18) | (44100 << 2) | 2,
LC_128_44100_stereo = (128L << 18) | (44100 << 2) | 2,
AAX_22_32 = LC_32_22050_stereo,
AAX_22_64 = LC_64_22050_stereo,
AAX_44_64 = LC_64_44100_stereo,
AAX_44_128 = LC_128_44100_stereo
}
public class AudioFormat : IComparable<AudioFormat>, IComparable
{
internal int AudioFormatID { get; private set; }
public int Bitrate { get; private init; }
public int SampleRate { get; private init; }
public int Channels { get; private init; }
public bool IsValid => Bitrate != 0 && SampleRate != 0 && Channels != 0;
public static AudioFormat FromString(string formatStr)
{
if (Enum.TryParse(formatStr, ignoreCase: true, out AudioFormatEnum enumVal))
return FromEnum(enumVal);
return FromEnum(AudioFormatEnum.Unknown);
}
internal static AudioFormat FromEnum(AudioFormatEnum enumVal)
{
var val = (long)enumVal;
return new()
{
Bitrate = (int)(val >> 18),
SampleRate = (int)(val >> 2) & ushort.MaxValue,
Channels = (int)(val & 3)
};
}
internal AudioFormatEnum ToEnum()
{
var val = (AudioFormatEnum)(((long)Bitrate << 18) | ((long)SampleRate << 2) | (long)Channels);
return Enum.IsDefined(val) ?
val : AudioFormatEnum.Unknown;
}
public override string ToString()
=> IsValid ?
$"{Bitrate} Kbps, {SampleRate / 1000d:F1} kHz, {(Channels == 2 ? "Stereo" : Channels)}" :
"Unknown";
public int CompareTo(AudioFormat other) => ToEnum().CompareTo(other.ToEnum());
public int CompareTo(object obj) => CompareTo(obj as AudioFormat);
}
}
+5 -3
View File
@@ -43,9 +43,11 @@ namespace DataLayer
public ContentType ContentType { get; private set; } public ContentType ContentType { get; private set; }
public string Locale { get; private set; } public string Locale { get; private set; }
internal AudioFormatEnum _audioFormat; //This field is now unused, however, there is little sense in adding a
//database migration to remove an unused field. Leave it for compatibility.
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); } #pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0
internal long _audioFormat;
#pragma warning restore CS0649
// mutable // mutable
public string PictureId { get; set; } public string PictureId { get; set; }
@@ -43,5 +43,7 @@ namespace DataLayer
} }
public override string ToString() => Name; public override string ToString() => Name;
public void SetAudibleContributorId(string audibleContributorId)
=> AudibleContributorId = audibleContributorId;
} }
} }
+2 -1
View File
@@ -6,6 +6,7 @@ namespace DataLayer
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext> public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
{ {
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options); protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString); protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
} }
} }
+5 -1
View File
@@ -44,7 +44,11 @@ namespace DataLayer
public static bool IsEpisodeParent(this Book book) public static bool IsEpisodeParent(this Book book)
=> book.ContentType is ContentType.Parent; => book.ContentType is ContentType.Parent;
public static bool HasLiberated(this Book book)
public static IEnumerable<LibraryBook> WithoutParents(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => !lb.Book.IsEpisodeParent());
public static bool HasLiberated(this Book book)
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated || => book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated; book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
} }
@@ -21,8 +21,8 @@ namespace DataLayer
.AsNoTrackingWithIdentityResolution() .AsNoTrackingWithIdentityResolution()
.GetLibrary() .GetLibrary()
.AsEnumerable() .AsEnumerable()
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents) .Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList(); .ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context => context
@@ -91,7 +91,7 @@ namespace DataLayer
} }
#nullable disable #nullable disable
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent) public static List<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList => bookList
.Where( .Where(
lb => lb =>
@@ -103,13 +103,11 @@ namespace DataLayer
) == true ) == true
).ToList(); ).ToList();
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList) public static bool NeedsPdfDownload(this LibraryBook libraryBook)
=> bookList => !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated;
.Where( public static bool NeedsBookDownload(this LibraryBook libraryBook)
lb => => !libraryBook.AbsentFromLastScan && libraryBook.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload;
!lb.AbsentFromLastScan && public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
(lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload => bookList.Where(lb => lb.NeedsPdfDownload() || lb.NeedsBookDownload());
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload)
);
} }
} }
@@ -154,9 +154,6 @@ namespace DtoImporterService
// 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);
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
book.AudioFormat = codec;
// set/update book-specific info which may have changed // set/update book-specific info which may have changed
if (item.PictureId is not null) if (item.PictureId is not null)
book.PictureId = item.PictureId; book.PictureId = item.PictureId;
@@ -61,19 +61,19 @@ namespace DtoImporterService
private int upsertPeople(List<Person> people) private int upsertPeople(List<Person> people)
{ {
var hash = people var qtyNew = 0;
// new people only foreach (var person in people)
.Where(p => !Cache.ContainsKey(p.Name))
// remove duplicates by Name. first in wins
.ToDictionarySafe(p => p.Name);
foreach (var kvp in hash)
{ {
var person = kvp.Value; if (!Cache.TryGetValue(person.Name, out var contributor))
addContributor(person.Name, person.Asin); {
contributor = createContributor(person.Name, person.Asin);
qtyNew++;
}
updateContributor(person, contributor);
} }
return hash.Count; return qtyNew;
} }
// only use after loading contributors => local // only use after loading contributors => local
@@ -86,16 +86,22 @@ namespace DtoImporterService
.ToHashSet(); .ToHashSet();
foreach (var pub in hash) foreach (var pub in hash)
addContributor(pub); createContributor(pub);
return hash.Count; return hash.Count;
} }
private Contributor addContributor(string name, string id = null) private void updateContributor(Person person, Contributor contributor)
{
if (person.Asin != contributor.AudibleContributorId)
contributor.SetAudibleContributorId(person.Asin);
}
private Contributor createContributor(string name, string id = null)
{ {
try try
{ {
var newContrib = new Contributor(name); var newContrib = new Contributor(name, id);
var entityEntry = DbContext.Contributors.Add(newContrib); var entityEntry = DbContext.Contributors.Add(newContrib);
var entity = entityEntry.Entity; var entity = entityEntry.Entity;
@@ -62,7 +62,7 @@ namespace DtoImporterService
existing.SetAccount(item.AccountId); existing.SetAccount(item.AccountId);
} }
existing.AbsentFromLastScan = isPlusTitleUnavailable(item); existing.AbsentFromLastScan = isUnavailable(item);
} }
else else
{ {
@@ -71,7 +71,7 @@ namespace DtoImporterService
item.DtoItem.DateAdded, item.DtoItem.DateAdded,
item.AccountId) item.AccountId)
{ {
AbsentFromLastScan = isPlusTitleUnavailable(item) AbsentFromLastScan = isUnavailable(item)
}; };
try try
@@ -113,7 +113,13 @@ namespace DtoImporterService
} }
private static ImportItem tieBreak(ImportItem item1, ImportItem item2) private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isPlusTitleUnavailable(item1) && !isPlusTitleUnavailable(item2) ? item2 : item1; => isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item) private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null => item.DtoItem.ContentType is null
+6 -31
View File
@@ -15,53 +15,28 @@ namespace FileLiberator
public event EventHandler<byte[]> CoverImageDiscovered; public event EventHandler<byte[]> CoverImageDiscovered;
public abstract Task CancelAsync(); public abstract Task CancelAsync();
protected LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new()
{
Mode = MPEGMode.Mono,
Quality = config.LameEncoderQuality,
OutputSampleRate = (int)config.MaxSampleRate
};
if (config.LameTargetBitrate)
{
if (config.LameConstantBitrate)
lameConfig.BitRate = config.LameBitrate;
else
{
lameConfig.ABRRateKbps = config.LameBitrate;
lameConfig.VBR = VBRMode.ABR;
lameConfig.WriteVBRTag = true;
}
}
else
{
lameConfig.VBR = VBRMode.Default;
lameConfig.VBRQuality = config.LameVBRQuality;
lameConfig.WriteVBRTag = true;
}
return lameConfig;
}
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title); protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
protected void OnTitleDiscovered(object _, string title) protected void OnTitleDiscovered(object _, string title)
{ {
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title }); Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
TitleDiscovered?.Invoke(this, title); if (title != null)
TitleDiscovered?.Invoke(this, title);
} }
protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors); protected void OnAuthorsDiscovered(string authors) => OnAuthorsDiscovered(null, authors);
protected void OnAuthorsDiscovered(object _, string authors) protected void OnAuthorsDiscovered(object _, string authors)
{ {
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors }); Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
AuthorsDiscovered?.Invoke(this, authors); if (authors != null)
AuthorsDiscovered?.Invoke(this, authors);
} }
protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators); protected void OnNarratorsDiscovered(string narrators) => OnNarratorsDiscovered(null, narrators);
protected void OnNarratorsDiscovered(object _, string narrators) protected void OnNarratorsDiscovered(object _, string narrators)
{ {
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators }); Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
NarratorsDiscovered?.Invoke(this, narrators); if (narrators != null)
NarratorsDiscovered?.Invoke(this, narrators);
} }
protected byte[] OnRequestCoverArt() protected byte[] OnRequestCoverArt()
+9 -2
View File
@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using DataLayer; using DataLayer;
using LibationFileManager; using LibationFileManager;
using LibationFileManager.Templates;
namespace FileLiberator namespace FileLiberator
{ {
@@ -38,14 +39,20 @@ namespace FileLiberator
/// Path: in progress directory. /// Path: in progress directory.
/// File name: final file name. /// File name: final file name.
/// </summary> /// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true); => Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
/// <summary> /// <summary>
/// PDF: audio file does not exist /// PDF: audio file does not exist
/// </summary> /// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension) public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension); => Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
/// <summary> /// <summary>
/// PDF: audio file already exists /// PDF: audio file already exists
+5 -1
View File
@@ -44,13 +44,17 @@ namespace FileLiberator
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read)); var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
continue;
OnTitleDiscovered(m4bBook.AppleTags.Title); OnTitleDiscovered(m4bBook.AppleTags.Title);
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor); OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator); OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
OnCoverImageDiscovered(m4bBook.AppleTags.Cover); OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
var config = Configuration.Instance; var config = Configuration.Instance;
var lameConfig = GetLameOptions(config); var lameConfig = DownloadOptions.GetLameOptions(config);
var chapters = m4bBook.GetChaptersFromMetadata(); var chapters = m4bBook.GetChaptersFromMetadata();
//Finishing configuring lame encoder. //Finishing configuring lame encoder.
AaxDecrypter.MpegUtil.ConfigureLameOptions( AaxDecrypter.MpegUtil.ConfigureLameOptions(
+106 -232
View File
@@ -2,6 +2,7 @@
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Threading;
using System.Threading.Tasks; using System.Threading.Tasks;
using AaxDecrypter; using AaxDecrypter;
using ApplicationServices; using ApplicationServices;
@@ -18,10 +19,15 @@ namespace FileLiberator
{ {
public override string Name => "Download & Decrypt"; public override string Name => "Download & Decrypt";
private AudiobookDownloadBase abDownloader; private AudiobookDownloadBase abDownloader;
private readonly CancellationTokenSource cancellationTokenSource = new();
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override async Task CancelAsync()
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask; {
cancellationTokenSource.Cancel();
if (abDownloader is not null)
await abDownloader.CancelAsync();
}
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{ {
@@ -41,19 +47,25 @@ namespace FileLiberator
} }
OnBegin(libraryBook); OnBegin(libraryBook);
var cancellationToken = cancellationTokenSource.Token;
try try
{ {
if (libraryBook.Book.Audio_Exists()) if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var config = Configuration.Instance;
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken);
bool success = false; bool success = false;
try try
{ {
FilePathCache.Inserted += FilePathCache_Inserted; FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed; FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook); success = await downloadAudiobookAsync(api, config, downloadOptions);
} }
finally finally
{ {
@@ -69,38 +81,32 @@ namespace FileLiberator
.Where(f => f.FileType != FileType.AAXC) .Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path)))); .Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
return cancellationToken.ThrowIfCancellationRequested();
abDownloader?.IsCanceled is true return new StatusHandler { "Decrypt failed" };
? new StatusHandler { "Cancelled" }
: new StatusHandler { "Decrypt failed" };
} }
var finalStorageDir = getDestinationDirectory(libraryBook); var finalStorageDir = getDestinationDirectory(libraryBook);
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries)); var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
Task[] finalTasks = new[] Task[] finalTasks =
{ [
Task.Run(() => downloadCoverArt(libraryBook)), Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
moveFilesTask, moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir)) Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
}; ];
try try
{ {
await Task.WhenAll(finalTasks); await Task.WhenAll(finalTasks);
} }
catch catch when (!moveFilesTask.IsFaulted)
{ {
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions. //Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
//Only fail if the downloaded audio files failed to move to Books directory //Only fail if the downloaded audio files failed to move to Books directory
if (moveFilesTask.IsFaulted) }
{ finally
throw;
}
}
finally
{ {
if (moveFilesTask.IsCompletedSuccessfully) if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{ {
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion)); await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
@@ -109,28 +115,23 @@ namespace FileLiberator
} }
return new StatusHandler(); return new StatusHandler();
} }
finally catch when (cancellationToken.IsCancellationRequested)
{
return new StatusHandler { "Cancelled" };
}
finally
{ {
OnCompleted(libraryBook); OnCompleted(libraryBook);
} }
} }
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook) private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
{ {
var config = Configuration.Instance; var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
downloadValidation(libraryBook);
var quality = (AudibleApi.DownloadQuality)config.FileDownloadQuality;
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, quality);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory; var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != DrmType.Adrm) if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions); abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
else else
{ {
@@ -140,7 +141,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.LowestCategoryNames()); converter.RetrievedMetadata += Converter_RetrievedMetadata;
abDownloader = converter; abDownloader = converter;
} }
@@ -151,201 +152,70 @@ namespace FileLiberator
abDownloader.RetrievedAuthors += OnAuthorsDiscovered; abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered; abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path); abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
// REAL WORK DONE HERE // REAL WORK DONE HERE
var success = await abDownloader.RunAsync(); var success = await abDownloader.RunAsync();
if (success && config.SaveMetadataToFile) if (success && config.SaveMetadataToFile)
{ {
var metadataFile = Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json"); var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(libraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); var item = await api.GetCatalogProductAsync(dlOptions.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.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(contentLic.ContentMetadata.ContentReference)); item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString()); File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(libraryBook, metadataFile); OnFileCreated(dlOptions.LibraryBook, metadataFile);
} }
return success; return success;
} }
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, ContentLicense contentLic) private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
{ {
//If DrmType != Adrm the delivered file is an unencrypted mp3. if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
return;
var outputFormat #region Prevent erroneous truncation due to incorrect chapter info
= contentLic.DrmType != DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
? OutputFormat.Mp3
: OutputFormat.M4b;
long chapterStartMs //Sometimes the chapter info is not accurate. Since AAXClean trims audio
= config.StripAudibleBrandAudio //files to the chapters start and end, if the last chapter's end time is
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs //before the end of the audio file, the file will be truncated to match
: 0; //the chapter. This is never desirable, so pad the last chapter to match
//the original audio length.
//Set the requested AudioFormat for use in file naming templates var fileDuration = converter.AaxFile.Duration;
libraryBook.Book.AudioFormat = AudioFormat.FromString(contentLic.ContentMetadata.ContentReference.ContentFormat); if (options.Config.StripAudibleBrandAudio)
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl) var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
//Remove the last chapter and re-add it with the durationDelta that will
//make the chapter's end coincide with the end of the audio file.
var chapters = options.ChapterInfo.Chapters as List<AAXClean.Chapter>;
var lastChapter = chapters[^1];
chapters.Remove(lastChapter);
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
#endregion
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
tags.Album ??= tags.Title;
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
tags.AlbumArtists ??= tags.Artist;
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
tags.Comment ??= options.LibraryBook.Book.Description;
tags.LongDescription ??= tags.Comment;
tags.Publisher ??= options.LibraryBook.Book.Publisher;
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
tags.Asin = options.LibraryBook.Book.AudibleProductId;
tags.Acr = options.ContentMetadata.ContentReference.Acr;
tags.Version = options.ContentMetadata.ContentReference.Version;
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
{ {
AudibleKey = contentLic?.Voucher?.Key, tags.Year ??= pubDate.Year.ToString();
AudibleIV = contentLic?.Voucher?.Iv, tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
OutputFormat = outputFormat,
LameConfig = GetLameOptions(config),
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
};
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs)
.ToList();
if (config.MergeOpeningAndEndCredits)
combineCredits(chapters);
for (int i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= chapterStartMs;
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
return dlOptions;
}
/*
Flatten Audible's new hierarchical chapters, combining children into parents.
Audible may deliver chapters like this:
00:00 - 00:10 Opening Credits
00:10 - 00:12 Book 1
00:12 - 00:14 | Part 1
00:14 - 01:40 | | Chapter 1
01:40 - 03:20 | | Chapter 2
03:20 - 03:22 | Part 2
03:22 - 05:00 | | Chapter 3
05:00 - 06:40 | | Chapter 4
06:40 - 06:42 Book 2
06:42 - 06:44 | Part 3
06:44 - 08:20 | | Chapter 5
08:20 - 10:00 | | Chapter 6
10:00 - 10:02 | Part 4
10:02 - 11:40 | | Chapter 7
11:40 - 13:20 | | Chapter 8
13:20 - 13:30 End Credits
And flattenChapters will combine them into this:
00:00 - 00:10 Opening Credits
00:10 - 01:40 Book 1: Part 1: Chapter 1
01:40 - 03:20 Book 1: Part 1: Chapter 2
03:20 - 05:00 Book 1: Part 2: Chapter 3
05:00 - 06:40 Book 1: Part 2: Chapter 4
06:40 - 08:20 Book 2: Part 3: Chapter 5
08:20 - 10:00 Book 2: Part 3: Chapter 6
10:00 - 11:40 Book 2: Part 4: Chapter 7
11:40 - 13:20 Book 2: Part 4: Chapter 8
13:20 - 13:40 End Credits
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
chapter. A duration longer than a few seconds implies that the chapter contains more than just
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
00:00 - 00:10 Opening Credits
00:10 - 00:25 Book 1
00:25 - 00:27 | Part 1
00:27 - 01:40 | | Chapter 1
01:40 - 03:20 | | Chapter 2
03:20 - 03:22 | Part 2
03:22 - 05:00 | | Chapter 3
05:00 - 06:40 | | Chapter 4
06:40 - 06:42 Book 2
06:42 - 07:02 | Part 3
07:02 - 08:20 | | Chapter 5
08:20 - 10:00 | | Chapter 6
10:00 - 10:02 | Part 4
10:02 - 11:40 | | Chapter 7
11:40 - 13:20 | | Chapter 8
13:20 - 13:30 End Credits
then flattenChapters will combine them into this:
00:00 - 00:10 Opening Credits
00:10 - 00:25 Book 1
00:25 - 01:40 Book 1: Part 1: Chapter 1
01:40 - 03:20 Book 1: Part 1: Chapter 2
03:20 - 05:00 Book 1: Part 2: Chapter 3
05:00 - 06:40 Book 1: Part 2: Chapter 4
06:40 - 07:02 Book 2: Part 3
07:02 - 08:20 Book 2: Part 3: Chapter 5
08:20 - 10:00 Book 2: Part 3: Chapter 6
10:00 - 11:40 Book 2: Part 4: Chapter 7
11:40 - 13:20 Book 2: Part 4: Chapter 8
13:20 - 13:40 End Credits
*/
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string titleConcat = ": ")
{
List<Chapter> chaps = new();
foreach (var c in chapters)
{
if (c.Chapters is null)
chaps.Add(c);
else if (titleConcat is null)
{
chaps.Add(c);
chaps.AddRange(flattenChapters(c.Chapters));
}
else
{
if (c.LengthMs < 10000)
{
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
c.Chapters[0].LengthMs += c.LengthMs;
}
else
chaps.Add(c);
var children = flattenChapters(c.Chapters);
foreach (var child in children)
child.Title = $"{c.Title}{titleConcat}{child.Title}";
chaps.AddRange(children);
}
}
return chaps;
}
public static void combineCredits(IList<Chapter> chapters)
{
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
{
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
chapters[1].LengthMs += chapters[0].LengthMs;
chapters.RemoveAt(0);
}
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
{
chapters[^2].LengthMs += chapters[^1].LengthMs;
chapters.Remove(chapters[^1]);
} }
} }
@@ -392,16 +262,17 @@ namespace FileLiberator
/// <summary>Move new files to 'Books' directory</summary> /// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns> /// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries) private static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries, CancellationToken cancellationToken)
{ {
// create final directory. move each file into it // create final directory. move each file into it
var destinationDir = getDestinationDirectory(libraryBook); var destinationDir = getDestinationDirectory(libraryBook);
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < entries.Count; i++) for (var i = 0; i < entries.Count; i++)
{ {
var entry = entries[i]; var entry = entries[i];
var realDest var realDest
= FileUtility.SaferMoveToValidPath( = FileUtility.SaferMoveToValidPath(
entry.Path, entry.Path,
Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
@@ -413,7 +284,8 @@ namespace FileLiberator
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop) // propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
entries[i] = entry with { Path = realDest }; entries[i] = entry with { Path = realDest };
} cancellationToken.ThrowIfCancellationRequested();
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue); var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default) if (cue != default)
@@ -422,7 +294,8 @@ namespace FileLiberator
SetFileTime(libraryBook, cue.Path); SetFileTime(libraryBook, cue.Path);
} }
AudibleFileStorage.Audio.Refresh(); cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
} }
private static string getDestinationDirectory(LibraryBook libraryBook) private static string getDestinationDirectory(LibraryBook libraryBook)
@@ -436,7 +309,7 @@ namespace FileLiberator
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries) private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio); => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private static void downloadCoverArt(LibraryBook libraryBook) private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken)
{ {
if (!Configuration.Instance.DownloadCoverArt) return; if (!Configuration.Instance.DownloadCoverArt) return;
@@ -444,24 +317,25 @@ namespace FileLiberator
try try
{ {
var destinationDir = getDestinationDirectory(libraryBook); var destinationDir = getDestinationDirectory(options.LibraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg"); coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath)); coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
if (File.Exists(coverPath)) if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath); FileUtility.SaferDelete(coverPath);
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native)); var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken);
if (picBytes.Length > 0) if (picBytes.Length > 0)
{ {
File.WriteAllBytes(coverPath, picBytes); File.WriteAllBytes(coverPath, picBytes);
SetFileTime(libraryBook, coverPath); SetFileTime(options.LibraryBook, coverPath);
} }
} }
catch (Exception ex) catch (Exception ex)
{ {
//Failure to download cover art should not be considered a failure to download the book //Failure to download cover art should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product."); Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
throw;
} }
} }
} }
@@ -0,0 +1,361 @@
using AaxDecrypter;
using AudibleApi;
using AudibleApi.Common;
using AudibleUtilities.Widevine;
using DataLayer;
using LibationFileManager;
using NAudio.Lame;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
namespace FileLiberator;
public partial class DownloadOptions
{
private const string Ec3Codec = "ec+3";
private const string Ac4Codec = "ac-4";
/// <summary>
/// Initiate an audiobook download from the audible api.
/// </summary>
public static async Task<DownloadOptions> InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token)
{
var license = await ChooseContent(api, libraryBook, config, token);
token.ThrowIfCancellationRequested();
//Some audiobooks will have incorrect chapters in the metadata returned from the license request,
//but the metadata returned by the content metadata endpoint will be correct. Call the content
//metadata endpoint and use its chapters. Only replace the license request chapters if the total
//lengths match (defensive against different audio formats having slightly different lengths).
var metadata = await api.GetContentMetadataAsync(libraryBook.Book.AudibleProductId);
if (metadata.ChapterInfo.RuntimeLengthMs == license.ContentMetadata.ChapterInfo.RuntimeLengthMs)
license.ContentMetadata.ChapterInfo = metadata.ChapterInfo;
token.ThrowIfCancellationRequested();
return BuildDownloadOptions(libraryBook, config, license);
}
private class LicenseInfo
{
public DrmType DrmType { get; }
public ContentMetadata ContentMetadata { get; set; }
public KeyData[]? DecryptionKeys { get; }
public LicenseInfo(ContentLicense license, IEnumerable<KeyData>? keys = null)
{
DrmType = license.DrmType;
ContentMetadata = license.ContentMetadata;
DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher);
}
private static KeyData[]? ToKeys(VoucherDtoV10? voucher)
=> voucher is null ? null : [new KeyData(voucher.Key, voucher.Iv)];
}
private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token)
{
var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High;
if (!config.UseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm)
{
token.ThrowIfCancellationRequested();
var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, dlQuality);
return new LicenseInfo(license);
}
token.ThrowIfCancellationRequested();
try
{
//try to request a widevine content license using the user's spatial audio settings
var codecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Ac4Codec : Ec3Codec;
var contentLic
= await api.GetDownloadLicenseAsync(
libraryBook.Book.AudibleProductId,
dlQuality,
ChapterTitlesType.Tree,
DrmType.Widevine,
config.RequestSpatial,
codecChoice);
if (contentLic.DrmType is not DrmType.Widevine)
return new LicenseInfo(contentLic);
using var client = new HttpClient();
using var mpdResponse = await client.GetAsync(contentLic.LicenseResponse, token);
var dash = new MpegDash(mpdResponse.Content.ReadAsStream(token));
if (!dash.TryGetUri(new Uri(contentLic.LicenseResponse), out var contentUri))
throw new InvalidDataException("Failed to get mpeg-dash content download url.");
contentLic.ContentMetadata.ContentUrl = new() { OfflineUrl = contentUri.ToString() };
using var session = cdm.OpenSession();
var challenge = session.GetLicenseChallenge(dash);
var licenseMessage = await api.WidevineDrmLicense(libraryBook.Book.AudibleProductId, challenge);
var keys = session.ParseLicense(licenseMessage);
return new LicenseInfo(contentLic, keys.Select(k => new KeyData(k.Kid.ToByteArray(bigEndian: true), k.Key)));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to request a Widevine license.");
//We failed to get a widevine content license. Depending on the
//failure reason, users can potentially still download this audiobook
//by disabling the "Use Widevine DRM" feature.
throw;
}
}
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
= config.StripAudibleBrandAudio
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
{
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
{
dlOptions.LibraryBookDto.BitRate = bitrate;
dlOptions.LibraryBookDto.SampleRate = sampleRate;
dlOptions.LibraryBookDto.Channels = channels;
}
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
.OrderBy(c => c.StartOffsetMs)
.ToList();
if (config.MergeOpeningAndEndCredits)
combineCredits(chapters);
for (int i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= chapterStartMs;
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
return dlOptions;
}
/// <summary>
/// The most reliable way to get these audio file properties is from the filename itself.
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
/// </summary>
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
{
bitrate = sampleRate = channels = null;
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
return false;
var file = Path.GetFileName(uri.LocalPath);
var match = AdrmAudioProperties().Match(file);
if (match.Success)
{
bitrate = int.Parse(match.Groups[1].Value);
sampleRate = int.Parse(match.Groups[2].Value);
channels = int.Parse(match.Groups[3].Value);
return true;
}
else if ((match = WidevineAudioProperties().Match(file)).Success)
{
bitrate = int.Parse(match.Groups[2].Value);
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
channels = match.Groups[3].Value switch
{
"ec3" => 6,
"ac4" => 3,
_ => null
};
return true;
}
return false;
}
public static LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new()
{
Mode = MPEGMode.Mono,
Quality = config.LameEncoderQuality,
OutputSampleRate = (int)config.MaxSampleRate
};
if (config.LameTargetBitrate)
{
if (config.LameConstantBitrate)
lameConfig.BitRate = config.LameBitrate;
else
{
lameConfig.ABRRateKbps = config.LameBitrate;
lameConfig.VBR = VBRMode.ABR;
lameConfig.WriteVBRTag = true;
}
}
else
{
lameConfig.VBR = VBRMode.Default;
lameConfig.VBRQuality = config.LameVBRQuality;
lameConfig.WriteVBRTag = true;
}
return lameConfig;
}
/*
Flatten Audible's new hierarchical chapters, combining children into parents.
Audible may deliver chapters like this:
00:00 - 00:10 Opening Credits
00:10 - 00:12 Book 1
00:12 - 00:14 | Part 1
00:14 - 01:40 | | Chapter 1
01:40 - 03:20 | | Chapter 2
03:20 - 03:22 | Part 2
03:22 - 05:00 | | Chapter 3
05:00 - 06:40 | | Chapter 4
06:40 - 06:42 Book 2
06:42 - 06:44 | Part 3
06:44 - 08:20 | | Chapter 5
08:20 - 10:00 | | Chapter 6
10:00 - 10:02 | Part 4
10:02 - 11:40 | | Chapter 7
11:40 - 13:20 | | Chapter 8
13:20 - 13:30 End Credits
And flattenChapters will combine them into this:
00:00 - 00:10 Opening Credits
00:10 - 01:40 Book 1: Part 1: Chapter 1
01:40 - 03:20 Book 1: Part 1: Chapter 2
03:20 - 05:00 Book 1: Part 2: Chapter 3
05:00 - 06:40 Book 1: Part 2: Chapter 4
06:40 - 08:20 Book 2: Part 3: Chapter 5
08:20 - 10:00 Book 2: Part 3: Chapter 6
10:00 - 11:40 Book 2: Part 4: Chapter 7
11:40 - 13:20 Book 2: Part 4: Chapter 8
13:20 - 13:40 End Credits
However, if one of the parent chapters is longer than 10000 milliseconds, it's kept as its own
chapter. A duration longer than a few seconds implies that the chapter contains more than just
the narrator saying the chapter title, so it should probably be preserved as a separate chapter.
Using the example above, if "Book 1" was 15 seconds long and "Part 3" was 20 seconds long:
00:00 - 00:10 Opening Credits
00:10 - 00:25 Book 1
00:25 - 00:27 | Part 1
00:27 - 01:40 | | Chapter 1
01:40 - 03:20 | | Chapter 2
03:20 - 03:22 | Part 2
03:22 - 05:00 | | Chapter 3
05:00 - 06:40 | | Chapter 4
06:40 - 06:42 Book 2
06:42 - 07:02 | Part 3
07:02 - 08:20 | | Chapter 5
08:20 - 10:00 | | Chapter 6
10:00 - 10:02 | Part 4
10:02 - 11:40 | | Chapter 7
11:40 - 13:20 | | Chapter 8
13:20 - 13:30 End Credits
then flattenChapters will combine them into this:
00:00 - 00:10 Opening Credits
00:10 - 00:25 Book 1
00:25 - 01:40 Book 1: Part 1: Chapter 1
01:40 - 03:20 Book 1: Part 1: Chapter 2
03:20 - 05:00 Book 1: Part 2: Chapter 3
05:00 - 06:40 Book 1: Part 2: Chapter 4
06:40 - 07:02 Book 2: Part 3
07:02 - 08:20 Book 2: Part 3: Chapter 5
08:20 - 10:00 Book 2: Part 3: Chapter 6
10:00 - 11:40 Book 2: Part 4: Chapter 7
11:40 - 13:20 Book 2: Part 4: Chapter 8
13:20 - 13:40 End Credits
*/
public static List<Chapter> flattenChapters(IList<Chapter> chapters, string? titleConcat = ": ")
{
List<Chapter> chaps = new();
foreach (var c in chapters)
{
if (c.Chapters is null)
chaps.Add(c);
else if (titleConcat is null)
{
chaps.Add(c);
chaps.AddRange(flattenChapters(c.Chapters, titleConcat));
}
else
{
if (c.LengthMs < 10000)
{
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
c.Chapters[0].StartOffsetSec = c.StartOffsetSec;
c.Chapters[0].LengthMs += c.LengthMs;
}
else
chaps.Add(c);
var children = flattenChapters(c.Chapters, titleConcat);
foreach (var child in children)
child.Title = $"{c.Title}{titleConcat}{child.Title}";
chaps.AddRange(children);
}
}
return chaps;
}
public static void combineCredits(IList<Chapter> chapters)
{
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")
{
chapters[1].StartOffsetMs = chapters[0].StartOffsetMs;
chapters[1].StartOffsetSec = chapters[0].StartOffsetSec;
chapters[1].LengthMs += chapters[0].LengthMs;
chapters.RemoveAt(0);
}
if (chapters.Count > 1 && chapters[^1].Title == "End Credits")
{
chapters[^2].LengthMs += chapters[^1].LengthMs;
chapters.Remove(chapters[^1]);
}
}
static double RelativePercentDifference(long num1, long num2)
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
[GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex WidevineAudioProperties();
[GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex AdrmAudioProperties();
}
+63 -31
View File
@@ -7,45 +7,49 @@ using System.Threading.Tasks;
using System; using System;
using System.IO; using System.IO;
using ApplicationServices; using ApplicationServices;
using LibationFileManager.Templates;
#nullable enable
namespace FileLiberator namespace FileLiberator
{ {
public class DownloadOptions : IDownloadOptions, IDisposable public partial class DownloadOptions : IDownloadOptions, IDisposable
{ {
public event EventHandler<long> DownloadSpeedChanged; public event EventHandler<long>? DownloadSpeedChanged;
public LibraryBook LibraryBook { get; } public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; } public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; } public string DownloadUrl { get; }
public string AudibleKey { get; init; } public KeyData[]? DecryptionKeys { get; }
public string AudibleIV { get; init; } public required TimeSpan RuntimeLength { get; init; }
public TimeSpan RuntimeLength { get; init; } public OutputFormat OutputFormat { get; }
public OutputFormat OutputFormat { get; init; } public required ChapterInfo ChapterInfo { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public string Title => LibraryBook.Book.Title; public string Title => LibraryBook.Book.Title;
public string Subtitle => LibraryBook.Book.Subtitle; public string Subtitle => LibraryBook.Book.Subtitle;
public string Publisher => LibraryBook.Book.Publisher; public string Publisher => LibraryBook.Book.Publisher;
public string Language => LibraryBook.Book.Language; public string Language => LibraryBook.Book.Language;
public string AudibleProductId => LibraryBookDto.AudibleProductId; public string? AudibleProductId => LibraryBookDto.AudibleProductId;
public string SeriesName => LibraryBookDto.SeriesName; public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
public float? SeriesNumber => LibraryBookDto.SeriesNumber; public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
public NAudio.Lame.LameConfig LameConfig { get; init; } public NAudio.Lame.LameConfig? LameConfig { get; }
public string UserAgent => AudibleApi.Resources.Download_User_Agent; public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio; public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged; public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
public bool CreateCueSheet => config.CreateCueSheet; public bool CreateCueSheet => Config.CreateCueSheet;
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks; public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
public long DownloadSpeedBps => config.DownloadSpeedLimit; public long DownloadSpeedBps => Config.DownloadSpeedLimit;
public bool RetainEncryptedFile => config.RetainAaxFile; public bool RetainEncryptedFile => Config.RetainAaxFile;
public bool FixupFile => config.AllowLibationFixup; public bool FixupFile => Config.AllowLibationFixup;
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono; public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate; public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
public bool MoveMoovToBeginning => config.MoveMoovToBeginning; public bool MoveMoovToBeginning => Config.MoveMoovToBeginning;
public AAXClean.FileType? InputType { get; }
public AudibleApi.Common.DrmType DrmType { get; }
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
public string GetMultipartFileName(MultiConvertFileProperties props) public string GetMultipartFileName(MultiConvertFileProperties props)
{ {
var baseDir = Path.GetDirectoryName(props.OutputFileName); var baseDir = Path.GetDirectoryName(props.OutputFileName);
var extension = Path.GetExtension(props.OutputFileName); var extension = Path.GetExtension(props.OutputFileName);
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension); return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension);
} }
public string GetMultipartTitle(MultiConvertFileProperties props) public string GetMultipartTitle(MultiConvertFileProperties props)
@@ -55,7 +59,7 @@ namespace FileLiberator
{ {
if (DownloadClipsBookmarks) if (DownloadClipsBookmarks)
{ {
var format = config.ClipsBookmarksFileFormat; var format = Config.ClipsBookmarksFileFormat;
var formatExtension = format.ToString().ToLowerInvariant(); var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension); var filePath = Path.ChangeExtension(fileName, formatExtension);
@@ -80,18 +84,46 @@ namespace FileLiberator
return string.Empty; return string.Empty;
} }
private readonly Configuration config; public Configuration Config { get; }
private readonly IDisposable cancellation; private readonly IDisposable cancellation;
public void Dispose() => cancellation?.Dispose(); public void Dispose()
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
{ {
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config)); cancellation?.Dispose();
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook)); GC.SuppressFinalize(this);
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl)); }
// no null/empty check for key/iv. unencrypted files do not have them
private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseInfo licInfo)
{
Config = ArgumentValidator.EnsureNotNull(config, nameof(config));
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo));
if (licInfo.ContentMetadata.ContentUrl.OfflineUrl is not string licUrl)
throw new InvalidDataException("Content license doesn't contain an offline Url");
DownloadUrl = licUrl;
DecryptionKeys = licInfo.DecryptionKeys;
DrmType = licInfo.DrmType;
ContentMetadata = licInfo.ContentMetadata;
InputType
= licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax
: licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 16 && licInfo.DecryptionKeys[0].KeyPart2?.Length == 16 ? AAXClean.FileType.Aaxc
: null;
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
OutputFormat
= licInfo.DrmType is not AudibleApi.Common.DrmType.Adrm and not AudibleApi.Common.DrmType.Widevine ||
(config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != Ac4Codec)
? OutputFormat.Mp3
: OutputFormat.M4b;
LameConfig = OutputFormat == OutputFormat.Mp3 ? GetLameOptions(config) : null;
// no null/empty check for key/iv. unencrypted files do not have them
LibraryBookDto = LibraryBook.ToDto(); LibraryBookDto = LibraryBook.ToDto();
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
cancellation = cancellation =
config config
@@ -19,5 +19,10 @@
<DebugType>embedded</DebugType> <DebugType>embedded</DebugType>
</PropertyGroup> </PropertyGroup>
<ItemGroup>
<Compile Update="DownloadOptions.*.cs">
<DependentUpon>DownloadOptions.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project> </Project>
+28 -10
View File
@@ -5,8 +5,9 @@ using System.Threading.Tasks;
using AudibleUtilities; using AudibleUtilities;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using LibationFileManager; using LibationFileManager.Templates;
#nullable enable
namespace FileLiberator namespace FileLiberator
{ {
public static class UtilityExtensions public static class UtilityExtensions
@@ -19,9 +20,15 @@ namespace FileLiberator
account: libraryBook.Account.ToMask() account: libraryBook.Account.ToMask()
); );
public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; }
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook) public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
{ {
var apiExtended = await ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale); Account account;
using (var accounts = AudibleApiStorage.GetAccountsSettingsPersister())
account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
var apiExtended = await ApiExtended.CreateAsync(account);
return apiExtended.Api; return apiExtended.Api;
} }
@@ -47,20 +54,31 @@ namespace FileLiberator
YearPublished = libraryBook.Book.DatePublished?.Year, YearPublished = libraryBook.Book.DatePublished?.Year,
DatePublished = libraryBook.Book.DatePublished, DatePublished = libraryBook.Book.DatePublished,
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(), Authors = libraryBook.Book.Authors.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => new ContributorDto(c.Name, c.AudibleContributorId)).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(), Series = getSeries(libraryBook.Book.SeriesLink),
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
IsPodcastParent = libraryBook.Book.IsEpisodeParent(), IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(), IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
Channels = libraryBook.Book.AudioFormat.Channels,
Language = libraryBook.Book.Language Language = libraryBook.Book.Language
}; };
} }
private static List<SeriesDto>? getSeries(IEnumerable<SeriesBook> seriesBooks)
{
if (!seriesBooks.Any())
return null;
//I don't remember why or if there was a good reason not to have series numbers for
//podcast parents, but preserving the behavior for backwards compatibility.
return seriesBooks
.Select(sb
=> new SeriesDto(
sb.Series.Name,
sb.Book.IsEpisodeParent() ? null : sb.Index,
sb.Series.AudibleSeriesId)
).ToList();
}
} }
} }
+2 -2
View File
@@ -5,8 +5,8 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.0.1" /> <PackageReference Include="Dinah.Core" Version="9.0.1.1" />
<PackageReference Include="Polly" Version="8.5.0" /> <PackageReference Include="Polly" Version="8.6.2" />
</ItemGroup> </ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
+1 -1
View File
@@ -157,7 +157,7 @@ namespace FileManager
/// <param name="extension">File extension override to use for <paramref name="destination"/></param> /// <param name="extension">File extension override to use for <paramref name="destination"/></param>
/// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param> /// <param name="overwrite">If <c>false</c> and <paramref name="destination"/> exists, append " (n)" to filename and try again.</param>
/// <returns>The actual destination filename</returns> /// <returns>The actual destination filename</returns>
public static string SaferMoveToValidPath( public static LongPath SaferMoveToValidPath(
LongPath source, LongPath source,
LongPath destination, LongPath destination,
ReplacementCharacters replacements, ReplacementCharacters replacements,
+1 -1
View File
@@ -88,7 +88,7 @@ namespace FileManager
Replacement.OtherQuote(""), Replacement.OtherQuote(""),
Replacement.OpenAngleBracket(""), Replacement.OpenAngleBracket(""),
Replacement.CloseAngleBracket(""), Replacement.CloseAngleBracket(""),
Replacement.Colon(""), Replacement.Colon("_"),
Replacement.Asterisk("✱"), Replacement.Asterisk("✱"),
Replacement.QuestionMark(""), Replacement.QuestionMark(""),
Replacement.Pipe("⏐"), Replacement.Pipe("⏐"),
+26 -3
View File
@@ -6,7 +6,30 @@
<local:ViewLocator/> <local:ViewLocator/>
</Application.DataTemplates> </Application.DataTemplates>
<Application.Styles> <Application.Styles>
<FluentTheme/> <FluentTheme>
</Application.Styles> <FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" />
<ColorPaletteResources x:Key="Dark" />
</FluentTheme.Palettes>
</FluentTheme>
<Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
<Style Selector="^ /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
</Style>
</Style>
<Style Selector="Button">
<Setter Property="VerticalContentAlignment" Value="Center"/>
<Style Selector="^">
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style>
<Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/>
</Style>
</Application.Styles>
</Application> </Application>
Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

@@ -4,27 +4,16 @@
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006" xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450" mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
x:Class="HangoverAvalonia.Controls.CheckedListBox"> x:Class="HangoverAvalonia.Controls.CheckedListBox">
<UserControl.Resources>
<RecyclePool x:Key="RecyclePool" />
<DataTemplate x:Key="queuedBook">
<CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
</DataTemplate>
<RecyclingElementFactory x:Key="elementFactory" RecyclePool="{StaticResource RecyclePool}">
<RecyclingElementFactory.Templates>
<StaticResource x:Key="queuedBook" ResourceKey="queuedBook" />
</RecyclingElementFactory.Templates>
</RecyclingElementFactory>
</UserControl.Resources>
<ScrollViewer <ScrollViewer
Name="scroller"
HorizontalScrollBarVisibility="Disabled" HorizontalScrollBarVisibility="Disabled"
VerticalScrollBarVisibility="Auto"> VerticalScrollBarVisibility="Auto">
<ItemsRepeater IsVisible="True" <ItemsControl ItemsSource="{Binding $parent[1].Items}">
VerticalCacheLength="1.2" <ItemsControl.ItemTemplate>
HorizontalCacheLength="1" <DataTemplate>
ItemsSource="{Binding CheckboxItems}" <CheckBox HorizontalAlignment="Stretch" Margin="10,0,0,0" Content="{Binding Item}" IsChecked="{Binding IsChecked, Mode=TwoWay}" />
ItemTemplate="{StaticResource elementFactory}" /> </DataTemplate>
</ItemsControl.ItemTemplate>
</ItemsControl>
</ScrollViewer> </ScrollViewer>
</UserControl> </UserControl>
@@ -2,103 +2,18 @@ using Avalonia;
using Avalonia.Collections; using Avalonia.Collections;
using Avalonia.Controls; using Avalonia.Controls;
using HangoverAvalonia.ViewModels; using HangoverAvalonia.ViewModels;
using ReactiveUI;
using System;
using System.Collections;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
namespace HangoverAvalonia.Controls namespace HangoverAvalonia.Controls;
public partial class CheckedListBox : UserControl
{ {
public partial class CheckedListBox : UserControl public static readonly StyledProperty<AvaloniaList<CheckBoxViewModel>> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, AvaloniaList<CheckBoxViewModel>>(nameof(Items));
public AvaloniaList<CheckBoxViewModel> Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
public CheckedListBox()
{ {
public event EventHandler<ItemCheckEventArgs> ItemCheck; InitializeComponent();
public static readonly StyledProperty<IEnumerable> ItemsProperty =
AvaloniaProperty.Register<CheckedListBox, IEnumerable>(nameof(Items));
public IEnumerable Items { get => GetValue(ItemsProperty); set => SetValue(ItemsProperty, value); }
private CheckedListBoxViewModel _viewModel = new();
public IEnumerable<object> CheckedItems =>
_viewModel
.CheckboxItems
.Where(i => i.IsChecked)
.Select(i => i.Item);
public void SetItemChecked(int i, bool isChecked) => _viewModel.CheckboxItems[i].IsChecked = isChecked;
public void SetItemChecked(object item, bool isChecked)
{
var obj = _viewModel.CheckboxItems.SingleOrDefault(i => i.Item == item);
if (obj is not null)
obj.IsChecked = isChecked;
}
public CheckedListBox()
{
InitializeComponent();
scroller.DataContext = _viewModel;
_viewModel.CheckedChanged += _viewModel_CheckedChanged;
}
private void _viewModel_CheckedChanged(object sender, CheckBoxViewModel e)
{
var args = new ItemCheckEventArgs { Item = e.Item, ItemIndex = _viewModel.CheckboxItems.IndexOf(e), IsChecked = e.IsChecked };
ItemCheck?.Invoke(this, args);
}
protected override void OnPropertyChanged(AvaloniaPropertyChangedEventArgs change)
{
if (change.Property.Name == nameof(Items) && Items != null)
_viewModel.SetItems(Items);
base.OnPropertyChanged(change);
}
public class CheckedListBoxViewModel : ViewModelBase
{
public event EventHandler<CheckBoxViewModel> CheckedChanged;
public AvaloniaList<CheckBoxViewModel> CheckboxItems { get; private set; }
public void SetItems(IEnumerable items)
{
UnsubscribeFromItems(CheckboxItems);
CheckboxItems = new(items.OfType<object>().Select(o => new CheckBoxViewModel { Item = o }));
SubscribeToItems(CheckboxItems);
this.RaisePropertyChanged(nameof(CheckboxItems));
}
private void SubscribeToItems(IEnumerable objects)
{
foreach (var i in objects.OfType<INotifyPropertyChanged>())
i.PropertyChanged += I_PropertyChanged;
}
private void UnsubscribeFromItems(AvaloniaList<CheckBoxViewModel> objects)
{
if (objects is null) return;
foreach (var i in objects)
i.PropertyChanged -= I_PropertyChanged;
}
private void I_PropertyChanged(object sender, PropertyChangedEventArgs e)
{
CheckedChanged?.Invoke(this, (CheckBoxViewModel)sender);
}
}
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}
}
public class ItemCheckEventArgs : EventArgs
{
public int ItemIndex { get; init; }
public bool IsChecked { get; init; }
public object Item { get; init; }
} }
} }
@@ -71,13 +71,12 @@
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Avalonia" Version="11.2.1" /> <PackageReference Include="Avalonia" Version="11.3.2" />
<PackageReference Include="Avalonia.Desktop" Version="11.2.1" /> <PackageReference Include="Avalonia.Desktop" Version="11.3.2" />
<!--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.2.1" /> <PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.2" />
<PackageReference Include="Avalonia.ReactiveUI" Version="11.2.1" /> <PackageReference Include="Avalonia.ReactiveUI" Version="11.3.2" />
<PackageReference Include="Avalonia.Controls.ItemsRepeater" Version="11.1.5" /> <PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" /> <ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />
@@ -0,0 +1,11 @@
using ReactiveUI;
namespace HangoverAvalonia.ViewModels;
public class CheckBoxViewModel : ViewModelBase
{
private bool _isChecked;
public bool IsChecked { get => _isChecked; set => this.RaiseAndSetIfChanged(ref _isChecked, value); }
private object _bookText;
public object Item { get => _bookText; set => this.RaiseAndSetIfChanged(ref _bookText, value); }
}
@@ -1,41 +1,8 @@
using ApplicationServices; namespace HangoverAvalonia.ViewModels;
using DataLayer;
using ReactiveUI;
using System.Collections.Generic;
namespace HangoverAvalonia.ViewModels public partial class MainVM
{ {
public partial class MainVM public TrashBinViewModel TrashBinViewModel { get; } = new();
{
private List<LibraryBook> _deletedBooks;
public List<LibraryBook> DeletedBooks { get => _deletedBooks; set => this.RaiseAndSetIfChanged(ref _deletedBooks, value); }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
private int _totalBooksCount = 0; private void Load_deletedVM() { }
private int _checkedBooksCount = 0;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
if (_checkedBooksCount != value)
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
}
private void Load_deletedVM()
{
reload();
}
public void reload()
{
DeletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
_checkedBooksCount = 0;
_totalBooksCount = DeletedBooks.Count;
this.RaisePropertyChanged(nameof(CheckedCountText));
}
}
} }
@@ -0,0 +1,117 @@
using ApplicationServices;
using Avalonia.Collections;
using DataLayer;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.ComponentModel;
using System.Linq;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
public class TrashBinViewModel : ViewModelBase, IDisposable
{
public AvaloniaList<CheckBoxViewModel> DeletedBooks { get; }
public string CheckedCountText => $"Checked : {_checkedBooksCount} of {_totalBooksCount}";
private bool _controlsEnabled = true;
public bool ControlsEnabled { get => _controlsEnabled; set => this.RaiseAndSetIfChanged(ref _controlsEnabled, value); }
private bool? everythingChecked = false;
public bool? EverythingChecked
{
get => everythingChecked;
set
{
everythingChecked = value ?? false;
if (everythingChecked is true)
CheckAll();
else if (everythingChecked is false)
UncheckAll();
}
}
private int _totalBooksCount = 0;
private int _checkedBooksCount = -1;
public int CheckedBooksCount
{
get => _checkedBooksCount;
set
{
_checkedBooksCount = value;
this.RaisePropertyChanged(nameof(CheckedCountText));
everythingChecked
= _checkedBooksCount == 0 || _totalBooksCount == 0 ? false
: _checkedBooksCount == _totalBooksCount ? true
: null;
this.RaisePropertyChanged(nameof(EverythingChecked));
}
}
public IEnumerable<LibraryBook> CheckedBooks => DeletedBooks.Where(i => i.IsChecked).Select(i => i.Item).Cast<LibraryBook>();
public TrashBinViewModel()
{
DeletedBooks = new()
{
ResetBehavior = ResetBehavior.Remove
};
tracker = DeletedBooks.TrackItemPropertyChanged(CheckboxPropertyChanged);
Reload();
}
public void CheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = true;
}
public void UncheckAll()
{
foreach (var item in DeletedBooks)
item.IsChecked = false;
}
public async Task RestoreCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.RestoreBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public async Task PermanentlyDeleteCheckedAsync()
{
ControlsEnabled = false;
var qtyChanges = await Task.Run(CheckedBooks.PermanentlyDeleteBooks);
if (qtyChanges > 0)
Reload();
ControlsEnabled = true;
}
public void Reload()
{
var deletedBooks = DbContexts.GetContext().GetDeletedLibraryBooks();
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));
_totalBooksCount = DeletedBooks.Count;
CheckedBooksCount = 0;
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);
}
public void Dispose() => tracker?.Dispose();
}
@@ -1,40 +1,12 @@
using ApplicationServices; namespace HangoverAvalonia.Views;
using DataLayer;
using HangoverAvalonia.Controls;
using System.Linq;
namespace HangoverAvalonia.Views public partial class MainWindow
{ {
public partial class MainWindow private void deletedTab_VisibleChanged(bool isVisible)
{ {
private void deletedTab_VisibleChanged(bool isVisible) if (!isVisible)
{ return;
if (!isVisible)
return;
if (_viewModel.DeletedBooks.Count == 0) _viewModel.TrashBinViewModel.Reload();
_viewModel.reload();
}
public void Deleted_CheckedListBox_ItemCheck(object sender, ItemCheckEventArgs args)
{
_viewModel.CheckedBooksCount = deletedCbl.CheckedItems.Count();
}
public void Deleted_CheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, true);
}
public void Deleted_UncheckAll_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
foreach (var item in deletedCbl.Items)
deletedCbl.SetItemChecked(item, false);
}
public void Deleted_Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
var qtyChanges = libraryBooksToRestore.RestoreBooks();
if (qtyChanges > 0)
_viewModel.reload();
}
} }
} }
+38 -14
View File
@@ -15,13 +15,11 @@
<TabControl Name="tabControl1" Grid.Row="0"> <TabControl Name="tabControl1" Grid.Row="0">
<TabControl.Styles> <TabControl.Styles>
<Style Selector="ItemsPresenter#PART_ItemsPresenter"> <Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="23"/> <Setter Property="Height" Value="33"/>
</Style> </Style>
<Style Selector="TabItem"> <Style Selector="TabItem /template/ Border#PART_LayoutRoot">
<Setter Property="MinHeight" Value="40"/> <Setter Property="Height" Value="33"/>
<Setter Property="Height" Value="40"/>
<Setter Property="Padding" Value="8,2,8,5"/>
</Style> </Style>
<Style Selector="TabItem#Header TextBlock"> <Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/> <Setter Property="MinHeight" Value="5"/>
@@ -51,6 +49,7 @@
<TextBox <TextBox
Margin="0,5,0,5" Margin="0,5,0,5"
AcceptsReturn="True"
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" /> Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
<Button <Button
@@ -73,33 +72,58 @@
<TabItem.Header> <TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock> <TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
</TabItem.Header> </TabItem.Header>
<Grid <Grid
DataContext="{Binding TrashBinViewModel}"
RowDefinitions="Auto,*,Auto"> RowDefinitions="Auto,*,Auto">
<TextBlock <TextBlock
Grid.Row="0" Grid.Row="0"
Margin="5" Margin="5"
Text="To restore deleted book, check box and save" /> Text="Check books you want to permanently delete from or restore to Libation" />
<controls:CheckedListBox <controls:CheckedListBox
Grid.Row="1" Grid.Row="1"
Margin="5,0,5,0" Margin="5,0,5,0"
BorderThickness="1" BorderThickness="1"
BorderBrush="Gray" BorderBrush="Gray"
Name="deletedCbl" IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" /> Items="{Binding DeletedBooks}" />
<Grid <Grid
Grid.Row="2" Grid.Row="2"
Margin="5" Margin="5"
ColumnDefinitions="Auto,Auto,Auto,*"> ColumnDefinitions="Auto,Auto,*,Auto">
<Button Grid.Column="0" Margin="0,0,20,0" Content="Check All" Click="Deleted_CheckAll_Click" /> <CheckBox
<Button Grid.Column="1" Margin="0,0,20,0" Content="Uncheck All" Click="Deleted_UncheckAll_Click" /> IsEnabled="{Binding ControlsEnabled}"
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding CheckedCountText}" /> IsThreeState="True"
<Button Grid.Column="3" HorizontalAlignment="Right" Content="Save" Click="Deleted_Save_Click" /> Margin="0,0,20,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Restore"
Command="{Binding RestoreCheckedAsync}"/>
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Command="{Binding PermanentlyDeleteCheckedAsync}" >
<TextBlock
TextAlignment="Center"
Text="Permanently Delete&#xa;from Libation" />
</Button>
</Grid> </Grid>
</Grid> </Grid>
</TabItem> </TabItem>
@@ -18,7 +18,6 @@ namespace HangoverAvalonia.Views
public void OnLoad() public void OnLoad()
{ {
deletedCbl.ItemCheck += Deleted_CheckedListBox_ItemCheck;
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); }; databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); }; deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); }; cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 89 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.5 KiB

After

Width:  |  Height:  |  Size: 6.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 498 B

After

Width:  |  Height:  |  Size: 601 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 754 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 929 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.1 KiB

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.1 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 50 KiB

After

Width:  |  Height:  |  Size: 35 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 133 KiB

After

Width:  |  Height:  |  Size: 36 KiB

+36 -19
View File
@@ -2,6 +2,7 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:LibationAvalonia" xmlns:local="using:LibationAvalonia"
xmlns:controls="using:LibationAvalonia.Controls" xmlns:controls="using:LibationAvalonia.Controls"
xmlns:dialogs="using:LibationAvalonia.Dialogs"
x:Class="LibationAvalonia.App" x:Class="LibationAvalonia.App"
Name="Libation"> Name="Libation">
@@ -12,6 +13,18 @@
<Application.Resources> <Application.Resources>
<ResourceDictionary> <ResourceDictionary>
<ControlTheme x:Key="{x:Type TextBlock}" TargetType="TextBlock">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
<Setter Property="Background" Value="Transparent" />
</ControlTheme>
<ControlTheme x:Key="{x:Type DataGridCell}" TargetType="DataGridCell" BasedOn="{StaticResource {x:Type DataGridCell}}">
<Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" />
</ControlTheme>
<ControlTheme x:Key="{x:Type DataGridColumnHeader}" TargetType="DataGridColumnHeader" BasedOn="{StaticResource {x:Type DataGridColumnHeader}}">
<Setter Property="Padding" Value="6,0,0,0" />
</ControlTheme>
<x:Double x:Key="DataGridSortIconMinWidth">0</x:Double>
<ResourceDictionary.ThemeDictionaries> <ResourceDictionary.ThemeDictionaries>
<ResourceDictionary x:Key="Light"> <ResourceDictionary x:Key="Light">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" /> <SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#abffab" />
@@ -20,17 +33,11 @@
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" /> <SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="Khaki" />
<SolidColorBrush x:Key="HyperlinkNew" Color="Blue" /> <SolidColorBrush x:Key="HyperlinkNew" Color="Blue" />
<SolidColorBrush x:Key="HyperlinkVisited" Color="Purple" /> <SolidColorBrush x:Key="HyperlinkVisited" Color="Purple" />
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="White" />
<SolidColorBrush x:Key="SystemOpaqueBase" Color="White" />
<SolidColorBrush x:Key="CancelRed" Color="FireBrick" /> <SolidColorBrush x:Key="CancelRed" Color="FireBrick" />
<SolidColorBrush x:Key="IconFill" Color="#231F20" /> <SolidColorBrush x:Key="IconFill" Color="#231F20" />
<SolidColorBrush x:Key="StoplightRed" Color="#F06060" /> <SolidColorBrush x:Key="StoplightRed" Color="#F06060" />
<SolidColorBrush x:Key="StoplightYellow" Color="#F0E160" /> <SolidColorBrush x:Key="StoplightYellow" Color="#F0E160" />
<SolidColorBrush x:Key="StoplightGreen" Color="#70FA70" /> <SolidColorBrush x:Key="StoplightGreen" Color="#70FA70" />
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
</ResourceDictionary> </ResourceDictionary>
<ResourceDictionary x:Key="Dark"> <ResourceDictionary x:Key="Dark">
<SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#bed2fa" /> <SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#bed2fa" />
@@ -39,35 +46,32 @@
<SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="#4e4b15" /> <SolidColorBrush x:Key="ProcessQueueBookCancelledBrush" Color="#4e4b15" />
<SolidColorBrush x:Key="HyperlinkNew" Color="CornflowerBlue" /> <SolidColorBrush x:Key="HyperlinkNew" Color="CornflowerBlue" />
<SolidColorBrush x:Key="HyperlinkVisited" Color="Orchid" /> <SolidColorBrush x:Key="HyperlinkVisited" Color="Orchid" />
<SolidColorBrush x:Key="ProcessQueueBookDefaultBrush" Color="Black" />
<SolidColorBrush x:Key="SystemOpaqueBase" Color="Black" />
<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="#7d1f1f" /> <SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" />
<SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" /> <SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" />
<SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" /> <SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" />
<SolidColorBrush x:Key="DisabledGrayBrush" Opacity="0.4" Color="{StaticResource SystemChromeMediumColor}" />
</ResourceDictionary> </ResourceDictionary>
</ResourceDictionary.ThemeDictionaries> </ResourceDictionary.ThemeDictionaries>
</ResourceDictionary> </ResourceDictionary>
</Application.Resources> </Application.Resources>
<Application.Styles> <Application.Styles>
<FluentTheme />
<StyleInclude Source="avares://Avalonia.Themes.Fluent/FluentTheme.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/> <StyleInclude Source="avares://Avalonia.Controls.DataGrid/Themes/Fluent.xaml"/>
<StyleInclude Source="avares://Avalonia.Controls.ColorPicker/Themes/Fluent/Fluent.xaml" />
<StyleInclude Source="/Assets/LibationVectorIcons.xaml"/> <StyleInclude Source="/Assets/LibationVectorIcons.xaml"/>
<StyleInclude Source="/Assets/DataGridColumnHeader.xaml"/> <FluentTheme>
<FluentTheme.Palettes>
<ColorPaletteResources x:Key="Light" />
<ColorPaletteResources x:Key="Dark" />
</FluentTheme.Palettes>
</FluentTheme>
<Style Selector="TextBox[IsReadOnly=true]"> <Style Selector="TextBox[IsReadOnly=true]">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" /> <Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
<Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" /> <Setter Property="CaretBrush" Value="{DynamicResource SystemControlTransparentBrush}" />
<Style Selector="^ /template/ Border#PART_BorderElement"> <Style Selector="^ /template/ Border#PART_BorderElement">
<Setter Property="Background" Value="{DynamicResource SystemControlBackgroundBaseLowBrush}" /> <Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" />
</Style> </Style>
</Style> </Style>
<Style Selector="controls|LinkLabel"> <Style Selector="controls|LinkLabel">
@@ -76,11 +80,24 @@
</Style> </Style>
<Style Selector="Button"> <Style Selector="Button">
<Setter Property="VerticalContentAlignment" Value="Center"/> <Setter Property="VerticalContentAlignment" Value="Center"/>
<Style Selector="^">
<Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" />
</Style>
</Style> </Style>
<Style Selector="ScrollBar"> <Style Selector="ScrollBar">
<!-- It's called AutoHide, but this is really the mouseover shrink/expand. --> <!-- It's called AutoHide, but this is really the mouseover shrink/expand. -->
<Setter Property="AllowAutoHide" Value="false"/> <Setter Property="AllowAutoHide" Value="false"/>
</Style> </Style>
<Style Selector="dialogs|DialogWindow">
<Setter Property="SystemDecorations" Value="Full"/>
<Setter Property="Icon" Value="/Assets/libation.ico"/>
<Setter Property="Template">
<ControlTemplate>
<ContentPresenter Background="{DynamicResource SystemRegionColor}" Content="{TemplateBinding Content}" />
</ControlTemplate>
</Setter>
</Style>
</Application.Styles> </Application.Styles>
<NativeMenu.Menu> <NativeMenu.Menu>
+63 -40
View File
@@ -1,10 +1,7 @@
using ApplicationServices; using ApplicationServices;
using Avalonia; using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes; using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Input;
using Avalonia.Markup.Xaml; using Avalonia.Markup.Xaml;
using Avalonia.Media;
using Avalonia.Platform; using Avalonia.Platform;
using Avalonia.Styling; using Avalonia.Styling;
using LibationAvalonia.Dialogs; using LibationAvalonia.Dialogs;
@@ -14,21 +11,25 @@ using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
using ReactiveUI; using Avalonia.Threading;
using DataLayer; using Dinah.Core;
using LibationAvalonia.Themes;
using Avalonia.Data.Core.Plugins;
using System.Linq;
using LibationUiBase.Forms;
using Avalonia.Controls;
#nullable enable
namespace LibationAvalonia namespace LibationAvalonia
{ {
public class App : Application public class App : Application
{ {
public static Window MainWindow { get; private set; } public static Task<List<DataLayer.LibraryBook>>? LibraryTask { get; set; }
public static IBrush ProcessQueueBookFailedBrush { get; private set; } public static ChardonnayTheme? DefaultThemeColors { get; private set; }
public static IBrush ProcessQueueBookCompletedBrush { get; private set; } public static MainWindow? MainWindow { get; private set; }
public static IBrush ProcessQueueBookCancelledBrush { get; private set; } public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/");
public static IBrush ProcessQueueBookDefaultBrush { get; private set; } public static new Application Current => Application.Current ?? throw new InvalidOperationException("The Avalonia app hasn't started yet.");
public static IBrush SeriesEntryGridBackgroundBrush { get; private set; }
public static readonly Uri AssetUriBase = new("avares://Libation/Assets/");
public static Stream OpenAsset(string assetRelativePath) public static Stream OpenAsset(string assetRelativePath)
=> AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath)); => AssetLoader.Open(new Uri(AssetUriBase, assetRelativePath));
@@ -37,17 +38,24 @@ namespace LibationAvalonia
AvaloniaXamlLoader.Load(this); AvaloniaXamlLoader.Load(this);
} }
public static Task<List<DataLayer.LibraryBook>> LibraryTask;
public override void OnFrameworkInitializationCompleted() public override void OnFrameworkInitializationCompleted()
{ {
DefaultThemeColors = ChardonnayTheme.GetLiveTheme();
if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{ {
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
// Avoid duplicate validations from both Avalonia and the CommunityToolkit.
// More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins
DisableAvaloniaDataAnnotationValidation();
var config = Configuration.Instance; var config = Configuration.Instance;
if (!config.LibationSettingsAreValid) if (!config.LibationSettingsAreValid)
{ {
var defaultLibationFilesDir = Configuration.UserProfile; var defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory;
// check for existing settings in default location // check for existing settings in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
@@ -72,11 +80,23 @@ namespace LibationAvalonia
base.OnFrameworkInitializationCompleted(); base.OnFrameworkInitializationCompleted();
} }
private void DisableAvaloniaDataAnnotationValidation()
private async void Setup_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{ {
var setupDialog = sender as SetupDialog; // Get an array of plugins to remove
var desktop = ApplicationLifetime as IClassicDesktopStyleApplicationLifetime; var dataValidationPluginsToRemove =
BindingPlugins.DataValidators.OfType<DataAnnotationsValidationPlugin>().ToArray();
// remove each entry found
foreach (var plugin in dataValidationPluginsToRemove)
{
BindingPlugins.DataValidators.Remove(plugin);
}
}
private async void Setup_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (sender is not SetupDialog setupDialog || ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime desktop)
return;
try try
{ {
@@ -85,12 +105,12 @@ namespace LibationAvalonia
// - error message, Exit() // - error message, Exit()
if (setupDialog.IsNewUser) if (setupDialog.IsNewUser)
{ {
Configuration.SetLibationFiles(Configuration.UserProfile); Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory);
setupDialog.Config.Books = Path.Combine(Configuration.UserProfile, nameof(Configuration.Books)); setupDialog.Config.Books = Configuration.DefaultBooksDirectory;
if (setupDialog.Config.LibationSettingsAreValid) if (setupDialog.Config.LibationSettingsAreValid)
{ {
string theme = setupDialog.SelectedTheme.Content as string; string? theme = setupDialog.SelectedTheme.Content as string;
setupDialog.Config.SetString(theme, nameof(ThemeVariant)); setupDialog.Config.SetString(theme, nameof(ThemeVariant));
@@ -146,7 +166,7 @@ namespace LibationAvalonia
desktop.MainWindow = libationFilesDialog; desktop.MainWindow = libationFilesDialog;
libationFilesDialog.Show(); libationFilesDialog.Show();
void WindowClosing(object sender, System.ComponentModel.CancelEventArgs e) void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e)
{ {
libationFilesDialog.Closing -= WindowClosing; libationFilesDialog.Closing -= WindowClosing;
e.Cancel = true; e.Cancel = true;
@@ -177,7 +197,7 @@ namespace LibationAvalonia
if (continueResult == DialogResult.Yes) if (continueResult == DialogResult.Yes)
{ {
config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books)); config.Books = Configuration.DefaultBooksDirectory;
if (config.LibationSettingsAreValid) if (config.LibationSettingsAreValid)
{ {
@@ -204,30 +224,33 @@ namespace LibationAvalonia
private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop)
{ {
Current.RequestedThemeVariant = Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)) switch Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged;
{ OpenAndApplyTheme(Configuration.Instance.GetString(propertyName: nameof(ThemeVariant)));
nameof(ThemeVariant.Dark) => ThemeVariant.Dark,
nameof(ThemeVariant.Light) => ThemeVariant.Light,
// "System"
_ => ThemeVariant.Default
};
//Reload colors for current theme
LoadStyles();
var mainWindow = new MainWindow(); var mainWindow = new MainWindow();
desktop.MainWindow = MainWindow = mainWindow; desktop.MainWindow = MainWindow = mainWindow;
mainWindow.OnLibraryLoaded(LibraryTask.GetAwaiter().GetResult()); mainWindow.Loaded += MainWindow_Loaded;
mainWindow.RestoreSizeAndLocation(Configuration.Instance); mainWindow.RestoreSizeAndLocation(Configuration.Instance);
mainWindow.Show(); mainWindow.Show();
} }
private static void LoadStyles() [PropertyChangeFilter(nameof(ThemeVariant))]
private static void ThemeVariant_PropertyChanged(object sender, PropertyChangedEventArgsEx e)
=> OpenAndApplyTheme(e.NewValue as string);
private static void OpenAndApplyTheme(string? themeVariant)
{ {
ProcessQueueBookFailedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookFailedBrush)); using var themePersister = ChardonnayThemePersister.Create();
ProcessQueueBookCompletedBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCompletedBrush)); themePersister?.Target.ApplyTheme(themeVariant);
ProcessQueueBookCancelledBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookCancelledBrush)); }
SeriesEntryGridBackgroundBrush = AvaloniaUtils.GetBrushFromResources(nameof(SeriesEntryGridBackgroundBrush));
ProcessQueueBookDefaultBrush = AvaloniaUtils.GetBrushFromResources(nameof(ProcessQueueBookDefaultBrush)); private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (LibraryTask is not null && MainWindow is not null)
{
var library = await LibraryTask;
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
}
} }
} }
} }
@@ -1,104 +0,0 @@
<Styles xmlns="https://github.com/avaloniaui" xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:collections="using:Avalonia.Collections">
<Styles.Resources>
<!--
Based on Fluent template from v11.0.0-preview8
Modified sort arrow positioning to make more room for header text
-->
<ControlTheme x:Key="{x:Type DataGridColumnHeader}" TargetType="DataGridColumnHeader">
<Setter Property="Foreground" Value="{DynamicResource DataGridColumnHeaderForegroundBrush}" />
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderBackgroundBrush}" />
<Setter Property="HorizontalContentAlignment" Value="Stretch" />
<Setter Property="VerticalContentAlignment" Value="Center" />
<Setter Property="SeparatorBrush" Value="{DynamicResource DataGridGridLinesBrush}" />
<Setter Property="Padding" Value="8,0,0,0" />
<Setter Property="FontSize" Value="12" />
<Setter Property="MinHeight" Value="32" />
<Setter Property="Template">
<ControlTemplate>
<Border x:Name="HeaderBorder"
Background="{TemplateBinding Background}"
BorderBrush="{TemplateBinding BorderBrush}"
BorderThickness="{TemplateBinding BorderThickness}"
CornerRadius="{TemplateBinding CornerRadius}">
<Grid Name="PART_ColumnHeaderRoot" ColumnDefinitions="*,Auto">
<Grid Margin="{TemplateBinding Padding}"
HorizontalAlignment="{TemplateBinding HorizontalContentAlignment}"
VerticalAlignment="{TemplateBinding VerticalContentAlignment}">
<Grid.ColumnDefinitions>
<ColumnDefinition Width="*" />
<ColumnDefinition Width="16" />
</Grid.ColumnDefinitions>
<ContentPresenter Content="{TemplateBinding Content}"
ContentTemplate="{TemplateBinding ContentTemplate}" />
<Path Name="SortIcon"
IsVisible="False"
Grid.Column="1"
Height="12"
HorizontalAlignment="Left"
VerticalAlignment="Center"
Fill="{TemplateBinding Foreground}"
Stretch="Uniform" />
</Grid>
<Rectangle Name="VerticalSeparator"
Grid.Column="1"
Width="1"
VerticalAlignment="Stretch"
Fill="{TemplateBinding SeparatorBrush}"
IsVisible="{TemplateBinding AreSeparatorsVisible}" />
<Grid x:Name="FocusVisual" IsHitTestVisible="False"
IsVisible="False">
<Rectangle x:Name="FocusVisualPrimary"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Fill="Transparent"
IsHitTestVisible="False"
Stroke="{DynamicResource DataGridCellFocusVisualPrimaryBrush}"
StrokeThickness="2" />
<Rectangle x:Name="FocusVisualSecondary"
Margin="2"
HorizontalAlignment="Stretch"
VerticalAlignment="Stretch"
Fill="Transparent"
IsHitTestVisible="False"
Stroke="{DynamicResource DataGridCellFocusVisualSecondaryBrush}"
StrokeThickness="1" />
</Grid>
</Grid>
</Border>
</ControlTemplate>
</Setter>
<Style Selector="^:focus-visible /template/ Grid#FocusVisual">
<Setter Property="IsVisible" Value="True" />
</Style>
<Style Selector="^:pointerover /template/ Grid#PART_ColumnHeaderRoot">
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderHoveredBackgroundBrush}" />
</Style>
<Style Selector="^:pressed /template/ Grid#PART_ColumnHeaderRoot">
<Setter Property="Background" Value="{DynamicResource DataGridColumnHeaderPressedBackgroundBrush}" />
</Style>
<Style Selector="^:dragIndicator">
<Setter Property="Opacity" Value="0.5" />
</Style>
<Style Selector="^:sortascending /template/ Path#SortIcon">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Data" Value="{StaticResource DataGridSortIconAscendingPath}" />
</Style>
<Style Selector="^:sortdescending /template/ Path#SortIcon">
<Setter Property="IsVisible" Value="True" />
<Setter Property="Data" Value="{StaticResource DataGridSortIconDescendingPath}" />
</Style>
</ControlTheme>
</Styles.Resources>
</Styles>
@@ -91,6 +91,32 @@
S 192,128 147,147 S 192,128 147,147
</StreamGeometry> </StreamGeometry>
<StreamGeometry x:Key="LibationGlassIcon">
M262,8
h-117
a 192,200 0 0 0 -36,82
a 222,334 41 0 0 138,236
v158
h-81
a 16,16 0 0 0 0,32
h192
a 16 16 0 0 0 0,-32
h-81
v-158
a 222,334 -41 0 0 138,-236
a 192,200 0 0 0 -36,-82
h-117
m-99,30
a 192,200 0 0 0 -26,95
a 187.5,334 35 0 0 125,159
a 187.5,334 -35 0 0 125,-159
a 192,200 0 0 0 -26,-95
h-198
M158,136
a 168,305 35 0 0 104,136
a 168,305 -35 0 0 104,-136
</StreamGeometry>
</ResourceDictionary> </ResourceDictionary>
</Styles.Resources> </Styles.Resources>
</Styles> </Styles>
Binary file not shown.

After

Width:  |  Height:  |  Size: 93 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.4 KiB

Loaded 100 of 338 files, more files were not shown because too many files have changed in this diff. Show more