Compare commits

...

759 Commits

Author SHA1 Message Date
Robert McRackan
cf9ec9facf did last tag incorrect. New version 2023-02-28 10:13:26 -05:00
Robert McRackan
f6084ef10c v9.4.1 2023-02-28 10:04:47 -05:00
rmcrackan
740b73beb7 Merge pull request #518 from Mbucari/master
Improve Audible login and Libation Upgrade
2023-02-28 09:51:08 -05:00
Mbucari
5c45802391 Fixed review comments 2023-02-28 07:42:26 -07:00
MBucari
429aa603f5 Update workflows 2023-02-27 21:41:59 -07:00
MBucari
80ea394934 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-27 16:33:16 -07:00
Mbucari
bce4437c79 Change workflows 2023-02-27 16:18:48 -07:00
Mbucari
b6ad1a289b Remove windows desktop runtime dependency from chardonnay 2023-02-27 16:13:40 -07:00
Mbucari
2a22d05f37 Remove windows desktop runtime dependency from chardonnay 2023-02-27 15:08:54 -07:00
Mbucari
d787843fd2 Unify upgrade process and add update progress bar 2023-02-27 14:08:15 -07:00
Mbucari
ded58f687d Update 2FA and Captcha controls 2023-02-27 14:08:14 -07:00
Mbucari
1f1f34b6ce Merge branch 'rmcrackan:master' into master 2023-02-27 09:36:53 -07:00
Mbucari
ffadf90f4f Fix MFA and 2FA 2023-02-27 09:36:19 -07:00
rmcrackan
67807efacf Merge pull request #515 from Mbucari/patch-4
Update InstallOnMac.md
2023-02-26 15:29:55 -05:00
Mbucari
980f5afa54 Update InstallOnMac.md 2023-02-25 19:42:45 -07:00
Robert McRackan
b2f68760b2 New audible api login 2023-02-24 15:52:14 -05:00
rmcrackan
faf86711a5 Merge pull request #509 from Mbucari/master
Add More MP3 Options and improved AAXClean
2023-02-24 15:35:38 -05:00
Mbucari
4a78b9d28f Revert workflow change 2023-02-24 12:38:29 -07:00
Michael Bucari-Tovo
1b0a7f5062 New mp3 options and improved encoding performance 2023-02-24 12:12:41 -07:00
Mbucari
49982043e0 Merge branch 'rmcrackan:master' into master 2023-02-24 11:15:14 -07:00
Robert McRackan
378cf7057e updated to AudibleApi v8 2023-02-24 13:12:18 -05:00
Mbucari
abdc0f018e Update build-linux.yml 2023-02-22 09:23:15 -07:00
Robert McRackan
c65f61b92e Fix paypal links 2023-02-22 07:33:58 -05:00
Robert McRackan
c12805c8ce incr ver for release 2023-02-19 14:55:55 -05:00
rmcrackan
67f9a6db78 Merge pull request #503 from Mbucari/master
Mac and Linux Arm64 releases and Fixed #502
2023-02-19 14:52:09 -05:00
Mbucari
bb6336ce2a Update .releaseindex.json 2023-02-19 11:27:23 -07:00
Michael Bucari-Tovo
af7a4a6acf Add comments 2023-02-19 11:11:28 -07:00
Michael Bucari-Tovo
21d18aa565 Final edits 2023-02-19 10:59:42 -07:00
Michael Bucari-Tovo
c96875ba5d Add '-chardonnay' to build assets name 2023-02-19 10:23:49 -07:00
Michael Bucari-Tovo
6ebbfb8e59 Refactor SetReleaseIdentifier() 2023-02-19 10:20:01 -07:00
Michael Bucari-Tovo
1e6e28cd57 Start downloading asynchronously 2023-02-18 22:38:26 -07:00
Michael Bucari-Tovo
defed72862 Force garbage collection after completing a Processable 2023-02-18 22:16:46 -07:00
Michael Bucari-Tovo
71503b34b5 Fix macOS crash 2023-02-18 20:29:10 -07:00
Michael Bucari-Tovo
a00849fb6f Refactor InteropFactory 2023-02-18 13:57:00 -07:00
Michael Bucari-Tovo
14b63c0883 Add apple UUTYPEs 2023-02-18 10:27:37 -07:00
Michael Bucari-Tovo
59d556733e Edit Mac Build Script 2023-02-17 23:46:28 -07:00
Michael Bucari-Tovo
a99a175683 Update AAXClean to fix #502 2023-02-17 23:20:35 -07:00
Michael Bucari-Tovo
26fedcfb60 Fix DirectorySelectControl not displaying known dir 2023-02-17 22:58:24 -07:00
Michael Bucari-Tovo
dde8024506 More thread safety to address #492 2023-02-17 22:57:43 -07:00
Michael Bucari-Tovo
25f7c29380 New linux build workflows 2023-02-17 18:04:34 -07:00
Robert McRackan
2f347e83e8 fix linux 'can update'. upgrade aaxclean 2023-02-16 07:57:36 -05:00
rmcrackan
080a74884d Update InstallOnMac.md
new mac setup video
2023-02-16 07:44:09 -05:00
Robert McRackan
2dbeb64c38 incr ver. updates for mac and linux 2023-02-15 08:38:13 -05:00
rmcrackan
bb508c0718 Merge pull request #489 from Mbucari/master
Mac App Bundle and added mp3 conversion support on mac
2023-02-15 08:33:06 -05:00
Michael Bucari-Tovo
9a450b0d63 add 'macOS' to mac bundle name 2023-02-15 06:31:09 -07:00
Michael Bucari-Tovo
c1de0e60d2 Hopefully fix #492 2023-02-14 23:07:40 -07:00
Mbucari
dc7c03661d Add auto update to linux and macos 2023-02-14 23:06:14 -07:00
Mbucari
952eee6d32 Merge branch 'rmcrackan:master' into master 2023-02-13 21:42:11 -07:00
Michael Bucari-Tovo
472a0f30b9 Launch hangover from Libation app bundle for mac 2023-02-13 21:40:53 -07:00
Robert McRackan
73533c58a8 update dependencies 2023-02-13 21:14:56 -05:00
Mbucari
65ef018719 Move NameListFormatter to its own class 2023-02-13 10:09:13 -07:00
Mbucari
f0ca349539 Update UNSAFE_MigrationHelper with new appsettings.json getter 2023-02-13 09:03:03 -07:00
Mbucari
500b287721 Fix #490 2023-02-13 08:08:10 -07:00
Mbucari
21f3ae45d3 Delete deb.yml 2023-02-12 22:25:39 -07:00
Michael Bucari-Tovo
d496564f0d Edit Mac and Linux bundle build workflows 2023-02-12 21:50:33 -07:00
Michael Bucari-Tovo
6fdd6293ce Ensure appsettings.json is created in a writable location. 2023-02-12 15:32:51 -07:00
Michael Bucari-Tovo
3bca495521 Add MacOS app bundle workflow 2023-02-11 23:38:17 -07:00
Michael Bucari-Tovo
0fb580f1a5 Ensure appsettings.json is created in a writable location. 2023-02-11 20:06:04 -07:00
Michael Bucari-Tovo
a7cd47e0b1 Update AAXClean 2023-02-11 18:34:07 -07:00
Robert McRackan
30aecedfae incr ver 2023-02-10 23:16:22 -05:00
rmcrackan
e72799efe5 Merge pull request #487 from Mbucari/master
Custom author and narrator names formatting and batch locate books
2023-02-10 23:14:31 -05:00
Michael Bucari-Tovo
ee8c0ae27b Use new .NET regular expression source generators 2023-02-10 19:45:52 -07:00
Mbucari
5b4a4341ad More agressive garbage collection 2023-02-10 15:03:43 -07:00
Mbucari
56823c1105 Move FindAudiobooks() to AudioFileStorage 2023-02-10 14:54:29 -07:00
Mbucari
1f4ada604a Make suggested changes 2023-02-10 14:37:28 -07:00
Mbucari
3a4ab80892 Add human name parsing and formatting to naming templates 2023-02-10 12:53:12 -07:00
Mbucari
bba9c2ba7b Add Locate Audiobooks function (#485) 2023-02-10 09:35:21 -07:00
Robert McRackan
c4acd5d208 incr ver 2023-02-08 13:56:13 -05:00
rmcrackan
381440db4c Merge pull request #479 from Mbucari/master
Fix #478 and other stuff I'd already worked on
2023-02-08 13:43:56 -05:00
Michael Bucari-Tovo
00c8be1f7e Create LibationUiBase for shared UI code 2023-02-08 09:30:13 -07:00
rmcrackan
d665122aa2 Update GettingStarted.md
Classic vs Chardonnay
2023-02-08 07:49:18 -05:00
Michael Bucari-Tovo
bb40df5fa3 Fix #478 2023-02-07 22:58:29 -07:00
Mbucari
e3c9f70dff Move shared GUI code into AppScaffolding 2023-02-06 16:04:58 -07:00
Mbucari
b351033cec Improve download and convert speed estimate 2023-02-06 15:54:12 -07:00
Mbucari
18f69bc73d Refactor Naming Template 2023-02-06 15:24:18 -07:00
Robert McRackan
39fe7b79d2 Bug fix #474 2023-02-06 08:35:24 -05:00
rmcrackan
85769d797b Merge pull request #473 from Mbucari/master
Made changes discussed in previous PR and fixed #472
2023-02-04 17:32:53 -05:00
Michael Bucari-Tovo
9a80f18e1c Rename ConditionalTagClass to ConditionalTagCollection 2023-02-04 15:19:47 -07:00
Michael Bucari-Tovo
aec8305e52 Fix #472 2023-02-04 12:49:59 -07:00
Michael Bucari-Tovo
a672174a9b Refactor Naming Templates 2023-02-04 12:49:48 -07:00
Mbucari
6f490b4491 Rename TagClass to TagCollection 2023-02-03 17:14:04 -07:00
Mbucari
5917d059e4 Add Enumerable initializers 2023-02-03 17:03:15 -07:00
Robert McRackan
40602c7626 incr ver 2023-02-03 17:26:17 -05:00
rmcrackan
7d5ee2afa8 Merge pull request #471 from Mbucari/master
Add a more general NamingTemplate
2023-02-03 17:24:37 -05:00
Mbucari
08b6f8fa11 Add test 2023-02-03 14:46:48 -07:00
Mbucari
5f9699aa3b Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-03 14:43:42 -07:00
Mbucari
70607aaaf4 Documentation 2023-02-03 14:43:35 -07:00
Mbucari
1d96d39af7 Add Naming Template Documentation 2023-02-03 14:33:11 -07:00
Mbucari
5557772957 Add conditional negation 2023-02-03 11:47:55 -07:00
Mbucari
5c7db6cd23 Add <series#> tag zero padding (#466) 2023-02-03 11:31:15 -07:00
Mbucari
c72b64d74c Properly truncate filenames 2023-02-03 09:53:40 -07:00
Mbucari
20474e0b3c Add a more general NamingTemplate 2023-02-02 22:48:27 -07:00
Robert McRackan
867085600c New feature #469 - <language> and <language short> template options 2023-02-01 12:12:50 -05:00
rmcrackan
74290ec609 Merge pull request #468 from rmcrackan/dependabot/github_actions/docker/build-push-action-4
Bump docker/build-push-action from 3 to 4
2023-01-31 10:18:40 -05:00
dependabot[bot]
5ee555e60c Bump docker/build-push-action from 3 to 4
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 3 to 4.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-01-31 14:11:36 +00:00
Robert McRackan
a36c28d48f incr. ver. 2023-01-30 21:24:54 -05:00
rmcrackan
0877f2c042 Merge pull request #464 from Mbucari/master
Fix #463
2023-01-25 20:48:58 -05:00
Mbucari
2baf5243ea Fix #463 2023-01-25 14:23:02 -07:00
rmcrackan
b7e71f5812 Merge pull request #462 from Mbucari/master
Refactor AaxDecrypter
2023-01-25 07:06:37 -05:00
Michael Bucari-Tovo
2ed1076fab Cleanup 2023-01-24 23:35:05 -07:00
Michael Bucari-Tovo
0b20aa751f Only Dispose of NFS on disposing 2023-01-24 23:00:07 -07:00
Michael Bucari-Tovo
05a4ece8d1 Merged 2023-01-24 22:27:31 -07:00
Michael Bucari-Tovo
25b37c6266 Refactor AaxDecrypter 2023-01-24 22:25:55 -07:00
Robert McRackan
b668cff0ac Update dependenciesd. Build is broken until the ambiguous ref.s moved into Dinah.Core are resolved 2023-01-24 22:40:08 -05:00
Robert McRackan
4d6c742ae9 Bug fix #459 , New feature/setting #366 2023-01-23 22:50:37 -05:00
rmcrackan
933f663d22 Merge pull request #460 from Mbucari/master
Upgrade AAXClean.Codecs to 0.5.12, add moov relocation, and fix #459
2023-01-23 22:46:20 -05:00
Michael Bucari-Tovo
0c55f278a4 Revert Solution Changes 2023-01-23 20:32:27 -07:00
Michael Bucari-Tovo
3f567ee82e Merged 2023-01-23 20:13:19 -07:00
Michael Bucari-Tovo
8dc912c11d Add option to move the moov atom to the beginning of the file. 2023-01-23 20:11:00 -07:00
Michael Bucari-Tovo
f1b4e2a17d Upgrade AAXClean.Codecs to 0.5.11 2023-01-23 19:04:15 -07:00
Michael Bucari-Tovo
630cfdeab3 Upgrade AAXClean.Codecs to 0.5.11 2023-01-23 17:39:08 -07:00
Michael Bucari-Tovo
7029409792 Upgrade AAXClean.Codecs to 0.5.10 and fix #459 2023-01-23 16:30:17 -07:00
rmcrackan
d0727b5a85 Update InstallOnMac.md
Added @Mbucari 's awesome video
2023-01-23 08:17:28 -05:00
rmcrackan
9f52ad5e0a Update README.md
link to docker readme
2023-01-23 08:07:37 -05:00
rmcrackan
501ae643f7 Merge pull request #458 from pixil98/master
Deb build tweaks, Docker read me
2023-01-23 08:06:51 -05:00
Aaron Reisman
400074170e Add docker readme 2023-01-22 17:09:25 -06:00
Aaron Reisman
17103ed066 Get release id correctly 2023-01-22 15:33:27 -06:00
Aaron Reisman
b6b29309c9 Go back to the old way of uploading assets 2023-01-22 15:15:56 -06:00
Aaron Reisman
a04538710f Try with a different glob 2023-01-22 14:53:05 -06:00
Aaron Reisman
01f6f5c137 Add name to release 2023-01-22 14:17:08 -06:00
Aaron Reisman
b1a37cbd8c Switch to a still maintained release action 2023-01-22 13:56:36 -06:00
Aaron Reisman
8c59e1280b Wait for deb to be finished before releasing 2023-01-22 13:20:59 -06:00
Aaron Reisman
00339127aa reference correct deb yaml 2023-01-22 13:15:51 -06:00
Aaron Reisman
5935b40b60 Remove output version 2023-01-22 13:03:23 -06:00
Aaron Reisman
6cfd2dea96 Move deb building out of the build pipeline 2023-01-22 13:01:12 -06:00
rmcrackan
7d3a39c693 Merge pull request #456 from Mbucari/master
Add file creation DateTime to naming templates
2023-01-20 15:18:11 -05:00
Mbucari
6e7a4ea475 Update date format regex and tests 2023-01-20 10:03:55 -07:00
Michael Bucari-Tovo
3479dbc3f0 Date format naming templates 2023-01-20 01:00:22 -07:00
Mbucari
9309aea6d9 Add file creation DateTime to naming templates
typo
2023-01-19 17:12:28 -07:00
Robert McRackan
f72551fa9a incr ver 2023-01-19 08:06:18 -05:00
rmcrackan
e3b237b75f Merge pull request #454 from Mbucari/master
Fixed #453
2023-01-18 22:23:55 -05:00
Mbucari
b1ddf18f73 Merge branch 'rmcrackan:master' into master 2023-01-18 15:47:18 -07:00
Michael Bucari-Tovo
13f522abb8 Fix file extension detection error (#453) 2023-01-18 15:47:05 -07:00
Robert McRackan
3c3d956bf3 remove linux and mac from beta 2023-01-16 13:16:49 -05:00
Robert McRackan
8160547c11 incr ver 2023-01-16 13:06:19 -05:00
rmcrackan
ef71d36dee Merge pull request #451 from Mbucari/patch-3
Updated for new deb package builds
2023-01-16 12:59:23 -05:00
Mbucari
b0d8434455 Updated for new deb package builds 2023-01-16 10:41:21 -07:00
rmcrackan
9be0d58461 Merge pull request #450 from Mbucari/master
Update workflows and AAXClean
2023-01-16 10:38:18 -05:00
Michael Bucari-Tovo
1addcc8211 Update AAXClean and add better error handling 2023-01-15 21:42:03 -07:00
Mbucari
38c75dc8c5 Update workflows 2023-01-12 15:47:01 -07:00
rmcrackan
89c3ea8311 Merge pull request #444 from Mbucari/master
Build and attach deb package
2023-01-12 08:45:14 -05:00
Michael Bucari-Tovo
18ff799fb1 Update build scripts 2023-01-11 21:14:26 -07:00
Mbucari
67b6aaed99 Fix #447 2023-01-11 16:08:01 -07:00
Mbucari
08bb463560 Invoke observables directly instead of through event handler 2023-01-11 14:38:12 -07:00
Mbucari
97767dcabb GLOB pattern 2023-01-11 14:37:12 -07:00
Mbucari
fb18940a5c Use DataGridMyRatingColumn for both user and product ratings. 2023-01-11 14:36:43 -07:00
Mbucari
b823f5fa00 Import average rating 2023-01-11 14:35:14 -07:00
Mbucari
d64fb081a0 Build and attach deb package 2023-01-11 14:15:07 -07:00
Robert McRackan
09118b1ddf nvm. I guess this has been default : true for a long time. Reverting 2023-01-10 10:13:47 -05:00
Robert McRackan
de20590fd5 do not enable AutoScan by default 2023-01-10 10:04:53 -05:00
rmcrackan
1050ffdb24 Merge pull request #443 from Mbucari/master
Chardonnay Bugfix + Moved Default Settings into Configuration
2023-01-10 09:54:49 -05:00
Michael Bucari-Tovo
2e49c7f697 Commit edits before refresh 2023-01-09 18:57:16 -07:00
Mbucari
708cdcc24c Merge branch 'master' of https://github.com/Mbucari/Libation 2023-01-09 16:30:59 -07:00
Mbucari
c89eafd568 Fix rating edits updating search results. 2023-01-09 16:27:19 -07:00
Michael Bucari-Tovo
10de241d53 Cache default values 2023-01-09 16:11:30 -07:00
Mbucari
e58952035f Spaces 2023-01-09 16:06:37 -07:00
Mbucari
50a8c7508a Cache default values 2023-01-09 16:05:55 -07:00
Michael Bucari-Tovo
2b243a6934 Remove testing code 2023-01-09 15:28:36 -07:00
Michael Bucari-Tovo
ece93cb4d7 Finish migrating default Configuration values into Configuration 2023-01-09 15:26:06 -07:00
rmcrackan
5b3ca0ed32 Merge pull request #442 from wtanksleyjr/master
Add a version parameter to DEB creation, do sanity checks, use it.
2023-01-09 16:36:48 -05:00
Robert McRackan
7474f1221a bug fix #441 -- new code with empty setting 2023-01-09 16:30:12 -05:00
William Tanksley
d023a943c1 Add a version parameter to DEB creation, do sanity checks, use it. 2023-01-09 13:12:12 -08:00
Michael Bucari-Tovo
4e80af5c53 Bind MyRatingCellEditor background color to the cell's background 2023-01-09 14:06:06 -07:00
Michael Bucari-Tovo
eee785377f Add default values to Configuration 2023-01-09 14:05:33 -07:00
Robert McRackan
915906e6ed test push ver 2023-01-09 11:59:24 -05:00
Robert McRackan
358c8b577e increment version 2023-01-09 11:53:44 -05:00
rmcrackan
5c450a01a4 Merge pull request #440 from Mbucari/master
Configuration Change Tracking and Bookk Records
2023-01-09 11:41:34 -05:00
Michael Bucari-Tovo
36264c6c6e Add back a check that was removed for testing 2023-01-08 13:19:21 -07:00
Michael Bucari-Tovo
fca946bf15 Fix bug with long folder templates 2023-01-08 10:01:30 -07:00
Michael Bucari-Tovo
452ceef285 Tweak 2023-01-07 23:27:28 -07:00
Michael Bucari-Tovo
7fafee804d Double clicking on template item adds it to the template. 2023-01-07 23:15:38 -07:00
Michael Bucari-Tovo
3a48479435 Typos and formatting 2023-01-07 18:41:34 -07:00
Michael Bucari-Tovo
cab8555ab5 Make UpgradeNotificationDialog a DialogWindow 2023-01-07 18:18:14 -07:00
Michael Bucari-Tovo
e3b7cbcc2a Add proper Upgrade form 2023-01-07 18:09:37 -07:00
Michael Bucari-Tovo
ed15614288 Improve character display in EditTemplateDialog 2023-01-07 14:33:49 -07:00
Michael Bucari-Tovo
acb6d1b335 Use new Inline controls to selectively style TextBlock text 2023-01-07 12:05:38 -07:00
Michael Bucari-Tovo
fe804796ab Use ContinueWith to set Rating changed value 2023-01-06 23:46:06 -07:00
Michael Bucari-Tovo
4725fe36d1 Add property changed filtering events to Configuration 2023-01-06 22:56:00 -07:00
Michael Bucari-Tovo
5c73beff4b Add PropertyChanged detection for Dictionary type settings 2023-01-06 19:46:55 -07:00
Michael Bucari-Tovo
1f7000c2c9 Add Configurations property change notifications 2023-01-06 16:50:20 -07:00
Mbucari
f09baa1318 Merge branch 'rmcrackan:master' into master 2023-01-05 23:41:27 -07:00
Michael Bucari-Tovo
7eaa03e43c Add clip and bookmark viewer and exporter 2023-01-05 23:40:39 -07:00
Robert McRackan
26099303fa update dependencies 2023-01-05 21:44:34 -05:00
Michael Bucari-Tovo
6417aee780 Add book records dialog 2023-01-05 17:02:39 -07:00
rmcrackan
f9deaba4c5 Merge pull request #438 from Mbucari/master
Linux and mac upgrade notification
2023-01-03 21:26:04 -05:00
Michael Bucari-Tovo
ddd6a3b279 Change args 2023-01-03 15:17:57 -07:00
Michael Bucari-Tovo
9359950666 Formatting 2023-01-03 15:00:29 -07:00
Michael Bucari-Tovo
d31b2a1b65 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-01-03 14:56:09 -07:00
Michael Bucari-Tovo
b89b4e0af4 Linux and mac upgrade notification 2023-01-03 14:55:58 -07:00
rmcrackan
cbcde027b3 Merge pull request #436 from Mbucari/master
Add download speed limit
2023-01-02 13:07:03 -05:00
Mbucari
d306e6bd22 Merge branch 'rmcrackan:master' into master 2023-01-02 02:54:09 -07:00
Michael Bucari-Tovo
9ec877999e Add download speed limit 2023-01-02 02:46:46 -07:00
rmcrackan
f4189bf409 Merge pull request #432 from Mbucari/master
Add ability for users to edit book ratings from the main grid
2023-01-01 22:34:06 -05:00
Michael Bucari-Tovo
0ed5062683 Cancel rating edit on escape 2023-01-01 11:23:22 -07:00
Michael Bucari-Tovo
7ef666dc91 Add TCOM tag for narrator 2022-12-31 23:31:24 -07:00
Michael Bucari-Tovo
1ac825919a Remove old migrations 2022-12-31 22:41:11 -07:00
Michael Bucari-Tovo
a7bf30954d Fix null file bug and add context menu to my ratings column 2022-12-31 21:09:30 -07:00
Michael Bucari-Tovo
613cfdd903 Automatic refiltering now works on chardonnay 2022-12-31 19:50:49 -07:00
Michael Bucari-Tovo
28802c8279 Refilter on search update 2022-12-31 18:41:55 -07:00
Michael Bucari-Tovo
6d7b3bd5f0 Improve star display on classic 2022-12-31 15:39:46 -07:00
Michael Bucari-Tovo
b97d8e9403 Add ratings cell tool tips 2022-12-31 11:12:04 -07:00
Michael Bucari-Tovo
b4838d364e Only show hollow stars in editing mode 2022-12-31 10:33:18 -07:00
Michael Bucari-Tovo
05ac5c63e1 Formatting 2022-12-31 10:16:54 -07:00
Michael Bucari-Tovo
874bf9e7c0 Improve classic and chardonnay rating editor simmilarity 2022-12-31 10:02:30 -07:00
Michael Bucari-Tovo
c9497ef39e Make my rating column sortable 2022-12-30 19:44:16 -07:00
Michael Bucari-Tovo
496830d01d Fix cell editor to work with desktop scaling 2022-12-30 19:25:11 -07:00
Mbucari
ccebcdd4c7 Merge branch 'rmcrackan:master' into master 2022-12-30 17:01:22 -07:00
Michael Bucari-Tovo
c900fe8461 Add user rating editing to grid 2022-12-30 17:00:40 -07:00
rmcrackan
a0158db37e Merge pull request #431 from Mbucari/master
Fix file naming template on unix systems (#430)
2022-12-30 11:05:09 -05:00
Robert McRackan
b8c26b01ad update dependencies 2022-12-30 09:58:51 -05:00
Mbucari
3a44bef0d9 Lowercase command is more linux-ey 2022-12-29 16:24:41 -07:00
Mbucari
57a4ee781b .deb package build script (#390) 2022-12-29 16:04:20 -07:00
=
e12f475850 Refactor 2022-12-29 15:39:48 -07:00
=
f822a23daa Linux and OSX directory length limits 2022-12-29 15:29:18 -07:00
=
6901b8be35 Fix file naming template on unix systems 2022-12-29 14:12:46 -07:00
Robert McRackan
83fb2cd1d0 New feature #430 : bulk set pdf-downloaded status 2022-12-29 09:33:32 -05:00
Robert McRackan
c98664d584 Bugfix #423 : Chardonnay updater fails when windows username has a space 2022-12-22 09:45:32 -05:00
Robert McRackan
d098be8b03 EF: move seed data into corresponding config class 2022-12-22 09:37:21 -05:00
Robert McRackan
3f6689d032 Test release before v9 2022-12-20 12:43:04 -05:00
rmcrackan
b4206fc203 Merge pull request #416 from pixil98/master
Linux runners and Docker image
2022-12-19 21:58:38 -05:00
rmcrackan
cfa4a0c07f Merge pull request #417 from Mbucari/master
Improve download cancellation
2022-12-19 13:42:24 -05:00
Michael Bucari-Tovo
357b220ace Suppress warnings 2022-12-19 09:48:18 -07:00
Michael Bucari-Tovo
47968304c9 Return Download to new background thread 2022-12-19 09:15:36 -07:00
Michael Bucari-Tovo
2024d5e116 Improve download cancellation. 2022-12-18 21:52:51 -07:00
pixil98
5ae2a99c14 Docker workflow (#7)
* Refactored workflows
* Added docker build to release
* Linux and MacOS now build on Linux
2022-12-18 17:57:37 -06:00
rmcrackan
7fd002d2c9 Merge pull request #413 from Mbucari/master
Update obsolete code and fix #347
2022-12-18 09:41:31 -05:00
Michael Bucari-Tovo
b7b7038244 Delete partially decrypted files from previous Libation instances 2022-12-17 12:30:26 -07:00
Michael Bucari-Tovo
b5519c4875 Add option for user to choose custom temp folder 2022-12-17 12:25:13 -07:00
Michael Bucari-Tovo
44feab9eb2 Update comments 2022-12-17 11:39:37 -07:00
Michael Bucari-Tovo
96c45c33e5 Refactor NetworkFileStream replace obsolete WebRequest 2022-12-17 11:31:51 -07:00
Michael Bucari-Tovo
36efbcb812 Replace deprecated file dialogs 2022-12-16 21:08:11 -07:00
Michael Bucari-Tovo
03f44b4e9c Fix IDE class grouping 2022-12-16 19:54:31 -07:00
rmcrackan
19860e9f09 Merge pull request #412 from Mbucari/master
Fix Character Replacements and Add More Useful Error Messages
2022-12-16 21:28:36 -05:00
Michael Bucari-Tovo
0701cb3970 Reorder tabs 2022-12-16 16:45:51 -07:00
Michael Bucari-Tovo
7d6000e3b6 Bring Hangover Chardonnay into feature parity with Classic (#409) 2022-12-16 16:41:24 -07:00
Mbucari
ef973ac56a Merge branch 'rmcrackan:master' into master 2022-12-16 08:55:08 -07:00
Robert McRackan
91a1033c52 makes slashes more clear 2022-12-16 09:48:00 -05:00
Robert McRackan
4197db6af9 Fix unit tests failing because of windows newlines 2022-12-16 09:27:44 -05:00
MBucari
210ab065c2 Make tests xplat 2022-12-15 23:04:27 -07:00
Mbucari
9cd10eca58 Merge branch 'rmcrackan:master' into master 2022-12-15 19:25:27 -07:00
Robert McRackan
ba676be46d update dependency 2022-12-15 21:06:26 -05:00
Mbucari
665a2e1866 Merge branch 'rmcrackan:master' into master 2022-12-15 16:22:43 -07:00
Michael Bucari-Tovo
94469cae3d Add better error messages for license denial #352 2022-12-15 16:22:25 -07:00
Michael Bucari-Tovo
a0dd2ccad6 Make filename character replacement more xplat and allow replacing any char, not just illegal. 2022-12-15 15:50:48 -07:00
Robert McRackan
b2cf837de7 Hangover. WinForms. Restore deleted books 2022-12-15 14:11:27 -05:00
Mbucari
80bcf60b5b Merge branch 'rmcrackan:master' into master 2022-12-14 15:40:42 -07:00
Robert McRackan
7ad0ab566a New feature: 'Remove' now removes forever. Removed books won't be re-added on next scan 2022-12-14 16:19:55 -05:00
Michael Bucari-Tovo
2b16e86c7b Fix character replacement for non-windows platforms. 2022-12-13 16:33:37 -07:00
Robert McRackan
f2ea02ae0b bugfix. file extension 2022-12-13 16:09:24 -05:00
Robert McRackan
f65cd39040 bug fix: keyboard shortcuts 2022-12-13 15:58:54 -05:00
Robert McRackan
5ca0d2a399 New feature #406 : Right Click Menu for Stop-Light Icon (Chardonnay UI) 2022-12-13 15:32:33 -05:00
Robert McRackan
d1528a095b New feature #406 : Right Click Menu for Stop-Light Icon (Classic UI) 2022-12-13 13:48:28 -05:00
rmcrackan
749173a463 Merge pull request #407 from Mbucari/master
Add dynamic context menus to products grid
2022-12-13 08:33:03 -05:00
Michael Bucari-Tovo
6fbd90a6b3 Fix hidden tag 2022-12-13 02:42:53 -07:00
Michael Bucari-Tovo
f39d272e6a Make reused ContextMenu static 2022-12-12 18:23:12 -07:00
Michael Bucari-Tovo
bb3854f512 Finishing touch 2022-12-12 17:21:41 -07:00
Michael Bucari-Tovo
e40daecfb8 Remove old static context menu 2022-12-12 17:12:44 -07:00
Michael Bucari-Tovo
3716ab9cb5 Merged 2022-12-12 17:11:40 -07:00
Michael Bucari-Tovo
0cc6d6337a Add dynamic context menus to main grid 2022-12-12 17:10:18 -07:00
Robert McRackan
ce711a36ba #398 - new feature: right-click, copy 2022-12-12 15:03:20 -05:00
rmcrackan
451af7bea9 Merge pull request #405 from Mbucari/master
Upgraded to Avalonia 11-Preview4
2022-12-12 13:18:06 -05:00
Michael Bucari-Tovo
63200592bf Ensure mandatory character replacements remain marked mandatory 2022-12-12 08:34:10 -07:00
Michael Bucari-Tovo
d165dfbeb5 Fix NRE 2022-12-12 08:24:18 -07:00
Michael Bucari-Tovo
eed3d84517 Add context menu 2022-12-11 19:21:49 -07:00
Michael Bucari-Tovo
ba7d890966 Update hangover 2022-12-11 18:33:57 -07:00
Michael Bucari-Tovo
5140fc63d9 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-12-11 18:30:56 -07:00
Michael Bucari-Tovo
78509c07e0 Remove unused files 2022-12-11 18:30:48 -07:00
Mbucari
5084141215 Delete Source/Visual Studio 2022 directory 2022-12-11 18:23:00 -07:00
Michael Bucari-Tovo
3f2ac83474 Fix copying grid contents to clipboard 2022-12-11 17:50:15 -07:00
Michael Bucari-Tovo
58a0468728 Tidy up 2022-12-11 16:58:51 -07:00
Mbucari
8e13aa7513 Merge branch 'rmcrackan:master' into master 2022-12-11 15:47:24 -07:00
Michael Bucari-Tovo
48e2d91fc8 Implement Illegal Char Replace dialog in Avalonia 2022-12-11 15:47:04 -07:00
rmcrackan
a7f119217f Merge pull request #404 from pixil98/master
Fix Auto Updating
2022-12-11 09:12:10 -05:00
pixil98
865f2261fe Fix Auto Updating (#8)
Release workflow no longer includes the parent folder in the zip.
2022-12-10 00:30:08 -06:00
Michael Bucari-Tovo
dfedb23efd Refactor ProductsDisplay 2022-12-09 12:27:52 -07:00
Mbucari
c01e1c3e4b Merge branch 'rmcrackan:master' into master 2022-12-09 12:27:14 -07:00
Robert McRackan
ad8dac5fb0 more forgiving releaseindex windows regex 2022-12-08 08:17:17 -05:00
Mbucari
84e81b6218 Merge branch 'rmcrackan:master' into master 2022-12-07 19:13:22 -07:00
Robert McRackan
86efe631fe restore yaml 2022-12-07 13:49:05 -05:00
Robert McRackan
f5f1dc483b publish debugging. create new version 2022-12-07 13:34:30 -05:00
Robert McRackan
8aa4328c6c update dependencies 2022-12-07 13:09:02 -05:00
Michael Bucari-Tovo
a01a8c4b19 Update Avalonia to v.11-Preview-4 2022-12-07 10:15:12 -07:00
Robert McRackan
4b2387b621 update dependencies 2022-12-07 11:53:10 -05:00
Robert McRackan
74d16d8ef9 yaml releases don't run. comment out for now 2022-12-07 07:42:11 -05:00
rmcrackan
b1ea8f9fa7 Update GettingStarted.md
disclaimer: don't install in Program Files
2022-12-06 15:59:11 -05:00
rmcrackan
c666fdeaff document CLI set-status 2022-12-06 15:02:26 -05:00
Robert McRackan
7068782975 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-12-06 14:58:30 -05:00
Robert McRackan
c4cebbebe7 * #396 New feature : match download status to files
* UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
  * CLI: Full library. No prompt
2022-12-06 14:58:22 -05:00
rmcrackan
53d43d9fa9 Merge pull request #401 from pixil98/master
Add validate and release workflows
2022-12-05 14:54:39 -05:00
Aaron Reisman
11d59beeed Rename happens before zipping 2022-11-28 13:08:57 -06:00
Aaron Reisman
ef71e297f4 Add special handling for classic build 2022-11-28 12:54:17 -06:00
Aaron Reisman
1e4d1d1973 Lowercase OS names in releaseindex 2022-11-28 12:44:48 -06:00
pixil98
893d99854b Merge branch 'master' into master 2022-11-25 00:20:51 -06:00
Aaron Reisman
db93980cd5 Rename publish to release 2022-11-24 23:54:49 -06:00
pixil98
34fac30b2b Merge official updates (#6)
Pull latest Libation updates, fix move to net7
2022-11-24 23:53:00 -06:00
pixil98
2fa0bcb765 Near final workflows
Updated workflows to release zips with the correct file names.
2022-11-23 15:48:37 -06:00
pixil98
78fd09aa91 Proper build
Builds all packages properly
2022-11-22 10:45:33 -06:00
Robert McRackan
a54516b4f5 Fix pubxml hierarchy 2022-11-21 13:51:30 -05:00
Robert McRackan
f193d6f376 AudibleApi fixed 2022-11-17 17:05:58 -05:00
pixil98
8a82c294a1 Fix publish workflow (#2)
* Add dotnet test workflow

* main -> master

* Try a different workflow

* Add working-directory

* use windows runner

* use env var

* Fix build and test order

* Specify configuration

* Specify sln instead of working dir

* Specify that DOTNET_SLN is an env var

* Add publish workflow

* Add env.DOTNET_SLN to publish workflow

* Add publish job

* Combine publish into one job

* Just create an artifact

* Remove unused nuget lines

* Add Publish job back

Co-authored-by: Aaron Reisman <areisman@epic.com>
2022-11-16 13:40:57 -06:00
Robert McRackan
9392cf4bf0 update dependencies 2022-11-16 13:36:23 -05:00
Robert McRackan
ec4deb9099 update db dependencies 2022-11-16 12:48:22 -05:00
Robert McRackan
cf0548aab9 update dependencies 2022-11-16 12:46:12 -05:00
pixil98
064801380b Add workflows (#1)
Adds basic workflows
2022-11-16 11:44:15 -06:00
Robert McRackan
9dc2a7424a DataLayer => .net7 2022-11-16 11:49:07 -05:00
Robert McRackan
4c8a56a5b9 Core, Utilities => .net7 2022-11-16 11:47:34 -05:00
Robert McRackan
9aad263996 Domain Internal Utilities => .net7 2022-11-16 11:46:26 -05:00
Robert McRackan
ce1ab7c20d Domain Utilities => .net7 2022-11-16 11:45:04 -05:00
Robert McRackan
c9217990cd applications => .net7 2022-11-16 11:43:35 -05:00
Robert McRackan
90cbf3b7a6 tests => .net7 2022-11-16 11:27:51 -05:00
Robert McRackan
c4f1b22ddf demos => .net7 2022-11-16 11:24:30 -05:00
Robert McRackan
fb612ea6ab Bug fix #394 : Scanning dir.s containing symlinks causes errors. Thanks @CharlieRussel 2022-11-14 16:22:29 -05:00
Robert McRackan
bce44b6f6d Fix string interpolation bugs 2022-11-14 15:16:22 -05:00
Robert McRackan
7575736991 Bugfix #389 : Handle corrupt cache file 2022-11-08 07:22:08 -05:00
Robert McRackan
06f8d055fc update dependencies 2022-11-02 16:24:14 -04:00
Robert McRackan
d64e043fe8 #367 : New template option "year": year published to audible 2022-10-21 13:41:44 -04:00
Robert McRackan
99564d9c25 update dependencies 2022-10-18 08:33:43 -04:00
rmcrackan
29bccd3e33 Update InstallOnMac.md
Gatekeeper instructions
2022-10-05 22:25:40 -04:00
Robert McRackan
20f65f6534 Fix description of poorly named AutoDownloadEpisodes 2022-09-28 15:49:16 -04:00
Robert McRackan
8ca72b2e2d incr ver 2022-09-28 13:27:04 -04:00
Robert McRackan
75429f288f update dependencies 2022-09-28 13:25:41 -04:00
Robert McRackan
d1bb921346 Cache assembly fetches/resolution so that repeat errors aren't clogging the log 2022-09-24 09:48:44 -04:00
rmcrackan
b979b6ddad Update README.md 2022-09-24 07:54:48 -04:00
rmcrackan
4eba41ddbb Update GettingStarted.md 2022-09-23 21:48:16 -04:00
rmcrackan
418f5062ff Update README.md
remove unofficial linux instructions
2022-09-23 21:46:45 -04:00
rmcrackan
f736f7f909 Update GettingStarted.md
remove unofficial linux instructions
2022-09-23 21:46:06 -04:00
rmcrackan
96ead28246 Update Advanced.md
remove unofficial linux instructions
2022-09-23 21:45:28 -04:00
Robert McRackan
34bad7a53d fix string template 2022-09-19 14:08:46 -04:00
Robert McRackan
7ac1fff3a0 update dependencies 2022-09-09 11:53:59 -04:00
Robert McRackan
a4c5c53df3 incr ver 2022-09-09 11:10:56 -04:00
Robert McRackan
87db5cfd94 revert accidental re-name of button text 2022-09-09 11:04:14 -04:00
Robert McRackan
85e7bbf366 Chardonnay readonly textboxes should be grey (as they are in Classic) 2022-09-07 13:29:41 -04:00
Robert McRackan
c55c5fac23 typo 2022-09-06 12:47:44 -04:00
Robert McRackan
e25e2f7211 Update documentaion for macos 2022-08-31 15:44:12 -04:00
Robert McRackan
f310d583d8 Bug fix #364 - app was crashing on attempt to download PDF to which the user no longer had ownership. Eg: returned or Plus catalog 2022-08-29 15:05:56 -04:00
Robert McRackan
f05465b29b incr ver 2022-08-18 13:38:23 -04:00
rmcrackan
959e31972e Merge pull request #363 from Mbucari/master
Change assembly loadig
2022-08-18 13:36:08 -04:00
Michael Bucari-Tovo
17181811f0 Remove assembly hot loading 2022-08-18 11:21:40 -06:00
Michael Bucari-Tovo
6d2624d52b Fix comment 2022-08-18 10:59:37 -06:00
Michael Bucari-Tovo
9dd5940c8c Remove trailing wild 2022-08-18 10:59:00 -06:00
Michael Bucari-Tovo
1927d19961 comments 2022-08-18 10:47:53 -06:00
Michael Bucari-Tovo
09cc838bb4 Checks 2022-08-18 10:45:07 -06:00
Michael Bucari-Tovo
8af4c71101 Change assembly loadig 2022-08-18 10:29:30 -06:00
Robert McRackan
7ffdf45164 Bug fix #361 : import would break when audible erroneous duplicates a name in the author list or a name in the narrator list. (Note: the same name as both author and narrator has always been ok.) 2022-08-17 20:05:47 -04:00
Robert McRackan
e0999dc9ae Bug fix #358 : pdf downoad errors in CLI were crashing the rest of the loop 2022-08-16 15:41:12 -04:00
Robert McRackan
a0f3d44e97 revert changes to DownloadDecryptBook. This is not the correct fix 2022-08-16 14:54:12 -04:00
Robert McRackan
1510a86579 Bug fix #350 : support old style of large multi-part books 2022-08-16 10:14:13 -04:00
Robert McRackan
b3581455d2 incr ver. Don't re-use previously bad build number 2022-08-15 22:03:51 -04:00
Robert McRackan
8ee1019fa5 ConfigApp.s need PublishReadyToRun and RuntimeIdentifies 2022-08-15 22:01:38 -04:00
Robert McRackan
285b10a95f bug fix: WindowsConfigApp must explicitly load a type from the Dinah.Core.WindowsDesktop asm since avalonia doesn't reference it 2022-08-15 10:20:41 -04:00
Robert McRackan
0ca33f864b oops: recent bug fix introduced an infinite loop. fixed 2022-08-15 09:41:33 -04:00
Robert McRackan
a0823fa26c Bug fix. Book details dialog save button should also close the form 2022-08-14 21:53:34 -04:00
Robert McRackan
aa9040da5d Fix release pragma OS var.s 2022-08-14 19:35:01 -04:00
Robert McRackan
222031ecc5 avalonia ui: add new setting 2022-08-14 11:12:52 -04:00
Robert McRackan
dda8f5a974 publish profiles should point to Publish dir 2022-08-14 09:32:50 -04:00
Robert McRackan
e9b484df04 * LoadByOS build profiles
* incr ver
2022-08-14 09:19:14 -04:00
rmcrackan
d505264e86 Merge pull request #356 from Mbucari/master
Add useCoverAsFolderIconCb setting to avalonia
2022-08-13 18:33:38 -04:00
Michael Bucari-Tovo
c0b1f1dc0a Add useCoverAsFolderIconCb setting to avalonia 2022-08-12 18:37:02 -06:00
Robert McRackan
1524d558a4 * Feature #307 : New windows setting to use cover art as folder's icon. Incomplete. Need to add to avalonia settings
* Interop refactor
2022-08-12 17:55:15 -04:00
Robert McRackan
aea8c11dc4 Add OS-specific interop 2022-08-12 13:49:51 -04:00
Robert McRackan
86c7f89788 update dependencies 2022-08-08 11:47:08 -04:00
Robert McRackan
3272541e81 Audible changed how scanning works. You must upgrade to scan again 2022-08-02 21:20:14 -04:00
Robert McRackan
3b3d40e4e6 Add classic/chardonnay to About box 2022-08-02 14:40:58 -04:00
Robert McRackan
a47866b6f7 Open file/folder is now cross platform 2022-08-02 12:56:52 -04:00
Robert McRackan
0df4dfdef5 update dependencies 2022-08-02 09:14:36 -04:00
Robert McRackan
fe2de6ecf7 recommended: System.Environment.ProcessPath 2022-08-02 07:58:42 -04:00
Robert McRackan
fc25e73b1a attach avalonia primer notes 2022-08-01 20:56:08 -04:00
Robert McRackan
a3df85c87e Refactor hangover 2022-08-01 11:59:55 -04:00
Robert McRackan
553a936e7e incr ver 2022-08-01 09:49:45 -04:00
Robert McRackan
635764625e incl HangoverAvalonia in sln 2022-08-01 07:46:44 -04:00
rmcrackan
f5599f7c57 Merge pull request #338 from Mbucari/master
HangoverAvalonia and other fixes
2022-08-01 07:36:25 -04:00
Mbucari
dc6aaf2dd6 Updated instructions for Standalone Build (no dotnet runtime required) 2022-07-31 22:37:42 -06:00
Michael Bucari-Tovo
f1ba2b4ae8 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-31 22:24:28 -06:00
Michael Bucari-Tovo
742310b8d6 Fix install workflow 2022-07-31 22:24:17 -06:00
Mbucari
073787173d Merge branch 'rmcrackan:master' into master 2022-07-31 21:59:46 -06:00
Michael Bucari-Tovo
66679ace2f Add HangoverAvalonia 2022-07-31 20:33:56 -06:00
Robert McRackan
3982537d46 Tags no longer saved outside of database 2022-07-31 21:58:53 -04:00
Robert McRackan
7cf4c63d79 OSX-specific bug fix for search engine 2022-07-31 14:24:24 -04:00
Robert McRackan
7c4575cf66 incr ver 2022-07-30 22:39:41 -04:00
rmcrackan
f4749d703f Merge pull request #337 from Mbucari/master
Fixes and Improvements
2022-07-30 22:24:37 -04:00
Michael Bucari-Tovo
f2f562619b Updated dependencies 2022-07-30 20:09:17 -06:00
Robert McRackan
16c019a9c6 update dependencies 2022-07-30 21:54:10 -04:00
Robert McRackan
644dcbdd4d updated dependency 2022-07-30 21:40:31 -04:00
Michael Bucari-Tovo
6b112f5248 Delete obj and bin folders on clean 2022-07-30 18:03:33 -06:00
Michael Bucari-Tovo
0bfa609058 Libation Runs on MacOS 2022-07-30 16:09:31 -06:00
Michael Bucari-Tovo
8020ded642 Add platform preprocessor definitions 2022-07-30 13:42:11 -06:00
Michael Bucari-Tovo
c4cd6b16fc Add macOS ID 2022-07-30 11:04:01 -06:00
Michael Bucari-Tovo
310012fd17 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-30 11:03:19 -06:00
Michael Bucari-Tovo
06163db6ff Merge Conflict 2022-07-30 11:03:17 -06:00
Michael Bucari-Tovo
7689eed711 Add macOS identifier 2022-07-30 10:58:53 -06:00
Mbucari
d396d697d7 Fix typos 2022-07-30 10:33:04 -06:00
Michael Bucari-Tovo
27ed11d904 More universal updating 2022-07-30 09:49:11 -06:00
Michael Bucari-Tovo
9e7670b918 Fix Subdirectory being added to custom directory selection 2022-07-30 09:48:57 -06:00
Michael Bucari-Tovo
31e97defd1 Add ReleaseIdentifier to logging 2022-07-30 09:48:27 -06:00
Mbucari
1a447627c7 Merge branch 'rmcrackan:master' into master 2022-07-28 20:04:36 -06:00
Robert McRackan
962b386d07 Bug fix: update checking code 2022-07-28 21:35:59 -04:00
Michael Bucari-Tovo
d69ff24c2d Modularize update process 2022-07-28 17:18:43 -06:00
Michael Bucari-Tovo
070ed1d373 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-28 17:06:18 -06:00
Michael Bucari-Tovo
47729bf7b0 fix release getter 2022-07-28 17:06:12 -06:00
Robert McRackan
ed0ce2976b Bug fix #329 : Chardonnay-beta freezes after a scan 2022-07-28 13:26:16 -04:00
rmcrackan
2224f46ed5 Merge pull request #331 from Mbucari/master
Fix MessageBox hang
2022-07-28 13:22:03 -04:00
Michael Bucari-Tovo
433974323c Remove unnecessary extensions 2022-07-28 11:17:29 -06:00
Michael Bucari-Tovo
7525d318c0 Crean up helper methods 2022-07-28 11:03:22 -06:00
Michael Bucari-Tovo
92327dcc0d Add synchronous thread extensions 2022-07-28 10:40:39 -06:00
Michael Bucari-Tovo
aeaf234edd Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-28 10:13:28 -06:00
Michael Bucari-Tovo
a99b644917 Fix thread hang issue#329 2022-07-28 10:12:43 -06:00
Mbucari
d79a55e5c9 Merge branch 'rmcrackan:master' into master 2022-07-28 09:43:20 -06:00
Mbucari
16b0feeb82 Create feature request template 2022-07-28 09:43:11 -06:00
Mbucari
7b3a25e45a Create bug report template 2022-07-28 09:42:33 -06:00
Robert McRackan
8effdcb92d add macos publish options. standardize publish profiles 2022-07-28 10:43:00 -04:00
Robert McRackan
b12bef81bd These stupid unused language packs are 40% of our disk usage. And the SatelliteResourceLanguages bug *still* isn't fixed 2022-07-28 09:36:12 -04:00
Robert McRackan
f04a5e0168 tweaks to getLatestRelease 2022-07-27 16:36:18 -04:00
rmcrackan
e093729707 Merge pull request #325 from Mbucari/master
Add app update to Avalonia Build
2022-07-27 15:49:41 -04:00
Michael Bucari-Tovo
369151ada2 Revert timeout time 2022-07-27 09:55:09 -06:00
Michael Bucari-Tovo
1f685ae8a0 Add release index download 2022-07-27 09:49:58 -06:00
Mbucari
bbe91099cb Update .releaseindex.json 2022-07-27 09:45:29 -06:00
Mbucari
92015ba4c2 Add files via upload 2022-07-27 09:27:57 -06:00
Mbucari
3bcacabadc Delete appcasttest.xml 2022-07-27 01:03:24 -06:00
Mbucari
f5736d9151 Merge branch 'rmcrackan:master' into master 2022-07-27 00:46:04 -06:00
Michael Bucari-Tovo
59015f438e Add auto app update to windows avalonia 2022-07-27 00:36:13 -06:00
Michael Bucari-Tovo
3af47ab395 Add update name pattern matching 2022-07-27 00:35:55 -06:00
Michael Bucari-Tovo
308619b01a Fix bug if MessageBox called from worker thread 2022-07-27 00:30:35 -06:00
Robert McRackan
4efce57488 gitignore bin-Avalonia 2022-07-26 22:15:23 -04:00
Robert McRackan
c8ee950f7d Linux beta 2022-07-26 21:14:43 -04:00
Mbucari
0bba0f9256 Add files via upload 2022-07-26 12:53:49 -06:00
rmcrackan
05bdff5123 Merge pull request #321 from Mbucari/master
Linux Beta
2022-07-26 08:16:55 -04:00
Mbucari
e58e6cfb9f Update README.md 2022-07-25 19:51:35 -06:00
Mbucari
b052871004 Update README.md 2022-07-25 19:48:01 -06:00
Mbucari
d738f4f35f Update README.md 2022-07-25 17:33:59 -06:00
Michael Bucari-Tovo
7286aee9dd Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-25 17:22:29 -06:00
Michael Bucari-Tovo
ca455978a5 Update for Linux 2022-07-25 17:22:17 -06:00
Mbucari
9c38bea5b7 Update README.md 2022-07-25 17:21:35 -06:00
Michael Bucari-Tovo
fbec1bc569 Linux pub 2022-07-25 14:45:39 -06:00
Michael Bucari-Tovo
6dd885f0b2 Wrap save and restore in tyy/catch blocks 2022-07-25 08:19:46 -06:00
Mbucari
ab38eb5571 Update README.md 2022-07-25 00:05:46 -06:00
Michael Bucari-Tovo
0e4b9ab396 Build standalone 2022-07-25 00:04:52 -06:00
Michael Bucari-Tovo
7dfedbc73b Remove beta checkbox 2022-07-24 16:51:38 -06:00
Michael Bucari-Tovo
625ae1d63c Removed Avalonia from LibationWinForms 2022-07-24 16:42:38 -06:00
Michael Bucari-Tovo
71098ef02f Publish Profiles 2022-07-24 16:33:45 -06:00
Mbucari
d63a6de543 Update README.md 2022-07-24 16:33:19 -06:00
Mbucari
2a71a85306 Update README.md 2022-07-24 16:19:03 -06:00
Michael Bucari-Tovo
6de3a8a2bf Linux instructions 2022-07-24 16:14:34 -06:00
Michael Bucari-Tovo
3fc1da66de Linux compat 2022-07-24 14:46:27 -06:00
Michael Bucari-Tovo
683c221ca8 Linux compatability 2022-07-24 14:18:26 -06:00
Michael Bucari-Tovo
fe6cfc899b Add Avalonia setup 2022-07-24 13:04:19 -06:00
Michael Bucari-Tovo
ffd947eb2e A 2022-07-23 21:04:27 -06:00
Michael Bucari-Tovo
8dd59cb08a Refactor 2022-07-23 20:54:02 -06:00
Michael Bucari-Tovo
1e4c489983 Libation Runs on Linux! 2022-07-23 18:07:04 -06:00
Michael Bucari-Tovo
17b0da358f Add LinkLabel control 2022-07-22 20:11:13 -06:00
Michael Bucari-Tovo
6aa0a1f8b9 Remove references to winforms 2022-07-22 19:28:31 -06:00
Michael Bucari-Tovo
ab731a63af Tweak MessageBox 2022-07-22 19:20:47 -06:00
Michael Bucari-Tovo
07d2c656fc Add description text 2022-07-22 18:33:49 -06:00
Michael Bucari-Tovo
9ecb32c3d2 Added login dialogs 2022-07-22 18:25:47 -06:00
Michael Bucari-Tovo
503e1e143e Separate invalid char check for folders and files. Files can't have slashes. 2022-07-22 18:11:39 -06:00
Mbucari
e34ce67a2c Merge branch 'rmcrackan:master' into master 2022-07-22 12:39:09 -06:00
Robert McRackan
a0fd0a3de6 Book details dialog. On open, tags should be first focus 2022-07-22 11:15:04 -04:00
Robert McRackan
7f3cbc454f Bug fix #319 : in some cases mp3 chapter metadata was incorrect 2022-07-21 22:29:12 -04:00
Mbucari
30eb117fa1 Merge branch 'rmcrackan:master' into master 2022-07-21 10:05:12 -06:00
Robert McRackan
63877160aa New feature #170 : book details, added link to audible's page for that book 2022-07-21 09:02:42 -04:00
Robert McRackan
77e61479cf New feature #284 : Add bitrate, sample rate, and channels to template options and to exports 2022-07-21 08:37:04 -04:00
Michael Bucari-Tovo
ca71283108 Revert 2022-07-20 20:10:07 -06:00
Michael Bucari-Tovo
285563af5e Revert 2022-07-20 20:08:53 -06:00
Michael Bucari-Tovo
62cbad0d8f Commit works in progress 2022-07-20 19:41:56 -06:00
Michael Bucari-Tovo
2cb2479d63 Added EditTemplateDialog and LibationFilesDialog 2022-07-20 13:35:30 -06:00
Mbucari
e7c5b1d8dc Merge branch 'rmcrackan:master' into master 2022-07-19 14:27:11 -06:00
Robert McRackan
7f086aeaac Bug fix #318: Audible changed their API, likely in conjunction with shutting down the Windows App. DownloadQuality.Extreme and DownloadQuality.Low now throw errors 2022-07-19 15:02:31 -04:00
Robert McRackan
78186d4973 update dependencies 2022-07-19 09:32:26 -04:00
Robert McRackan
4d84174ba6 AudibleApi: Make exceptions more flexible so that less logic is needed inside catch 2022-07-19 08:19:03 -04:00
Robert McRackan
579536f65a Revert: project publish => sln publish 2022-07-19 07:43:34 -04:00
Michael Bucari-Tovo
a4ff739684 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-18 23:01:20 -06:00
Michael Bucari-Tovo
9e06c70319 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-18 23:00:55 -06:00
Michael Bucari-Tovo
0c98ce000b Added SettingsDialog 2022-07-18 23:00:40 -06:00
Robert McRackan
230b23dc80 on startup, log BetaOptIn 2022-07-18 22:15:11 -04:00
Robert McRackan
d55b8eeeba Turn 'self contained' back on 2022-07-18 20:56:09 -04:00
rmcrackan
decf75411f Merge pull request #313 from Mbucari/master
Fixed hidden items being duplicated on library scan
2022-07-18 15:08:04 -04:00
Mbucari
c69f14dac5 Merge branch 'rmcrackan:master' into master 2022-07-18 12:37:13 -06:00
Michael Bucari-Tovo
10359aa5e8 Fixed hidden items being duplicated on library scan 2022-07-18 12:36:53 -06:00
Robert McRackan
72e030faaf Include beta feature: cross-platform UI 2022-07-18 14:26:19 -04:00
rmcrackan
b21055d0ea Merge pull request #312 from Mbucari/master
Avalonia Beta release
2022-07-18 14:15:15 -04:00
Michael Bucari-Tovo
720fd64c97 Fixed visible books count not updating 2022-07-18 10:07:13 -06:00
Michael Bucari-Tovo
e9a331292a Remove Commit method 2022-07-17 01:26:18 -06:00
Michael Bucari-Tovo
51fee4ae24 Remain classes and fix adding row to EditTagsDialog 2022-07-17 00:59:26 -06:00
Michael Bucari-Tovo
4cfe72a63b Add WheelComboBox 2022-07-17 00:44:32 -06:00
Michael Bucari-Tovo
6a8476c976 Undo change 2022-07-17 00:07:04 -06:00
Michael Bucari-Tovo
8bb17d09c3 Added beta opt-in setting 2022-07-16 23:57:12 -06:00
Michael Bucari-Tovo
ad6b86fcb4 Added DescriptionDisplayDialog and ImageDisplayDialog 2022-07-16 23:27:56 -06:00
Michael Bucari-Tovo
1578be2520 Added MessageBoxAlertAdmin 2022-07-16 22:04:00 -06:00
Michael Bucari-Tovo
82d8d954ef Added EditQuickFilters dialog 2022-07-16 21:24:07 -06:00
Michael Bucari-Tovo
eff9c2b35d Added AccountsDialog 2022-07-16 20:47:53 -06:00
Michael Bucari-Tovo
ccdd1dc9f3 Added BookDetailsDialog, LiberatedStatusBatchDialog, ScanAccountsDialog, SearchSyntaxDialog and TagsBatchDialog 2022-07-16 17:47:54 -06:00
Michael Bucari-Tovo
952173d450 Added book details dialog 2022-07-16 15:06:37 -06:00
Michael Bucari-Tovo
35f677a0fa Added gridlines 2022-07-15 20:57:22 -06:00
Michael Bucari-Tovo
51d0645699 Add stasrt time testing 2022-07-15 17:05:13 -06:00
Michael Bucari-Tovo
0189a197a8 Refactoring 2022-07-15 16:36:58 -06:00
Michael Bucari-Tovo
1ce5fedc8c Refactor ProductDisplay 2022-07-15 15:58:21 -06:00
Michael Bucari-Tovo
d336848ed0 Change how large cover image viewer loads images 2022-07-15 15:42:34 -06:00
Michael Bucari-Tovo
8cd6219bd9 Performance improvements and better mvvp pattern following 2022-07-15 15:16:27 -06:00
Michael Bucari-Tovo
c2a2e51bde Improve re-display function 2022-07-15 13:09:19 -06:00
Michael Bucari-Tovo
d62821cd60 Refactor 2022-07-15 01:06:55 -06:00
Michael Bucari-Tovo
180d591b0a Make Form1 MVVM 2022-07-15 00:23:22 -06:00
Michael Bucari-Tovo
7b7e1d8574 Further sorting and remove books refinements 2022-07-14 21:14:40 -06:00
Michael Bucari-Tovo
efd6156fa8 Fix STAThread error 2022-07-14 18:25:28 -06:00
Michael Bucari-Tovo
428ea5e864 Improve AvaloniaUI startup times 2022-07-14 17:57:46 -06:00
Michael Bucari-Tovo
2b6d1201b6 Add save and restore form size 2022-07-14 15:41:30 -06:00
Michael Bucari-Tovo
de3524d688 refine message box. 2022-07-14 13:26:36 -06:00
Michael Bucari-Tovo
61a529e62b MessageBox revision and more async loading 2022-07-14 12:51:50 -06:00
Michael Bucari-Tovo
a5d225dc44 Minor refactor 2022-07-14 02:46:45 -06:00
Michael Bucari-Tovo
7b28a274a8 Startup speedup 2022-07-14 02:35:38 -06:00
Michael Bucari-Tovo
26508e6a8a Speed up start time 2022-07-14 02:18:26 -06:00
Michael Bucari-Tovo
c8d91032c0 Refactor 2022-07-14 01:07:07 -06:00
Michael Bucari-Tovo
7a8e910697 Add Avalonia MessageBox 2022-07-14 00:50:50 -06:00
Michael Bucari-Tovo
31d6fc8197 Refactor 2022-07-13 19:03:52 -06:00
Michael Bucari-Tovo
e23e267d17 Add column customizations 2022-07-13 18:47:43 -06:00
Michael Bucari-Tovo
c727286d22 Move ProcessQueue biz logic into viewmodel 2022-07-13 17:06:18 -06:00
Michael Bucari-Tovo
3a61c32881 Fix sorting and refactor 2022-07-13 16:07:05 -06:00
Michael Bucari-Tovo
e33fd6ea1b Default invisible 2022-07-13 02:23:55 -06:00
Michael Bucari-Tovo
aa8e3ac09b More sorting hacking 2022-07-13 02:21:05 -06:00
Michael Bucari-Tovo
eb49dcfc54 Incremental prgress. 2022-07-13 01:14:05 -06:00
Michael Bucari-Tovo
6182b2bcee Improve styles and fix sotring of podcasts when they are collapsed. 2022-07-12 22:01:11 -06:00
Michael Bucari-Tovo
6e091230cf Use ReactiveUI.
Sort of fix remove book checkbox column.
2022-07-12 18:56:25 -06:00
Michael Bucari-Tovo
5f45d28b9f Refinements 2022-07-12 00:18:56 -06:00
Michael Bucari-Tovo
f8e9c16bc1 Change some defaults 2022-07-11 21:57:41 -06:00
Michael Bucari-Tovo
a66b7a6eab Add queue log and improve display styles 2022-07-11 21:43:20 -06:00
Michael Bucari-Tovo
3b42b52ff4 Improve sorting 2022-07-11 19:07:20 -06:00
Michael Bucari-Tovo
df5293ce1e Fix bug caused by moving column before frozen "Remove" column 2022-07-11 12:58:20 -06:00
Michael Bucari-Tovo
664ff6aabd Merge branch 'master' of https://github.com/Mbucari/Libation 2022-07-11 00:13:41 -06:00
Michael Bucari-Tovo
0de62ce010 Port Form1 to Avalonia 2022-07-11 00:13:32 -06:00
Robert McRackan
9eafbacad9 Add Audible ID to BookDetailsDialog 2022-07-08 23:04:05 -04:00
Robert McRackan
058eb31110 trivial change to test local github account settings 2022-07-08 21:43:10 -04:00
Robert McRackan
29de8f5706 trivial change to test github settings 2022-07-08 21:42:17 -04:00
Robert McRackan
ef869dbe09 new publish settings moved to Libation's settings in GitHubReleaser
old sizes
zipped 70.5 MB
unzipped 164 MB

new sizes
zipped 22.5 MB
unzipped 52.4 MB
2022-07-08 14:59:31 -04:00
Robert McRackan
9f8b320493 no longer needed 2022-07-07 21:43:51 -04:00
Robert McRackan
ef72e04be3 Oops! I only meant to get rid of the bump, not the embedded. Reverting 2022-07-07 16:47:08 -04:00
Robert McRackan
38d280b7f4 v8.1.7 2022-07-07 14:22:05 -04:00
Robert McRackan
468356d676 increm ver 2022-07-06 16:52:37 -04:00
rmcrackan
7364700899 Merge pull request #305 from Mbucari/master
Fix some bugs with user settings.
2022-07-06 16:50:32 -04:00
Michael Bucari-Tovo
e65f19cf24 Restore tool 2022-07-06 14:23:03 -06:00
Michael Bucari-Tovo
4272dfe03d Reformat for style 2022-07-06 14:18:53 -06:00
Michael Bucari-Tovo
3b739328fb Fix some bugs with user settings. 2022-07-06 13:10:37 -06:00
Robert McRackan
81c3dca740 increm ver 2022-06-26 17:08:55 -04:00
rmcrackan
dceb3121b1 Merge pull request #300 from Mbucari/master
Option to combine Opening/End Credits chapters and other changes
2022-06-26 16:12:17 -04:00
Michael Bucari-Tovo
cb60a97b91 Embed PDBs 2022-06-26 13:26:36 -06:00
Michael Bucari-Tovo
eb658396d2 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-26 13:16:31 -06:00
Michael Bucari-Tovo
0a1cefdb76 Update audible version 2022-06-26 13:16:27 -06:00
Robert McRackan
fb618e6719 api bug fix 2022-06-26 15:09:07 -04:00
Mbucari
2d529539cd Merge branch 'rmcrackan:master' into master 2022-06-26 13:02:02 -06:00
Michael Bucari-Tovo
9d93a98a58 Update reference 2022-06-26 13:01:40 -06:00
Michael Bucari-Tovo
38dcb10a6e Update reference 2022-06-26 12:59:02 -06:00
Michael Bucari-Tovo
50651339ec Don't throw on unidentified series. 2022-06-26 11:40:48 -06:00
Robert McRackan
d0b2889fec Bug fix #294 mp3s which are split by chapter 2022-06-26 12:55:19 -04:00
Michael Bucari-Tovo
3ce1f94f87 Revert preview feature 2022-06-26 10:42:52 -06:00
Michael Bucari-Tovo
888967be31 Pack files, not folder. 2022-06-26 01:22:46 -06:00
Michael Bucari-Tovo
6826237657 Use powershell script to publish and zip libation 2022-06-26 01:16:17 -06:00
Michael Bucari-Tovo
a8987cf1d3 Only increment build number on debug builds 2022-06-25 17:06:28 -06:00
Michael Bucari-Tovo
d48a74912a Use abstract static member, add publish script 2022-06-25 16:48:23 -06:00
Mbucari
1668b7c9a1 Merge branch 'rmcrackan:master' into master 2022-06-25 13:43:50 -06:00
Robert McRackan
efa2cfb50b Bug fix #294 2022-06-25 14:50:14 -04:00
Michael Bucari-Tovo
071b1a54d5 Publish Embedded 2022-06-25 05:11:21 -06:00
Michael Bucari-Tovo
7c3bba2ffd Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-24 23:27:53 -06:00
Michael Bucari-Tovo
d58092968a Add option to merge Opening/End Credits with following/preceding chapters 2022-06-24 23:26:52 -06:00
Michael Bucari-Tovo
1b20bb06ad Add some filename length headroom in case of diplicate files and " (n)" suffix. 2022-06-24 23:23:08 -06:00
Michael Bucari-Tovo
5815a04712 Add bitrate to Book 2022-06-24 23:09:20 -06:00
Robert McRackan
85c449bec0 Logging and error handling for issue #294 2022-06-24 07:27:26 -04:00
rmcrackan
10bdddb262 Merge pull request #296 from Mbucari/master
Error handling inside all click handlers
2022-06-24 07:15:32 -04:00
Michael Bucari-Tovo
b65875386d Add error handling to ProductsGrid.DataGridView_CellContentClick 2022-06-23 22:32:43 -06:00
Michael Bucari-Tovo
76b5e09f72 Add error handling around all ui click handlers for book downloads. 2022-06-23 21:17:43 -06:00
Michael Bucari-Tovo
0fe07695b2 Make better use of hierarchical chapters and add test 2022-06-23 20:45:41 -06:00
Michael Bucari-Tovo
51f9b4f473 More character replacement safety 2022-06-23 20:45:09 -06:00
Robert McRackan
153e1b92bf Bug fixes, logging, options for how to handle illegal characters 2022-06-23 21:01:44 -04:00
rmcrackan
fc5ae7403a Merge pull request #295 from Mbucari/master
Optional illegal character replacement and more error handling/logging
2022-06-23 20:56:58 -04:00
Michael Bucari-Tovo
13149eff08 Make better use of heirarch chapters to combine section title audio (which is usually short, eg "Part 1") with the following full-length chapter. 2022-06-23 17:29:45 -06:00
Michael Bucari-Tovo
9c53d9bf87 Better open/close quote detection 2022-06-23 16:52:13 -06:00
Michael Bucari-Tovo
bc9625fece Disallow illegal chars in templates 2022-06-23 16:36:56 -06:00
Michael Bucari-Tovo
7e00162ef2 Code reuse and better naming 2022-06-23 16:28:21 -06:00
Michael Bucari-Tovo
af38750e29 Fix reverted changes 2022-06-23 16:19:00 -06:00
Michael Bucari-Tovo
314f4850bc Add logging and error handling to Process Queue. and Processables 2022-06-23 15:38:39 -06:00
Michael Bucari-Tovo
9ff2a83ba3 Rename Minimum to Barebones 2022-06-23 13:11:35 -06:00
Michael Bucari-Tovo
2ab466c570 Custom illegal character replacement 2022-06-23 13:01:24 -06:00
Mbucari
184ba84600 Merge branch 'rmcrackan:master' into master 2022-06-23 11:35:09 -06:00
Michael Bucari-Tovo
99dddb1af4 Revert "* bug fix: occasional hang bug in process queue"
This reverts commit b7fd87b09c.
2022-06-23 11:34:50 -06:00
Michael Bucari-Tovo
48eca3f5af Revert "Add character replacement"
This reverts commit 1470aefd42.
2022-06-23 11:34:39 -06:00
Michael Bucari-Tovo
71192cc2ee Revert "Match rmcrackan's changes"
This reverts commit 52622fadbb.
2022-06-23 11:34:29 -06:00
Michael Bucari-Tovo
29c7344540 Revert "linux + WINE link"
This reverts commit eff2634b32.
2022-06-23 11:34:24 -06:00
Michael Bucari-Tovo
6411d23744 Revert "Non-null disposed BlockingCollection can throw exception"
This reverts commit ba722487d8.
2022-06-23 11:34:20 -06:00
Michael Bucari-Tovo
1a74736115 Revert "Improve display and function of character replacement"
This reverts commit b698697256.
2022-06-23 11:34:11 -06:00
Michael Bucari-Tovo
7c11ecb3a7 Revert "Change type"
This reverts commit 839a62cb07.
2022-06-23 11:34:07 -06:00
Michael Bucari-Tovo
fd7c833de0 Revert "make auto-scan more fault-tolerant"
This reverts commit f802d1524f.
2022-06-23 11:34:00 -06:00
Michael Bucari-Tovo
7fec8b0d7e Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-23 11:07:18 -06:00
Michael Bucari-Tovo
52622fadbb Match rmcrackan's changes 2022-06-23 11:07:10 -06:00
Robert McRackan
57255e0aec comments 2022-06-23 07:53:12 -04:00
rmcrackan
17ecfa132d Merge pull request #293 from Dr-Blank/master
Spellcheck in Comments and Strings
2022-06-23 06:53:54 -04:00
Dr-Blank
d1365c3d7d Spellcheck in Comments and Strings
Corrected some spellings in Display messages and Comments.
2022-06-22 23:35:54 -04:00
Robert McRackan
c33891a4bc update dependencies 2022-06-22 22:13:56 -04:00
Michael Bucari-Tovo
9a63f57147 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-22 08:01:48 -06:00
Michael Bucari-Tovo
839a62cb07 Change type 2022-06-22 08:01:39 -06:00
Mbucari
dc598e466e Merge branch 'rmcrackan:master' into master 2022-06-21 23:40:21 -06:00
Michael Bucari-Tovo
b698697256 Improve display and function of character replacement 2022-06-21 23:39:24 -06:00
Robert McRackan
f802d1524f make auto-scan more fault-tolerant 2022-06-21 22:44:25 -04:00
Mbucari
0cb18f9e1a Merge branch 'rmcrackan:master' into master 2022-06-21 20:17:27 -06:00
Robert McRackan
ba722487d8 Non-null disposed BlockingCollection can throw exception 2022-06-21 21:08:49 -04:00
rmcrackan
eff2634b32 linux + WINE link 2022-06-21 20:54:38 -04:00
Michael Bucari-Tovo
1470aefd42 Add character replacement 2022-06-21 18:50:30 -06:00
Robert McRackan
b7fd87b09c * bug fix: occasional hang bug in process queue
* bug fix: #283 template folders
2022-06-21 10:42:57 -04:00
rmcrackan
ab82a1656d Merge pull request #282 from Mbucari/master
Fixed rare bug that would hang if an error occured while downloading
2022-06-21 10:34:31 -04:00
Michael Bucari-Tovo
71387e94d8 Fix bug if folder ended in trailing slash 2022-06-21 08:08:09 -06:00
Michael Bucari-Tovo
503379079b Fix WaitToPosition logic 2022-06-21 00:23:02 -06:00
Michael Bucari-Tovo
1ae767087f Check downloadEnded inside WaitToPosition 2022-06-20 23:13:34 -06:00
Michael Bucari-Tovo
cfd2b7b7aa Fixed rare bug that would cause a hang if an error occured in the download loop 2022-06-20 22:36:14 -06:00
Robert McRackan
2c42b4c585 * #278 -- new hier. chapters format
* #281 -- template bug fix

Thanks for the quick turn-around, @MBucari !
2022-06-20 21:02:15 -04:00
rmcrackan
d3a9ff539e Merge pull request #280 from Mbucari/master
Add support for Audible's new  hierarchical chapters
2022-06-20 20:57:06 -04:00
Michael Bucari-Tovo
58f01bd642 Fix possible x-thread error. 2022-06-20 18:45:44 -06:00
Michael Bucari-Tovo
38806740e1 Use Path.Join instead of string.Join 2022-06-20 18:12:29 -06:00
Michael Bucari-Tovo
df583e73c2 Fixed file naming template 2022-06-20 17:37:52 -06:00
Michael Bucari-Tovo
e787d33e5a Fix NRE on cancel when there's nothing to cancel. 2022-06-20 16:47:25 -06:00
Michael Bucari-Tovo
91db665428 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-20 15:45:00 -06:00
Michael Bucari-Tovo
94d155cff2 Add support for Audible's new hierarchical chapters. 2022-06-20 15:41:37 -06:00
Robert McRackan
ad79075fd7 Fix issues #183 and #186 2022-06-20 16:30:40 -04:00
rmcrackan
7baefe2f44 Merge pull request #277 from Mbucari/master
Issues #183 and #186, and a lot of other little things
2022-06-20 16:29:20 -04:00
Michael Bucari-Tovo
141a4c29bb Correct error in saving settings 2022-06-20 14:04:03 -06:00
Michael Bucari-Tovo
b2992da370 Move DownloadOptions to FileLiberator 2022-06-20 10:22:21 -06:00
Michael Bucari-Tovo
fdee254020 Only copy files if conversion succeeded. 2022-06-20 09:04:06 -06:00
Michael Bucari-Tovo
c51489ac74 Await cancell 2022-06-19 18:49:47 -06:00
Michael Bucari-Tovo
3cd394ec10 Change unicode asterisk 2022-06-19 18:04:00 -06:00
Michael Bucari-Tovo
8374fea776 Update tests for unicode chars 2022-06-19 17:59:16 -06:00
Michael Bucari-Tovo
733ca891de Fix unicode replacement 2022-06-19 17:29:46 -06:00
Michael Bucari-Tovo
490d121db3 Add unicode replacements for illegal characters 2022-06-19 16:57:44 -06:00
Michael Bucari-Tovo
45c5efffbd Add support for multipart title naming templates 2022-06-19 15:42:21 -06:00
Michael Bucari-Tovo
a24c929acf Update tests for long file paths 2022-06-19 15:38:59 -06:00
Michael Bucari-Tovo
86a39f10d1 Formatting 2022-06-19 12:59:35 -06:00
Michael Bucari-Tovo
4658afdc20 Add Track Number support and make Cancel async 2022-06-19 12:56:33 -06:00
Michael Bucari-Tovo
ae6c2afb30 Improve filename template 2022-06-18 13:04:57 -06:00
Michael Bucari-Tovo
a3844a3535 Add long path support 2022-06-18 11:28:48 -06:00
Michael Bucari-Tovo
b710075544 Make use of unauthenticated API 2022-06-17 23:09:22 -06:00
Mbucari
c4c9786050 Merge branch 'rmcrackan:master' into master 2022-06-17 16:46:31 -06:00
Robert McRackan
b4cc81139a Bug fix ( #276 ): x-thread error on fresh install 2022-06-17 12:40:37 -04:00
Mbucari
fb20eb9162 Merge branch 'rmcrackan:master' into master 2022-06-15 14:22:09 -06:00
Robert McRackan
263987d2c9 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-06-15 10:43:04 -04:00
Robert McRackan
0b30a35383 updated dependencies 2022-06-15 10:42:56 -04:00
Mbucari
47df1fc602 Merge branch 'rmcrackan:master' into master 2022-06-14 10:46:00 -06:00
rmcrackan
d8375454b9 Merge pull request #274 from maaximal/spelling
Fix spelling error
2022-06-14 09:16:05 -04:00
Max Byszio
ad535501c4 Fix spelling error 2022-06-14 14:43:21 +02:00
Michael Bucari-Tovo
159f5cbd00 Add lame options to ConvertToMp3 2022-06-13 22:18:00 -06:00
Michael Bucari-Tovo
2bc74d5378 Combine Streamable and Processable, remove unused events. 2022-06-13 21:40:37 -06:00
Robert McRackan
eb513f563e Allow sorting by "Remove" column 2022-06-13 13:55:39 -04:00
rmcrackan
09dc5e9846 Merge pull request #273 from Mbucari/master
Add option to save episodes to series parent
2022-06-13 12:00:05 -04:00
Mbucari
cf35a87d85 Update AppScaffolding.csproj 2022-06-12 19:48:51 -06:00
Michael Bucari-Tovo
9f25f619a8 Formatting 2022-06-12 19:37:42 -06:00
Michael Bucari-Tovo
7e989c730c Add option to save podcasts to series folder 2022-06-12 19:36:18 -06:00
Robert McRackan
0926e86956 Version 8 2022-06-12 21:20:02 -04:00
rmcrackan
75967730fd Merge pull request #271 from Mbucari/master
Move Remove Books function into main grid, added more db migrations and fixups for episodes
2022-06-12 21:14:51 -04:00
Michael Bucari-Tovo
a3be3e354f Code readability changes 2022-06-12 17:30:11 -06:00
Michael Bucari-Tovo
58c52196f1 Remove Books button now on Main button row 2022-06-12 17:03:29 -06:00
Michael Bucari-Tovo
b7b49a60cf Migration Exception handling 2022-06-12 16:35:48 -06:00
Michael Bucari-Tovo
fa195483d6 Set Import/Esport initial directory 2022-06-12 16:29:33 -06:00
Michael Bucari-Tovo
2341f6ea3b Better display and hiding of process queue 2022-06-12 16:29:06 -06:00
Michael Bucari-Tovo
ffe0f0730d Don't fire click for error books 2022-06-12 15:27:10 -06:00
Michael Bucari-Tovo
23b512910e Update 2022-06-12 15:23:55 -06:00
Michael Bucari-Tovo
b1c624b104 Revised stoplight icons 2022-06-12 15:17:07 -06:00
Michael Bucari-Tovo
fe35be6682 New libation icons 2022-06-12 13:39:35 -06:00
Michael Bucari-Tovo
2d3eb29bd5 Move event invoke out of lock 2022-06-11 19:10:08 -06:00
Michael Bucari-Tovo
26f0ff62df Additional safety check 2022-06-11 15:10:18 -06:00
Michael Bucari-Tovo
5e145846bd Only check non-liberated books when doing scan remove books. 2022-06-11 12:42:00 -06:00
Michael Bucari-Tovo
1ae5f99bf0 Add migration to try and fix db for incorrect or missing espiode series entries. 2022-06-11 12:41:20 -06:00
Michael Bucari-Tovo
984119c7ee Exit download loop if zero bytes are read. 2022-06-10 21:00:04 -06:00
Michael Bucari-Tovo
f8f5eac109 Refactor 2022-06-10 20:45:10 -06:00
Michael Bucari-Tovo
4111d5fa48 Remove redundant declarations. 2022-06-10 19:37:50 -06:00
Michael Bucari-Tovo
2eca9056b9 Reorder api calls 2022-06-10 19:36:00 -06:00
Michael Bucari-Tovo
60e96572ff Always refresh token, regardless of expiration date. 2022-06-10 19:34:49 -06:00
Michael Bucari-Tovo
52193933b2 Add scan and remove books tomain view, remove separate dialog. 2022-06-10 19:22:54 -06:00
Michael Bucari-Tovo
7bcabdda38 FindInactiveBooks now fires ScanBegin and ScanEnd events 2022-06-10 18:30:16 -06:00
Mbucari
d993941c4d Merge branch 'rmcrackan:master' into master 2022-06-10 15:41:07 -06:00
Michael Bucari-Tovo
b447bff9a6 Add audible-cli import/export accounts 2022-06-10 15:19:05 -06:00
Robert McRackan
73cb5ffba4 clearly Hoopla integration isn't going to happen. delete temp files 2022-06-09 17:01:33 -04:00
Robert McRackan
7d694229c1 Prep for version release 2022-06-08 14:48:44 -04:00
rmcrackan
cdb6c9a1a4 Merge pull request #268 from Mbucari/master
Address issues in 263
2022-06-08 14:46:17 -04:00
Michael Bucari-Tovo
cc1d2b423f Fix an oopsie 2022-06-08 12:15:21 -06:00
Michael Bucari-Tovo
508e031143 Move all event invocations outside locks 2022-06-08 12:08:15 -06:00
Michael Bucari-Tovo
5a093a9a04 add event keyword 2022-06-08 10:53:45 -06:00
Michael Bucari-Tovo
074d647d19 Improve Query 2022-06-08 10:36:06 -06:00
Michael Bucari-Tovo
6cb98f99c5 Use new content type queries 2022-06-08 10:34:05 -06:00
Michael Bucari-Tovo
7d28681b23 Move queries into DataLayer 2022-06-08 10:08:18 -06:00
Michael Bucari-Tovo
859a8e933c Formatting 2022-06-08 09:46:11 -06:00
Michael Bucari-Tovo
a476d5986d Update dependency 2022-06-08 09:44:06 -06:00
Michael Bucari-Tovo
31812bc2d9 Refactoring 2022-06-08 09:24:06 -06:00
Michael Bucari-Tovo
30ba69eca7 Minor refactoring. 2022-06-08 08:52:25 -06:00
Michael Bucari-Tovo
cf1bc1c252 By defauly, only get actual books and not parents from DB 2022-06-08 08:40:25 -06:00
Michael Bucari-Tovo
ee109ba67d Refactor 2022-06-08 08:39:59 -06:00
Michael Bucari-Tovo
9c6211e8e0 Improve UI speed when adding many books to queue at once. 2022-06-08 08:39:17 -06:00
Michael Bucari-Tovo
0729e4ab09 Minor refactor 2022-06-07 15:41:33 -06:00
Michael Bucari-Tovo
5cbe728631 Don't add series parents to list 2022-06-07 15:32:49 -06:00
Michael Bucari-Tovo
920f4df213 Use new ContentType.Parent to add series info to grid display 2022-06-07 15:28:16 -06:00
Michael Bucari-Tovo
c48eacd9af Add ContentType.Parent
Import Series parent when only individual episodes are in library
2022-06-07 15:27:18 -06:00
Michael Bucari-Tovo
30e6deeeaa Add migration to cleans DB of 7.10.1 hack 2022-06-07 15:25:52 -06:00
Robert McRackan
5bc76a3160 New debugging tool: "Hangover". Will be packaged with all releases 2022-06-01 11:49:30 -04:00
Robert McRackan
114925ebce Global exception handling. Threadsafe MessageBoxAlertAdminDialog 2022-05-27 13:38:28 -04:00
Robert McRackan
5a80a0cc06 Second bug fix for issue 263 2022-05-27 07:15:15 -04:00
rmcrackan
aebefac7e6 Merge pull request #266 from Mbucari/master
Fix my own screwup
2022-05-27 07:10:02 -04:00
Michael Bucari-Tovo
b2d0ee41f2 Fix my own screwup 2022-05-26 21:26:56 -06:00
Robert McRackan
9c20250b0a increm ver 2022-05-26 21:10:12 -04:00
rmcrackan
b196836fca Merge pull request #264 from Mbucari/master
Fix for episodes with no series link
2022-05-26 20:41:55 -04:00
Michael Bucari-Tovo
d9fbcc615a Change flow 2022-05-26 18:06:44 -06:00
Michael Bucari-Tovo
fb247fb33f Add better handling for parents and series with no children. 2022-05-26 17:29:55 -06:00
Michael Bucari-Tovo
61f4dbd896 No need to make a new list. 2022-05-26 16:50:43 -06:00
Michael Bucari-Tovo
2c86571818 Better identification of Chilv vs Parent from SeriesBook.Order 2022-05-26 16:49:03 -06:00
Michael Bucari-Tovo
1b2ec67726 Add series info for parent will null order. 2022-05-26 16:43:56 -06:00
Michael Bucari-Tovo
845af854bd Add exception handling to products display 2022-05-26 16:29:40 -06:00
Mbucari
15b6a66d98 Merge branch 'rmcrackan:master' into master 2022-05-26 16:13:13 -06:00
Michael Bucari-Tovo
c95ba0764b Fix bug and add groundwork for future feature 2022-05-26 16:11:52 -06:00
Robert McRackan
42c0648ba7 Bug fix #262 : 'file not found' after moved dir 2022-05-26 16:11:03 -04:00
Robert McRackan
0a6e55dcb7 * Much faster library scans
* Libraries of unlimited size now supported (prev limit was 10k)
2022-05-26 11:33:05 -04:00
rmcrackan
99b77decff Merge pull request #260 from Mbucari/master
Throttle episode scanning to 10 concurrent scans.
2022-05-26 11:29:18 -04:00
Robert McRackan
9e2ca4e586 update dependencies 2022-05-26 10:45:57 -04:00
Michael Bucari-Tovo
2e8acfdeef Limnit episode concurrency to 5 2022-05-26 08:44:39 -06:00
Michael Bucari-Tovo
630096e06d Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-26 08:43:33 -06:00
Michael Bucari-Tovo
d92d892dc7 logging 2022-05-25 20:45:18 -06:00
Michael Bucari-Tovo
a8f41841bd Throttle episode scanning to 10 concurrent scans. 2022-05-25 20:43:12 -06:00
rmcrackan
76954b5a0a Merge pull request #258 from Mbucari/master
Add support for unlimited library size.
2022-05-25 22:00:22 -04:00
Michael Bucari-Tovo
c57b184a09 Remove test params 2022-05-25 16:51:25 -06:00
Michael Bucari-Tovo
20ca4e0739 Refactor for clarity. 2022-05-25 16:49:22 -06:00
Mbucari
a972ed5e2e Merge branch 'rmcrackan:master' into master 2022-05-25 16:05:31 -06:00
Michael Bucari-Tovo
2b15bc6ebb Count Items as they come in and log total. 2022-05-25 15:11:38 -06:00
Robert McRackan
f7a482659c New feature #241 : Auto download episodes after scanning. Setting is on Import Library tab 2022-05-25 15:21:28 -04:00
Robert McRackan
99527453a7 add TODO 2022-05-25 12:56:34 -04:00
Robert McRackan
3408b4637c search engine cleanup 2022-05-25 12:49:24 -04:00
Robert McRackan
3f2899e97e * New event SearchEngineCommands.SearchEngineUpdated
* Clean up redundant event notifications
2022-05-25 10:09:27 -04:00
Michael Bucari-Tovo
562496cfaa Add more logging 2022-05-24 21:36:56 -06:00
Michael Bucari-Tovo
8283f19d6b Parallelize getChildEpisodesAsync 2022-05-24 21:17:59 -06:00
Michael Bucari-Tovo
242909b542 Don't import empty episode 2022-05-24 18:39:47 -06:00
Michael Bucari-Tovo
a7b83ad5e0 Remove 10,000 book limitation and simplify episode import 2022-05-24 18:27:20 -06:00
Michael Bucari-Tovo
ed66019d9a Cleanup 2022-05-24 18:24:53 -06:00
Michael Bucari-Tovo
bc0009be6c Use event return value instead of passing a set delegate. 2022-05-24 15:47:30 -06:00
Michael Bucari-Tovo
c88f47eed4 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-24 15:34:09 -06:00
Michael Bucari-Tovo
59de048ced Error handling network error. 2022-05-24 15:33:52 -06:00
Robert McRackan
7987dfb819 Rename 'liberate visible' menu items. Similar names are error-prone 2022-05-24 15:45:56 -04:00
Robert McRackan
1b101106e7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-05-24 15:32:42 -04:00
rmcrackan
7b75955aec Merge pull request #257 from Mbucari/master
Fix hang on grid update
2022-05-24 15:32:29 -04:00
Michael Bucari-Tovo
8f5467e6ca Revert stupid change. 2022-05-24 13:30:39 -06:00
Michael Bucari-Tovo
28764f92b9 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-24 13:16:56 -06:00
Michael Bucari-Tovo
777dfe4c62 Fix hang on grid update 2022-05-24 13:16:44 -06:00
Robert McRackan
0878a704d9 search engine: podcast and episode should allow plural 2022-05-24 15:07:53 -04:00
Robert McRackan
f880897542 Increm version 2022-05-24 14:04:53 -04:00
rmcrackan
b37472a954 Merge pull request #255 from Mbucari/master
Implemented Episode grouping and refactored ProductsGrid
2022-05-24 13:59:12 -04:00
Michael Bucari-Tovo
68735a45dd Change episode color 2022-05-24 11:52:33 -06:00
Michael Bucari-Tovo
e26deb9092 Address comments 2022-05-24 11:15:41 -06:00
Michael Bucari-Tovo
43d6ea82cd Change failure behavior to match previous implementation 2022-05-24 09:17:09 -06:00
Mbucari
db1aa495ac Merge branch 'rmcrackan:master' into master 2022-05-24 08:48:32 -06:00
Michael Bucari-Tovo
ee62d9ae8d Attempt to fix app hang on LogMe event 2022-05-24 07:36:17 -06:00
Robert McRackan
4001124cfa AudibleApi. Better logging around getting pdf url 2022-05-24 09:03:43 -04:00
Michael Bucari-Tovo
43a4d0d1d7 Cleanup 2022-05-23 22:24:45 -06:00
Michael Bucari-Tovo
632b432b7c Revert to old column indexing 2022-05-23 22:21:37 -06:00
Michael Bucari-Tovo
e778c7a59d Create GridView namespace 2022-05-23 21:34:43 -06:00
Michael Bucari-Tovo
d71cdecd35 Refactoring and addressing comments 2022-05-23 21:20:26 -06:00
Michael Bucari-Tovo
4a82541ffd Fix error while removing filter on a sorted binding list 2022-05-23 17:46:55 -06:00
Michael Bucari-Tovo
f29dff3386 Fix filtering bug 2022-05-23 17:22:02 -06:00
Michael Bucari-Tovo
718d21f6cb NotifyPropertyChanged series on update 2022-05-23 16:42:05 -06:00
Michael Bucari-Tovo
440550ded9 Add binding source at design time 2022-05-23 16:35:18 -06:00
Michael Bucari-Tovo
593fe57ea1 Refactor ProductsGrid 2022-05-23 15:29:26 -06:00
Michael Bucari-Tovo
e8a320dac9 Add grid categories 2022-05-22 20:00:41 -06:00
Michael Bucari-Tovo
3cb43e5d3e Improve display 2022-05-22 20:00:06 -06:00
526 changed files with 35369 additions and 6994 deletions

31
.github/ISSUE_TEMPLATE/bug_report.md vendored Normal file
View File

@@ -0,0 +1,31 @@
---
name: Bug report
about: Create a report to help us improve Libation
title: ''
labels: bug
assignees: ''
---
**Describe the bug**
A clear and concise description of what the bug is.
**To Reproduce**
Steps to reproduce the behavior:
1. Go to '...'
2. Click on '....'
3. Scroll down to '....'
4. See error
**Expected behavior**
A clear and concise description of what you expected to happen.
**Screenshots**
If applicable, add screenshots to help explain your problem.
**Platform**
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here.

View File

@@ -0,0 +1,19 @@
---
name: Feature request
about: Suggest an idea for this project
title: ''
labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
Add any other context or screenshots about the feature request here.

8
.github/dependabot.yml vendored Normal file
View File

@@ -0,0 +1,8 @@
---
version: 2
updates:
# Maintain dependencies for GitHub Actions
- package-ecosystem: "github-actions"
directory: "/"
schedule:
interval: "daily"

107
.github/workflows/build-linux.yml vendored Normal file
View File

@@ -0,0 +1,107 @@
# build-linux.yml
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
version_override:
type: string
description: 'Version number override'
required: false
run_unit_tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
RELEASE_NAME: 'chardonnay'
jobs:
build:
runs-on: ${{ matrix.os }}
strategy:
matrix:
os: [ubuntu-latest, macos-latest]
arch: [x64, arm64]
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
inputVersion="${{ inputs.version_override }}"
if [[ "${#inputVersion}" -gt 0 ]]
then
version="${inputVersion}"
else
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
os=${{ matrix.os }}
target_os="$(echo ${os/-latest/} | sed 's/ubuntu/linux/')"
display_os="$(echo ${target_os/macos/macOS} | sed 's/linux/Linux/')"
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
RUNTIME_IDENTIFIER="$(echo ${target_os/macos/osx})-${{ matrix.arch }}"
echo "$RUNTIME_IDENTIFIER"
dotnet publish \
LibationAvalonia/LibationAvalonia.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LibationCli/LibationCli.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
HangoverAvalonia/HangoverAvalonia.csproj \
--runtime "$RUNTIME_IDENTIFIER" \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output bin/Publish/${display_os}-${{ matrix.arch }}-${{ env.RELEASE_NAME }} \
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
- name: Build bundle
id: bundle
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ matrix.arch }}-${{ env.RELEASE_NAME }}
run: |
BUNDLE_DIR=$(pwd)
echo "Bundle dir: ${BUNDLE_DIR}"
cd ..
SCRIPT=../../../Scripts/Bundle_${{ steps.publish.outputs.display_os }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ matrix.arch }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v3
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error

113
.github/workflows/build-windows.yml vendored Normal file
View File

@@ -0,0 +1,113 @@
# build-windows.yml
# Reusable workflow that builds the Windows versions of Libation.
---
name: build
on:
workflow_call:
inputs:
version_override:
type: string
description: 'Version number override'
required: false
run_unit_tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
jobs:
build:
runs-on: windows-latest
strategy:
matrix:
os: [Windows]
ui: [Avalonia]
release_name: [chardonnay]
include:
- os: Windows
ui: WinForms
release_name: classic
prefix: Classic-
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
if ("${{ inputs.version_override }}".length -gt 0) {
$version = "${{ inputs.version_override }}"
} else {
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
}
"version=$version" >> $env:GITHUB_OUTPUT
- name: Unit test
if: ${{ inputs.run_unit_tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LibationCli/LibationCli.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish
run: |
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
$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.runtimeconfig.json",
"WindowsConfigApp.deps.json"
)
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=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
- name: Publish artifact
uses: actions/upload-artifact@v3
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
if-no-files-found: error

30
.github/workflows/build.yml vendored Normal file
View File

@@ -0,0 +1,30 @@
# build.yml
# Reusable workflow that builds Libation for all platforms.
---
name: build
on:
workflow_call:
inputs:
version_override:
type: string
description: 'Version number override'
required: false
run_unit_tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
jobs:
windows:
uses: ./.github/workflows/build-windows.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
linux:
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}

46
.github/workflows/docker.yml vendored Normal file
View File

@@ -0,0 +1,46 @@
# docker.yml
# Reusable workflow that builds a docker image for Libation.
---
name: docker
on:
workflow_call:
inputs:
version:
type: string
description: 'Version number'
required: true
secrets:
docker_username:
required: true
docker_token:
required: true
env:
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
jobs:
docker:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
- name: Login to Docker Hub
uses: docker/login-action@v2
with:
username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }}
- name: Build and push
uses: docker/build-push-action@v4
with:
push: true
build-args: 'FOLDER_NAME=Linux-chardonnay'
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}

61
.github/workflows/release.yml vendored Normal file
View File

@@ -0,0 +1,61 @@
# release.yml
# Builds and creates the release on any tags starting with a `v`
---
name: release
on:
push:
tags:
- 'v*'
jobs:
prerelease:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get tag version
id: get_version
run: |
export TAG='${{ github.ref_name }}'
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
docker:
needs: [prerelease]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prerelease.outputs.version }}
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
release:
needs: [prerelease,build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
with:
path: artifacts
- name: Release
id: release
uses: softprops/action-gh-release@v1
with:
name: Libation v${{ needs.prerelease.outputs.version }}
body: <Put a body here>
draft: true
prerelease: false
- name: Upload release assets
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
with:
release_id: '${{ steps.release.outputs.id }}'
assets_path: ./artifacts

14
.github/workflows/validate.yml vendored Normal file
View File

@@ -0,0 +1,14 @@
# validate.yml
# Validates that Libation will build on a pull request or push to master.
---
name: validate
on:
push:
branches: [master]
pull_request:
branches: [master]
jobs:
build:
uses: ./.github/workflows/build.yml

3
.gitignore vendored
View File

@@ -184,7 +184,7 @@ publish/
*.azurePubxml
# Note: Comment the next line if you want to checkin your web deploy settings,
# but database connection strings (with potential passwords) will be unencrypted
*.pubxml
#*.pubxml
*.publishproj
# Microsoft Azure Web App publish settings. Comment the next line if you want to
@@ -370,3 +370,4 @@ FodyWeavers.xsd
/__TODO.txt
/DataLayer/LibationContext.db
*/bin-Avalonia

8
.releaseindex.json Normal file
View File

@@ -0,0 +1,8 @@
{
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz"
}

21
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,21 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console)",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
}
]
}

42
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,42 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"dependsOn": [
"build_libation",
"build_linuxconfigapp"
]
},
{
"label": "build_libation",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
"label": "build_linuxconfigapp",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
}
]
}

68
Docker/liberate.sh Executable file
View File

@@ -0,0 +1,68 @@
#!/bin/bash
# Rewire echo to print date time
echo() {
if [[ -n $1 ]]; then
printf "$(date '+%F %T'): %s\n" "$1"
fi
}
# ################################
# Setup
# ################################
echo "Starting"
if [[ -z "${SLEEP_TIME}" ]]; then
echo "No sleep time passed in. Will run once and exit."
else
echo "Sleep time is set to ${SLEEP_TIME}"
fi
echo ""
# Check if the config directory is passed in, and there is no link to it then create the link.
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
echo "Linking config directory to the Libation config directory"
ln -s /config/ /root/Libation
fi
# If no config error and exit
if [ ! -d "/config" ]; then
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
exit 1
fi
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
FILE=/db/LibationContext.db
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
echo "Linking passed in Libation database from /db/ to the Libation config directory"
ln -s $FILE /config/LibationContext.db
fi
# Confirm we have a db in the config direcotry.
if [ ! -f "/config/LibationContext.db" ]; then
echo "ERROR: No Libation database detected, exiting."
exit 1
fi
# ################################
# Loop and liberate
# ################################
while true
do
echo ""
echo "Scanning accounts"
/libation/LibationCli scan
echo "Liberating books"
/libation/LibationCli liberate
echo ""
# Liberate only once if SLEEP_TIME was set to -1
if [ "${SLEEP_TIME}" = -1 ]; then
break
fi
echo "Sleeping for ${SLEEP_TIME}"
sleep "${SLEEP_TIME}"
done
echo "Exiting"

22
Dockerfile Normal file
View File

@@ -0,0 +1,22 @@
# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
COPY Source /Source
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
FROM mcr.microsoft.com/dotnet/runtime:7.0
ENV SLEEP_TIME "30m"
# Sets the character set that will be used for folder and filenames when liberating
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
RUN mkdir /db /config /data
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
CMD ["./libation/liberate.sh"]

View File

@@ -1,6 +1,6 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
@@ -8,9 +8,8 @@
# Advanced: Table of Contents
- [Files and folders](#files-and-folders)
- [Linux and Mac (unofficial)](#linux-and-mac)
- [Settings](#settings)
- [Custom File Naming](#custom-file-naming)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
@@ -25,20 +24,10 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Linux and Mac
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
### Settings
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
### Custom File Naming
In Settings, on the Download/Decrypt tab, you can specify the format in which you want your files to be named. As you edit these templates, a live example will be shown. Parameters are listed for folders, files, and files split by chapter including an explanation of what each naming option means. For instance: you can use template `<title short> - <ch# 0> of <ch count> - <ch title>` to create the file `A Study in Scarlet - 04 of 10 - A Flight for Life.m4b`.
These templates apply to GUI and CLI.
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
@@ -76,4 +65,15 @@ export library to file
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```

36
Documentation/Docker.md Normal file
View File

@@ -0,0 +1,36 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
### Setup
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
In Settings.json, make the following changes:
* Change `Books` to `/data`
* Change `InProgress` to `/tmp`
### Running
Once the configuration files are copied and edited, the docker image can be run with the following command.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
--name libation \
--restart=always \
rmcrackan/libation
```
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
-e SLEEP_TIME='10m' \
--name libation \
--restart=always \
rmcrackan/libation
```

View File

@@ -1,6 +1,6 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
@@ -20,9 +20,21 @@
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
##### Which version? Chardonnay vs Classic
Nearly 100% of the difference is look and feel -- it's a matter of preference.
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
### Installation
To install Libation, extract the zip file to a folder, for example `C:\Libation`, and then run Libation.exe from that folder to begin the configuration process and configure your account(s).
* Windows
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
* [Ubuntu Linux](InstallOnLinux.md)
* [MacOS](InstallOnMac.md)
### Create Accounts

View File

@@ -0,0 +1,22 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
### Install and Run Libation on Ubuntu
New Libation releases are automatically packed into a debian package and are available from the Libation repository's releases page.
Run this command in your terminal to dowbnload and install Libation, replacing the url with the Latest Libation .deb package url:
```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
sudo apt install ./libation.deb
```
You should now see Libation among your applications.
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
Report bugs to https://github.com/rmcrackan/Libation/issues

View File

@@ -0,0 +1,41 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Install Libation
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
- Move the extracted Libation app bundle to your applications folder.
- Open a terminal (Go > Utilities > Terminal)
- Copy/paste/run the following command (you'll be prompted to enter your password)
```Console
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
```
- Close the terminal and use Libation!
## Running Hangover
Libation comes with a recovery app called Hangover. You can start it by running this command:
```Console
open /Applications/Libation.app --args hangover
```
## Runnign LibationCli
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
```Console
open /Applications/Libation.app --args cli
```
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
Then use `./LibationCli` to execute a command.
## Get Libation running on Mac
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)

View File

@@ -0,0 +1,116 @@
# Naming Templates
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
These templates apply to both GUI and CLI.
# Table of Contents
- [Template Tags](#template-tags)
- [Property Tags](#property-tags)
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Name List Formatters](#name-list-formatters)
- [Integer Formatters](#integer-formatters)
- [Date Formatters](#date-formatters)
# Template Tags
These are the naming template tags currently supported by Libation.
## Property Tags
These tags will be replaced in the template with the audiobook's values.
|Tag|Description|Type|
|-|-|-|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title|Text|
|\<title short\>|Title. Stop at first colon|Text|
|\<author\>|Author(s)|Name List|
|\<first author\>|First author|Text|
|\<narrator\>|Narrator(s)|Name List|
|\<first narrator\>|First narrator|Text|
|\<series\>|Name of series|Text|
|\<series#\>|Number order in series|Text|
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|\<samplerate\>|File's original audio sample rate|Integer|
|\<channels\>|Number of audio channels|Integer|
|\<account\>|Audible account of this book|Text|
|\<locale\>|Region/country|Text|
|\<year\>|Year published|Integer|
|\<language\>|Book's language|Text|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|DateTime|
|\<pub date\>|Audiobook publication date|DateTime|
|\<date added\>|Date the book added to your Audible account|DateTime|
|\<ch count\> **‡**|Number of chapters|Integer|
|\<ch title\> **‡**|Chapter title|Text|
|\<ch#\> **‡**|Chapter number|Integer|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
**†** Does not support custom formatting
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
## Conditional Tags
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|Tag|Description|Type|
|-|-|-|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
# Tag Formatters
**Text**, **Name List**, **Integer**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
## Text Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|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|
## Name List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join 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 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}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|sort(F \| M \| L)|Sorts the names by first, middle, 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|
## Integer Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
## Date Formatters
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
### Standard DateTime Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
### Custom DateTime Formatters
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|yy|2-digit year|\<file date[yy]\>|23|
|MM|2-digit month|\<file date[MM]\>|02|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|

View File

@@ -1,6 +1,6 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.

View File

@@ -0,0 +1,32 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512px" enable-background="new 0 0 512 512">
<path id="slosh" transform=
"translate(-50 23)
scale(0.7, 0.7)
rotate(12 256,256)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
<use href="#slosh" transform="translate(512 0) scale(-1 1)" />
</svg>

After

Width:  |  Height:  |  Size: 736 B

28
Images/libation_glass.svg Normal file
View File

@@ -0,0 +1,28 @@
<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">
<path id="glass" d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
z" />
<path id="wine-level" d=
"M146,128
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
z"/>
</svg>

After

Width:  |  Height:  |  Size: 585 B

View File

@@ -0,0 +1,30 @@
<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">
<g transform="translate(0 80) rotate(90 256,256)">
<path id="glass" d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
z" />
<path id="wine-level" d=
"M345,44
A 192,184 0 0 1 366,126
A 320,180 55 0 1 345,226
z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 638 B

33
Images/libation_slosh.svg Normal file
View File

@@ -0,0 +1,33 @@
<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">
<path
transform=
"rotate(15 256,256)
translate(0 25)
scale(0.93, 0.93)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
</svg>

After

Width:  |  Height:  |  Size: 649 B

View File

@@ -2,7 +2,7 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Table of Contents
@@ -27,10 +27,10 @@
- [Filters](Documentation/SearchingAndFiltering.md#filters)
- [Advanced](Documentation/Advanced.md)
- [Files and folders](Documentation/Advanced.md#files-and-folders)
- [Linux and Mac (unofficial)](Documentation/Advanced.md#linux-and-mac)
- [Settings](Documentation/Advanced.md#settings)
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
- [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Docker](Documentation/Docker.md)
## Getting started
@@ -54,7 +54,7 @@
### The bad
* Windows only
* Only fully supported in Windows. (Mac and Linux are in beta)
* Large file size
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
@@ -66,4 +66,4 @@
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
I made this for myself and I want to share it with the great programming and audible/audiobook communiites which have been so generous with their time and help.
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.

146
Scripts/Bundle_Linux.sh Normal file
View File

@@ -0,0 +1,146 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation Linux bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z "$ARCH" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb
FOLDER_EXEC=$DEB_DIR/usr/lib/libation
echo "Exec dir: $FOLDER_EXEC"
mkdir -p $FOLDER_EXEC
echo "Moving bins from $BIN_DIR to $FOLDER_EXEC"
mv "${BIN_DIR}/"* $FOLDER_EXEC
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
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')
if [[ "$ARCH" == "arm64" ]]
then
delfiles+=('libmp3lame.x64.so' 'ffmpegaac.x64.so')
else
delfiles+=('libmp3lame.arm64.so' 'ffmpegaac.arm64.so')
fi
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $FOLDER_EXEC/$n
done
FOLDER_ICON=$DEB_DIR/usr/share/icons/hicolor/scalable/apps/
echo "Icon dir: $FOLDER_ICON"
FOLDER_DESKTOP=$DEB_DIR/usr/share/applications
echo "Desktop dir: $FOLDER_DESKTOP"
FOLDER_DEBIAN=$DEB_DIR/DEBIAN
echo "Debian dir: $FOLDER_DEBIAN"
mkdir -p $FOLDER_ICON
mkdir -p $FOLDER_DESKTOP
mkdir -p $FOLDER_DEBIAN
echo "Copying icon..."
cp $FOLDER_EXEC/libation_glass.svg $FOLDER_ICON/libation.svg
echo "Copying desktop file..."
cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
echo "Creating pre-install file..."
echo "#!/bin/bash
# Pre-install script, removes previous installation program files and sym links
echo \"Removing previously created symlinks...\"
rm /usr/bin/libation
rm /usr/bin/hangover
rm /usr/bin/libationcli
echo \"Removing previously installed Libation files...\"
rm -r /usr/lib/libation
# making sure it won't stop installation
exit 0
" >> $FOLDER_DEBIAN/preinst
echo "Creating post-install file..."
echo "#!/bin/bash
gtk-update-icon-cache -f /usr/share/icons/hicolor/
ln -s /usr/lib/libation/Libation /usr/bin/libation
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
# Increase the maximum number of inotify instances
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
# workaround until this file is moved to the user's home directory
touch /usr/lib/libation/appsettings.json
chmod 666 /usr/lib/libation/appsettings.json
" >> $FOLDER_DEBIAN/postinst
echo "Creating control file..."
echo "Package: Libation
Version: $VERSION
Architecture: $ARCH
Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
" >> $FOLDER_DEBIAN/control
echo "Changing permissions for pre- and post-install files..."
chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
if [ "$(uname -s)" == "Darwin" ]; then
echo "macOS detected, installing dpkg"
brew install dpkg
fi
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
echo "Creating $DEB_FILE"
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
echo "moving to ./bundle/$DEB_FILE"
mkdir bundle
mv $DEB_FILE ./bundle/$DEB_FILE
rm -r "$BIN_DIR"
echo "Done!"

111
Scripts/Bundle_MacOS.sh Normal file
View File

@@ -0,0 +1,111 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation macos bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z $VERSION ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z $ARCH ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" $ARCH
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
BUNDLE=./Libation.app
echo "Bundle dir: $BUNDLE"
if [[ -d $BUNDLE ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
fi
BUNDLE_CONTENTS=$BUNDLE/Contents
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
BUNDLE_RESOURCES=$BUNDLE_CONTENTS/Resources
echo "Resources dir: $BUNDLE_RESOURCES"
BUNDLE_MACOS=$BUNDLE_CONTENTS/MacOS
echo "MacOS dir: $BUNDLE_MACOS"
mkdir -p $BUNDLE_CONTENTS
mkdir -p $BUNDLE_RESOURCES
mkdir -p $BUNDLE_MACOS
mv "${BIN_DIR}/"* $BUNDLE_MACOS
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
fi
echo "Moving icon..."
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION"
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')
if [[ "$ARCH" == "arm64" ]]
then
delfiles+=('libmp3lame.x64.dylib' 'ffmpegaac.x64.dylib')
else
delfiles+=('libmp3lame.arm64.dylib' 'ffmpegaac.arm64.dylib')
fi
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
echo "Signing executables in: $BUNDLE"
codesign --force --deep -s - $BUNDLE
echo "Creating app bundle: $APP_FILE"
tar -czvf $APP_FILE $BUNDLE
mkdir bundle
echo "moving to ./bundle/$APP_FILE"
mv $APP_FILE ./bundle/$APP_FILE
rm -r $BUNDLE
echo "Done!"

View File

@@ -1,15 +1,22 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean" Version="0.4.7" />
<PackageReference Include="AAXClean.Codecs" Version="0.2.7" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="1.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>

View File

@@ -1,17 +1,18 @@
using System;
using AAXClean;
using Dinah.Core.Net.Http;
using AAXClean;
using System;
using System.Threading.Tasks;
namespace AaxDecrypter
{
{
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{
public event EventHandler<AppleTags> RetrievedMetadata;
protected AaxFile AaxFile;
protected AaxFile AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic) { }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
@@ -21,9 +22,17 @@ namespace AaxDecrypter
AaxFile.AppleTags.Cover = coverArt;
}
public override async Task CancelAsync()
{
IsCanceled = true;
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
}
protected bool Step_GetMetadata()
{
AaxFile = new AaxFile(InputFileStream);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
if (DownloadOptions.StripUnabridged)
{
@@ -31,29 +40,22 @@ namespace AaxDecrypter
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
}
if (DownloadOptions.FixupFile)
{
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("&#169;", "©");
}
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
{
double bitrateMultiple = 1;
if (AaxFile.AudioChannels == 2)
{
if (DownloadOptions.Downsample)
bitrateMultiple = 0.5;
else
DownloadOptions.LameConfig.Mode = NAudio.Lame.MPEGMode.Stereo;
}
if (DownloadOptions.MatchSourceBitrate)
{
int kbps = (int)(AaxFile.AverageBitrate * bitrateMultiple / 1024);
if (DownloadOptions.LameConfig.VBR is null)
DownloadOptions.LameConfig.BitRate = kbps;
else if (DownloadOptions.LameConfig.VBR == NAudio.Lame.VBRMode.ABR)
DownloadOptions.LameConfig.ABRRateKbps = kbps;
}
}
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
@@ -64,57 +66,5 @@ namespace AaxDecrypter
return !IsCanceled;
}
protected DownloadProgress Step_DownloadAudiobook_Start()
{
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
};
OnDecryptProgressUpdate(zeroProgress);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
return zeroProgress;
}
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
{
AaxFile.Close();
CloseInputFileStream();
OnDecryptProgressUpdate(zeroProgress);
}
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = AaxFile.Duration;
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (e.ProcessPosition / e.TotalDuration);
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
}
public override void Cancel()
{
IsCanceled = true;
AaxFile?.Cancel();
AaxFile?.Dispose();
CloseInputFileStream();
}
}
}

View File

@@ -1,39 +1,27 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AAXClean;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.StepRunner;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
{
protected override StepSequence Steps { get; }
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
private FileStream workingFileStream;
private Func<MultiConvertFileProperties, string> multipartFileNameCallback { get; }
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
{
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
}
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
private List<string> multiPartFilePaths { get; } = new List<string>();
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic,
Func<MultiConvertFileProperties, string> multipartFileNameCallback = null)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
["Step 3: Cleanup"] = Step_Cleanup,
};
this.multipartFileNameCallback = multipartFileNameCallback ?? MultiConvertFileProperties.DefaultMultipartFilename;
}
/*
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
@@ -56,86 +44,102 @@ The book will be split into the following files:
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
*/
private bool Step_DownloadAudiobookAsMultipleFilesPerChapter()
{
var zeroProgress = Step_DownloadAudiobook_Start();
*/
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
var chapters = DownloadOptions.ChapterInfo.Chapters;
var chapters = DownloadOptions.ChapterInfo.Chapters.ToList();
// Ensure split files are at least minChapterLength in duration.
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
// Ensure split files are at least minChapterLength in duration.
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
var runningTotal = TimeSpan.Zero;
string title = "";
var runningTotal = TimeSpan.Zero;
string title = "";
for (int i = 0; i < chapters.Count; i++)
{
if (runningTotal == TimeSpan.Zero)
title = chapters[i].Title;
for (int i = 0; i < chapters.Count; i++)
{
if (runningTotal == TimeSpan.Zero)
title = chapters[i].Title;
runningTotal += chapters[i].Duration;
runningTotal += chapters[i].Duration;
if (runningTotal >= minChapterLength)
{
splitChapters.AddChapter(title, runningTotal);
runningTotal = TimeSpan.Zero;
}
}
if (runningTotal >= minChapterLength)
{
splitChapters.AddChapter(title, runningTotal);
runningTotal = TimeSpan.Zero;
}
}
try
{
await (AaxConversion = decryptMultiAsync(splitChapters));
// reset, just in case
multiPartFilePaths.Clear();
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(workingFileStream?.Name);
ConversionResult result;
return AaxConversion.IsCompletedSuccessfully;
}
finally
{
workingFileStream?.Dispose();
FinalizeDownload();
}
}
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
result = ConvertToMultiMp4a(splitChapters);
else
result = ConvertToMultiMp3(splitChapters);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
{
var chapterCount = 0;
return
DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
)
: AaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.LameConfig
);
Step_DownloadAudiobook_End(zeroProgress);
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
{
MultiConvertFileProperties props = new()
{
OutputFileName = OutputFileName,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title,
};
return result == ConversionResult.NoErrorsDetected;
}
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
private ConversionResult ConvertToMultiMp4a(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp4a(splitChapters, newSplitCallback =>
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.TrimOutputToChapterLength);
}
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count;
private ConversionResult ConvertToMultiMp3(ChapterInfo splitChapters)
{
var chapterCount = 0;
return AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
{
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
((NAudio.Lame.LameConfig)newSplitCallback.UserState).ID3.Track = chapterCount.ToString();
}, DownloadOptions.LameConfig, DownloadOptions.TrimOutputToChapterLength);
}
OnFileCreated(workingFileStream.Name);
}
private void createOutputFileStream(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
{
var fileName = multipartFileNameCallback(new()
{
OutputFileName = OutputFileName,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title
});
fileName = FileUtility.GetValidFilename(fileName);
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
FileUtility.SaferDelete(fileName);
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
}
multiPartFilePaths.Add(fileName);
FileUtility.SaferDelete(fileName);
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
OnFileCreated(fileName);
}
}
private Mp4Operation moveMoovToBeginning(string filename)
{
if (DownloadOptions.OutputFormat is OutputFormat.M4b
&& DownloadOptions.MoveMoovToBeginning
&& filename is not null
&& File.Exists(filename))
{
return Mp4File.RelocateMoovAsync(filename);
}
else return Mp4Operation.CompletedOperation;
}
}
}

View File

@@ -1,55 +1,92 @@
using System;
using System.IO;
using AAXClean;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.StepRunner;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.Diagnostics;
using System.IO;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
protected override StepSequence Steps { get; }
private readonly AverageSpeed averageSpeed = new();
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
{
var step = 1;
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
["Step 3: Create Cue"] = Step_CreateCue,
["Step 4: Cleanup"] = Step_Cleanup,
};
}
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
FileUtility.SaferDelete(OutputFileName);
private bool Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
FileUtility.SaferDelete(OutputFileName);
try
{
await (AaxConversion = decryptAsync(outputFile));
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
return AaxConversion.IsCompletedSuccessfully;
}
finally
{
FinalizeDownload();
}
}
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
var decryptionResult
= DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMp4a(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
: AaxFile.ConvertToMp3(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
private async Task<bool> Step_MoveMoov()
{
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
return AaxConversion.IsCompletedSuccessfully;
}
DownloadOptions.ChapterInfo = AaxFile.Chapters;
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
Step_DownloadAudiobook_End(zeroProgress);
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
return success;
}
}
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * e.FractionCompleted,
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
TotalBytesToReceive = InputFileStream.Length
});
}
private Mp4Operation decryptAsync(Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
? AaxFile.ConvertToMp3Async
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo
)
: DownloadOptions.FixupFile
? AaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo
)
: AaxFile.ConvertToMp4aAsync(outputFile);
}
}

View File

@@ -1,10 +1,10 @@
using System;
using System.Collections.Generic;
using System.IO;
using Dinah.Core;
using Dinah.Core;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
using System;
using System.IO;
using System.Threading.Tasks;
namespace AaxDecrypter
{
@@ -20,42 +20,103 @@ namespace AaxDecrypter
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
public bool IsCanceled { get; set; }
protected string OutputFileName { get; private set; }
protected DownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputFileName { get; }
protected IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position;
private bool downloadFinished;
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
private readonly NetworkFileStreamPersister nfsPersister;
private readonly DownloadProgress zeroProgress;
private readonly string jsonDownloadState;
private readonly string tempFilePath;
protected abstract StepSequence Steps { get; }
private NetworkFileStreamPersister nfsPersister;
private string jsonDownloadState { get; }
public string TempFilePath { get; }
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
{
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
var outDir = Path.GetDirectoryName(OutputFileName);
if (!Directory.Exists(outDir))
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
Directory.CreateDirectory(outDir);
if (!Directory.Exists(cacheDirectory))
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
nfsPersister = OpenNetworkFileStream();
zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = 0
};
OnDecryptProgressUpdate(zeroProgress);
}
public abstract void Cancel();
public async Task<bool> RunAsync()
{
await InputFileStream.BeginDownloadingAsync();
var progressTask = Task.Run(reportProgress);
AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync();
await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
return success;
async Task reportProgress()
{
AverageSpeed averageSpeed = new();
while (
InputFileStream.CanRead
&& InputFileStream.Length > InputFilePosition
&& !InputFileStream.IsCancelled
&& !downloadFinished)
{
averageSpeed.AddPosition(InputFilePosition);
var estSecsRemaining = (InputFileStream.Length - InputFilePosition) / averageSpeed.Average;
if (double.IsNormal(estSecsRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
var progressPercent = 100d * InputFilePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = InputFilePosition,
TotalBytesToReceive = InputFileStream.Length
});
await Task.Delay(200);
}
OnDecryptTimeRemaining(TimeSpan.Zero);
OnDecryptProgressUpdate(zeroProgress);
}
}
public abstract Task CancelAsync();
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
@@ -63,117 +124,115 @@ namespace AaxDecrypter
OnRetrievedCoverArt(coverArt);
}
public bool Run()
{
var (IsSuccess, Elapsed) = Steps.Run();
if (!IsSuccess)
Serilog.Log.Logger.Error("Conversion failed");
return IsSuccess;
}
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors)
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
protected void OnFileCreated(string path)
=> FileCreated?.Invoke(this, path);
protected void CloseInputFileStream()
protected virtual void FinalizeDownload()
{
nfsPersister?.NetworkFileStream?.Close();
nfsPersister?.Dispose();
downloadFinished = true;
}
protected bool Step_CreateCue()
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
{
if (!DownloadOptions.CreateCueSheet) return true;
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
}
protected async Task<bool> Step_CreateCueAsync()
{
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
// not a critical step. its failure should not prevent future steps from running
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
path = FileUtility.GetValidFilename(path);
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
OnFileCreated(path);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
}
return !IsCanceled;
}
protected bool Step_Cleanup()
private async Task<bool> CleanupAsync()
{
bool success = !IsCanceled;
if (success)
if (IsCanceled) return false;
FileUtility.SaferDelete(jsonDownloadState);
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
DownloadOptions.RetainEncryptedFile)
{
FileUtility.SaferDelete(jsonDownloadState);
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
FileUtility.SaferMove(tempFilePath, aaxPath);
if (DownloadOptions.AudibleKey is not null &&
DownloadOptions.AudibleIV is not null &&
DownloadOptions.RetainEncryptedFile)
{
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
FileUtility.SaferMove(TempFilePath, aaxPath);
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(TempFilePath);
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(tempFilePath);
return success;
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
{
if (!File.Exists(jsonDownloadState))
return NewNetworkFilePersister();
NetworkFileStreamPersister nfsp = default;
try
{
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// If More than ~1 hour has elapsed since getting the download url, it will expire.
// The new url will be to the same file.
if (!File.Exists(jsonDownloadState))
return nfsp = newNetworkFilePersister();
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// The download url expires after 1 hour.
// The new url points to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
return nfsp;
}
catch
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(TempFilePath);
return NewNetworkFilePersister();
FileUtility.SaferDelete(tempFilePath);
return nfsp = newNetworkFilePersister();
}
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection
finally
{
{ "User-Agent", DownloadOptions.UserAgent }
};
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
}
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, headers);
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
NetworkFileStreamPersister newNetworkFilePersister()
{
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
}
}
}

View File

@@ -0,0 +1,171 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
using System.Linq;
namespace AaxDecrypter;
public static class LinqStats
{
public static (double mean, double stdDev) BasicStatisticsBy<T>(this IEnumerable<T> values, Func<T, double> selector)
{
var count = values.Count();
var mean = values.Average(selector);
return (mean, Math.Sqrt(values.Sum(s => Math.Pow(selector(s) - mean, 2)) / (count - 1)));
}
public static bool T_Test_2By<T>(this IEnumerable<T> values, Func<T, double> selector, IEnumerable<T> secondGroup, Significance confidence)
{
var n1 = values.Count();
var n2 = secondGroup.Count();
var n = n1 + n2;
if (n1 < 3 || n2 < 3) return false;
(var mean1, var stdDev1) = values.BasicStatisticsBy(selector);
(var mean2, var stdDev2) = secondGroup.BasicStatisticsBy(selector);
var pooledStdDev = Math.Sqrt((((n1 - 1) * (stdDev1 * stdDev1)) + ((n2 - 1) * (stdDev2 * stdDev2))) / (n1 + n2 - 2));
var testStat = Math.Abs(mean1 - mean2) / (pooledStdDev * Math.Sqrt(1d / n1 + 1d / n2));
var crit = T_Stat(Math.Min(n - 2, MAX_DEGREES_FREEDOM), confidence);
return testStat > crit;
}
public static bool T_Test_1By<T>(this IEnumerable<T> values, Func<T, double> selector, double testMean, Significance confidence)
{
var n = values.Count();
if (n < 2) return false;
(var sampleMean, var sampleStdDev) = values.BasicStatisticsBy(selector);
var testStat = Math.Abs(sampleMean - testMean) / (sampleStdDev / Math.Sqrt(n));
var crit = T_Stat(Math.Min(n - 1, MAX_DEGREES_FREEDOM), confidence);
return testStat > crit;
}
private static double T_Stat(int degreesFreedom, Significance confidence)
{
ArgumentValidator.EnsureBetweenInclusive(degreesFreedom, nameof(degreesFreedom), MIN_DEGREES_FREEDOM, MAX_DEGREES_FREEDOM);
return T_TABLE[(int)confidence][degreesFreedom - MIN_DEGREES_FREEDOM];
}
static LinqStats()
{
T_TABLE = new double[][] { T_Table_01, T_Table_05, T_Table_10, T_Table_15, T_Table_20, T_Table_25 };
}
private const int MIN_DEGREES_FREEDOM = 1;
private const int MAX_DEGREES_FREEDOM = 201;
/// <summary>
/// 2-tailed t-Distribution critical values at 75%, 80%, 85%,
/// 90%, 95%, and 99% confidence for 1 - 201 degrees of freedom.
/// </summary>
private readonly static double[][] T_TABLE;
private readonly static double[] T_Table_25 = { 2.414213562, 1.603567451, 1.422625281, 1.344397556, 1.300949037, 1.273349309, 1.254278682, 1.240318261, 1.229659173, 1.221255395, 1.214460246, 1.208852542, 1.204146242, 1.200140298, 1.196689284, 1.193685414, 1.191047107, 1.188711483, 1.186629298, 1.184761434, 1.183076432, 1.181548697, 1.180157199, 1.178884497, 1.177716003, 1.176639425, 1.175644329, 1.174721803, 1.173864189, 1.173064871, 1.1723181, 1.17161886, 1.170962753, 1.17034591, 1.169764906, 1.169216709, 1.168698615, 1.168208212, 1.167743338, 1.167302049, 1.166882595, 1.166483396, 1.166103019, 1.165740162, 1.165393644, 1.165062385, 1.164745398, 1.164441782, 1.164150707, 1.163871412, 1.163603196, 1.163345413, 1.163097467, 1.162858803, 1.162628911, 1.162407316, 1.162193577, 1.161987283, 1.161788052, 1.161595527, 1.161409375, 1.161229286, 1.161054967, 1.160886145, 1.160722566, 1.160563987, 1.160410184, 1.160260944, 1.160116066, 1.159975363, 1.159838656, 1.159705777, 1.159576569, 1.15945088, 1.15932857, 1.159209503, 1.159093552, 1.158980598, 1.158870524, 1.158763222, 1.158658589, 1.158556526, 1.15845694, 1.158359742, 1.158264847, 1.158172173, 1.158081645, 1.157993188, 1.157906731, 1.157822209, 1.157739556, 1.157658712, 1.157579617, 1.157502216, 1.157426454, 1.157352281, 1.157279646, 1.157208502, 1.157138804, 1.157070509, 1.157003573, 1.156937958, 1.156873624, 1.156810534, 1.156748653, 1.156687945, 1.156628379, 1.156569922, 1.156512543, 1.156456213, 1.156400904, 1.156346587, 1.156293237, 1.156240827, 1.156189334, 1.156138733, 1.156089001, 1.156040117, 1.155992058, 1.155944804, 1.155898335, 1.155852631, 1.155807674, 1.155763446, 1.155719928, 1.155677105, 1.155634959, 1.155593475, 1.155552637, 1.15551243, 1.155472839, 1.155433851, 1.155395452, 1.155357629, 1.155320368, 1.155283658, 1.155247486, 1.155211841, 1.15517671, 1.155142084, 1.15510795, 1.1550743, 1.155041122, 1.155008406, 1.154976144, 1.154944326, 1.154912942, 1.154881984, 1.154851443, 1.154821311, 1.15479158, 1.154762241, 1.154733287, 1.154704711, 1.154676505, 1.154648662, 1.154621175, 1.154594037, 1.154567242, 1.154540783, 1.154514654, 1.154488849, 1.154463361, 1.154438185, 1.154413316, 1.154388747, 1.154364474, 1.15434049, 1.154316792, 1.154293373, 1.154270229, 1.154247355, 1.154224746, 1.154202398, 1.154180307, 1.154158467, 1.154136875, 1.154115526, 1.154094417, 1.154073543, 1.1540529, 1.154032485, 1.154012294, 1.153992323, 1.153972568, 1.153953027, 1.153933695, 1.15391457, 1.153895647, 1.153876925, 1.153858399, 1.153840066, 1.153821925, 1.15380397, 1.153786201, 1.153768613, 1.153751204, 1.153733972, 1.153716914, 1.153700026 };
private readonly static double[] T_Table_20 = { 3.077683537, 1.885618083, 1.637744354, 1.533206274, 1.475884049, 1.439755747, 1.414923928, 1.39681531, 1.383028738, 1.372183641, 1.363430318, 1.356217334, 1.350171289, 1.345030374, 1.340605608, 1.336757167, 1.33337939, 1.330390944, 1.327728209, 1.325340707, 1.323187874, 1.321236742, 1.31946024, 1.317835934, 1.316345073, 1.314971864, 1.313702913, 1.312526782, 1.311433647, 1.310415025, 1.309463549, 1.308572793, 1.307737124, 1.306951587, 1.306211802, 1.305513886, 1.304854381, 1.304230204, 1.303638589, 1.303077053, 1.302543359, 1.302035487, 1.301551608, 1.30109006, 1.300649332, 1.300228048, 1.299824947, 1.299438879, 1.299068785, 1.298713694, 1.298372713, 1.298045016, 1.297729843, 1.297426488, 1.2971343, 1.296852673, 1.296581044, 1.29631889, 1.296065725, 1.295821094, 1.295584571, 1.295355762, 1.295134294, 1.29491982, 1.294712013, 1.294510568, 1.294315197, 1.294125629, 1.293941609, 1.293762898, 1.293589269, 1.293420507, 1.293256413, 1.293096793, 1.292941469, 1.292790268, 1.292643029, 1.292499597, 1.292359828, 1.292223583, 1.29209073, 1.291961144, 1.291834705, 1.291711301, 1.291590824, 1.291473171, 1.291358243, 1.291245948, 1.291136195, 1.291028899, 1.290923979, 1.290821356, 1.290720956, 1.290622708, 1.290526543, 1.290432395, 1.290340202, 1.290249904, 1.290161442, 1.290074761, 1.289989809, 1.289906533, 1.289824884, 1.289744816, 1.289666283, 1.289589241, 1.289513648, 1.289439464, 1.289366649, 1.289295166, 1.289224979, 1.289156054, 1.289088355, 1.289021851, 1.28895651, 1.288892302, 1.288829199, 1.288767171, 1.288706191, 1.288646234, 1.288587273, 1.288529284, 1.288472243, 1.288416127, 1.288360913, 1.288306581, 1.288253109, 1.288200477, 1.288148665, 1.288097654, 1.288047427, 1.287997964, 1.287949248, 1.287901264, 1.287853994, 1.287807422, 1.287761534, 1.287716314, 1.287671748, 1.287627821, 1.287584521, 1.287541833, 1.287499745, 1.287458245, 1.287417319, 1.287376957, 1.287337146, 1.287297876, 1.287259135, 1.287220914, 1.2871832, 1.287145985, 1.287109259, 1.287073012, 1.287037235, 1.287001918, 1.286967053, 1.286932631, 1.286898644, 1.286865084, 1.286831942, 1.286799212, 1.286766884, 1.286734952, 1.286703409, 1.286672248, 1.286641461, 1.286611042, 1.286580985, 1.286551283, 1.286521929, 1.286492918, 1.286464244, 1.286435901, 1.286407882, 1.286380184, 1.286352799, 1.286325724, 1.286298952, 1.286272479, 1.286246299, 1.286220408, 1.286194801, 1.286169474, 1.286144421, 1.286119638, 1.286095122, 1.286070867, 1.28604687, 1.286023127, 1.285999633, 1.285976384, 1.285953377, 1.285930609, 1.285908074, 1.285885771, 1.285863694, 1.285841842, 1.285820209, 1.285798794 };
private readonly static double[] T_Table_15 = { 4.16529977, 2.281930588, 1.924319657, 1.778192164, 1.699362566, 1.650173154, 1.616591737, 1.59222144, 1.573735785, 1.559235933, 1.547559766, 1.537956495, 1.529919606, 1.523095061, 1.517227969, 1.51213017, 1.507659754, 1.503707672, 1.500188756, 1.497035518, 1.494193795, 1.491619612, 1.489276897, 1.487135783, 1.485171326, 1.483362535, 1.481691617, 1.48014339, 1.478704821, 1.477364662, 1.47611315, 1.474941772, 1.473843072, 1.47281049, 1.471838233, 1.470921166, 1.470054719, 1.469234815, 1.468457801, 1.467720399, 1.467019655, 1.466352901, 1.465717725, 1.465111933, 1.464533534, 1.463980712, 1.463451805, 1.462945295, 1.46245979, 1.461994009, 1.461546775, 1.461117, 1.460703683, 1.460305896, 1.45992278, 1.459553538, 1.45919743, 1.458853767, 1.458521908, 1.458201256, 1.457891251, 1.457591373, 1.457301133, 1.457020074, 1.456747768, 1.45648381, 1.456227824, 1.455979454, 1.455738365, 1.455504241, 1.455276784, 1.455055715, 1.454840767, 1.45463169, 1.454428246, 1.454230212, 1.454037373, 1.453849529, 1.453666487, 1.453488066, 1.453314093, 1.453144404, 1.452978842, 1.452817259, 1.452659513, 1.452505469, 1.452354998, 1.452207977, 1.452064289, 1.451923821, 1.451786468, 1.451652126, 1.451520697, 1.451392088, 1.451266209, 1.451142973, 1.451022299, 1.450904108, 1.450788323, 1.450674871, 1.450563684, 1.450454694, 1.450347836, 1.450243048, 1.450140271, 1.450039448, 1.449940523, 1.449843444, 1.449748158, 1.449654617, 1.449562773, 1.449472581, 1.449383997, 1.449296977, 1.449211481, 1.449127468, 1.449044902, 1.448963744, 1.448883959, 1.448805513, 1.448728372, 1.448652503, 1.448577876, 1.44850446, 1.448432226, 1.448361146, 1.448291192, 1.448222337, 1.448154557, 1.448087826, 1.44802212, 1.447957415, 1.447893688, 1.447830919, 1.447769085, 1.447708165, 1.44764814, 1.44758899, 1.447530695, 1.447473238, 1.447416601, 1.447360765, 1.447305715, 1.447251433, 1.447197905, 1.447145113, 1.447093044, 1.447041682, 1.446991013, 1.446941023, 1.446891698, 1.446843026, 1.446794994, 1.446747588, 1.446700797, 1.446654609, 1.446609012, 1.446563996, 1.446519548, 1.446475659, 1.446432318, 1.446389514, 1.446347238, 1.44630548, 1.44626423, 1.44622348, 1.44618322, 1.446143442, 1.446104137, 1.446065296, 1.446026911, 1.445988975, 1.44595148, 1.445914417, 1.44587778, 1.445841561, 1.445805753, 1.445770349, 1.445735343, 1.445700727, 1.445666495, 1.445632641, 1.445599159, 1.445566042, 1.445533284, 1.445500881, 1.445468825, 1.445437112, 1.445405736, 1.445374691, 1.445343973, 1.445313576, 1.445283495, 1.445253726, 1.445224264, 1.445195103, 1.445166239, 1.445137668, 1.445109385, 1.445081387 };
private readonly static double[] T_Table_10 = { 6.313751515, 2.91998558, 2.353363435, 2.131846786, 2.015048373, 1.943180281, 1.894578605, 1.859548038, 1.833112933, 1.812461123, 1.795884819, 1.782287556, 1.770933396, 1.761310136, 1.753050356, 1.745883676, 1.739606726, 1.734063607, 1.729132812, 1.724718243, 1.720742903, 1.717144374, 1.713871528, 1.71088208, 1.708140761, 1.70561792, 1.703288446, 1.701130934, 1.699127027, 1.697260887, 1.695518783, 1.693888748, 1.692360309, 1.690924255, 1.689572458, 1.688297714, 1.68709362, 1.68595446, 1.684875122, 1.683851013, 1.682878002, 1.681952357, 1.681070703, 1.680229977, 1.679427393, 1.678660414, 1.677926722, 1.677224196, 1.676550893, 1.675905025, 1.67528495, 1.674689154, 1.674116237, 1.673564906, 1.673033965, 1.672522303, 1.672028888, 1.671552762, 1.671093032, 1.670648865, 1.670219484, 1.669804163, 1.669402222, 1.669013025, 1.668635976, 1.668270514, 1.667916114, 1.667572281, 1.667238549, 1.666914479, 1.666599658, 1.666293696, 1.665996224, 1.665706893, 1.665425373, 1.665151353, 1.664884537, 1.664624645, 1.664371409, 1.664124579, 1.663883913, 1.663649184, 1.663420175, 1.663196679, 1.6629785, 1.662765449, 1.662557349, 1.662354029, 1.662155326, 1.661961084, 1.661771155, 1.661585397, 1.661403674, 1.661225855, 1.661051817, 1.66088144, 1.66071461, 1.660551217, 1.660391156, 1.660234326, 1.66008063, 1.659929976, 1.659782273, 1.659637437, 1.659495383, 1.659356034, 1.659219312, 1.659085144, 1.658953458, 1.658824187, 1.658697265, 1.658572629, 1.658450216, 1.658329969, 1.65821183, 1.658095744, 1.657981659, 1.657869522, 1.657759285, 1.657650899, 1.657544319, 1.657439499, 1.657336397, 1.65723497, 1.657135178, 1.657036982, 1.656940344, 1.656845226, 1.656751594, 1.656659413, 1.656568649, 1.65647927, 1.656391244, 1.656304542, 1.656219133, 1.656134988, 1.65605208, 1.655970382, 1.655889868, 1.655810511, 1.655732287, 1.655655173, 1.655579143, 1.655504177, 1.655430251, 1.655357345, 1.655285437, 1.655214506, 1.655144534, 1.6550755, 1.655007387, 1.654940175, 1.654873847, 1.654808385, 1.654743774, 1.654679996, 1.654617035, 1.654554875, 1.654493503, 1.654432901, 1.654373057, 1.654313957, 1.654255585, 1.654197929, 1.654140976, 1.654084713, 1.654029128, 1.653974208, 1.653919942, 1.653866317, 1.653813324, 1.653760949, 1.653709184, 1.653658017, 1.653607437, 1.653557435, 1.653508002, 1.653459126, 1.6534108, 1.653363013, 1.653315758, 1.653269024, 1.653222803, 1.653177088, 1.653131869, 1.653087138, 1.653042889, 1.652999113, 1.652955802, 1.652912949, 1.652870547, 1.652828589, 1.652787068, 1.652745977, 1.65270531, 1.652665059, 1.652625219, 1.652585784, 1.652546746, 1.652508101 };
private readonly static double[] T_Table_05 = { 12.70620474, 4.30265273, 3.182446305, 2.776445105, 2.570581836, 2.446911851, 2.364624252, 2.306004135, 2.262157163, 2.228138852, 2.20098516, 2.17881283, 2.160368656, 2.144786688, 2.131449546, 2.119905299, 2.109815578, 2.10092204, 2.093024054, 2.085963447, 2.079613845, 2.073873068, 2.06865761, 2.063898562, 2.059538553, 2.055529439, 2.051830516, 2.048407142, 2.045229642, 2.042272456, 2.039513446, 2.036933343, 2.034515297, 2.032244509, 2.030107928, 2.028094001, 2.026192463, 2.024394164, 2.02269092, 2.02107539, 2.01954097, 2.018081703, 2.016692199, 2.015367574, 2.014103389, 2.012895599, 2.011740514, 2.010634758, 2.009575237, 2.008559112, 2.00758377, 2.006646805, 2.005745995, 2.004879288, 2.004044783, 2.003240719, 2.002465459, 2.001717484, 2.000995378, 2.000297822, 1.999623585, 1.998971517, 1.998340543, 1.997729654, 1.997137908, 1.996564419, 1.996008354, 1.995468931, 1.994945415, 1.994437112, 1.993943368, 1.993463567, 1.992997126, 1.992543495, 1.992102154, 1.99167261, 1.991254395, 1.990847069, 1.99045021, 1.990063421, 1.989686323, 1.989318557, 1.98895978, 1.988609667, 1.988267907, 1.987934206, 1.987608282, 1.987289865, 1.9869787, 1.986674541, 1.986377154, 1.986086317, 1.985801814, 1.985523442, 1.985251004, 1.984984312, 1.984723186, 1.984467455, 1.984216952, 1.983971519, 1.983731003, 1.983495259, 1.983264145, 1.983037526, 1.982815274, 1.982597262, 1.98238337, 1.982173483, 1.98196749, 1.981765282, 1.981566757, 1.981371815, 1.981180359, 1.980992298, 1.980807541, 1.980626002, 1.980447599, 1.980272249, 1.980099876, 1.979930405, 1.979763763, 1.979599878, 1.979438685, 1.979280117, 1.979124109, 1.978970602, 1.978819535, 1.97867085, 1.978524491, 1.978380405, 1.978238539, 1.978098842, 1.977961264, 1.977825758, 1.977692277, 1.977560777, 1.977431212, 1.977303542, 1.977177724, 1.97705372, 1.976931489, 1.976810994, 1.976692198, 1.976575066, 1.976459563, 1.976345655, 1.976233309, 1.976122494, 1.976013178, 1.975905331, 1.975798924, 1.975693928, 1.975590315, 1.975488058, 1.975387131, 1.975287508, 1.975189163, 1.975092073, 1.974996213, 1.97490156, 1.974808092, 1.974715786, 1.974624621, 1.974534576, 1.97444563, 1.974357764, 1.974270957, 1.974185191, 1.974100447, 1.974016708, 1.973933954, 1.973852169, 1.973771337, 1.97369144, 1.973612462, 1.973534388, 1.973457202, 1.973380889, 1.973305434, 1.973230823, 1.973157042, 1.973084077, 1.973011915, 1.972940542, 1.972869946, 1.972800114, 1.972731033, 1.972662692, 1.972595079, 1.972528182, 1.97246199, 1.972396491, 1.972331676, 1.972267533, 1.972204051, 1.972141222, 1.972079034, 1.972017478, 1.971956544, 1.971896224 };
private readonly static double[] T_Table_01 = { 63.65674116, 9.924843201, 5.84090931, 4.604094871, 4.032142984, 3.707428021, 3.499483297, 3.355387331, 3.249835542, 3.169272673, 3.105806516, 3.054539589, 3.012275839, 2.976842734, 2.946712883, 2.920781622, 2.89823052, 2.878440473, 2.860934606, 2.84533971, 2.831359558, 2.818756061, 2.807335684, 2.796939505, 2.787435814, 2.778714533, 2.770682957, 2.763262455, 2.756385904, 2.749995654, 2.744041919, 2.738481482, 2.733276642, 2.728394367, 2.723805589, 2.71948463, 2.715408722, 2.711557602, 2.707913184, 2.704459267, 2.701181304, 2.698066186, 2.695102079, 2.692278266, 2.689585019, 2.687013492, 2.684555618, 2.682204027, 2.679951974, 2.677793271, 2.675722234, 2.673733631, 2.671822636, 2.669984796, 2.668215988, 2.666512398, 2.664870482, 2.663286954, 2.661758752, 2.660283029, 2.658857127, 2.657478565, 2.656145025, 2.654854337, 2.653604469, 2.652393515, 2.651219685, 2.650081299, 2.648976774, 2.647904624, 2.646863444, 2.645851913, 2.644868782, 2.643912872, 2.642983067, 2.642078313, 2.641197611, 2.640340015, 2.639504627, 2.638690596, 2.637897113, 2.63712341, 2.636368757, 2.635632458, 2.634913852, 2.634212309, 2.633527229, 2.632858038, 2.632204191, 2.631565166, 2.630940463, 2.630329608, 2.629732145, 2.629147638, 2.628575671, 2.628015844, 2.627467774, 2.626931096, 2.626405457, 2.625890521, 2.625385965, 2.624891476, 2.624406758, 2.623931523, 2.623465496, 2.623008411, 2.622560015, 2.622120061, 2.621688313, 2.621264543, 2.620848534, 2.620440073, 2.620038957, 2.619644989, 2.619257981, 2.618877749, 2.618504116, 2.618136914, 2.617775976, 2.617421145, 2.617072266, 2.616729191, 2.616391776, 2.616059883, 2.615733377, 2.615412127, 2.615096008, 2.614784899, 2.61447868, 2.614177238, 2.613880461, 2.613588242, 2.613300477, 2.613017065, 2.612737908, 2.61246291, 2.61219198, 2.611925028, 2.611661966, 2.611402711, 2.611147181, 2.610895295, 2.610646976, 2.61040215, 2.610160742, 2.609922682, 2.609687901, 2.609456331, 2.609227907, 2.609002566, 2.608780245, 2.608560883, 2.608344423, 2.608130807, 2.60791998, 2.607711886, 2.607506474, 2.607303692, 2.607103489, 2.606905817, 2.606710628, 2.606517876, 2.606327515, 2.606139501, 2.605953791, 2.605770342, 2.605589114, 2.605410067, 2.605233162, 2.605058359, 2.604885623, 2.604714916, 2.604546204, 2.60437945, 2.604214622, 2.604051686, 2.60389061, 2.603731363, 2.603573912, 2.603418229, 2.603264282, 2.603112045, 2.602961487, 2.602812582, 2.602665303, 2.602519622, 2.602375515, 2.602232955, 2.602091918, 2.60195238, 2.601814317, 2.601677705, 2.601542523, 2.601408747, 2.601276355, 2.601145327, 2.601015642, 2.600887278, 2.600760216, 2.600634436 };
}
public enum Significance
{
P01,
P05,
P10,
P15,
P20,
P25
}
public class AverageSpeed
{
/// <summary>Average speed in units per second</summary>
public double Average { get; private set; }
public TimeSpan SlowWindow { get; }
public TimeSpan FastWindow { get; }
public Significance SlowSignificance { get; }
public Significance FastSignificance { get; }
private DateTime start;
private TimeSpan lastTime;
private double lastPosition = double.NaN;
private readonly record struct Point(TimeSpan Time, double Velocity);
private readonly LinkedList<Point> speeds = new();
private const int MAX_SPEEDS = 200;
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
/// <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="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>
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
{
SlowWindow = ArgumentValidator.EnsureGreaterThan(slowWindow, nameof(slowWindow), fastWindow);
FastWindow = ArgumentValidator.EnsureGreaterThan(fastWindow, nameof(fastWindow), TimeSpan.Zero);
SlowSignificance = slowSignificance;
FastSignificance = fastSignificance;
}
/// <summary>Add a new position to the moving average</summary>
public void AddPosition(double position)
{
var now = DateTime.Now;
if (start == default)
start = now;
var time = now - start;
while (speeds.Count > MAX_SPEEDS || (speeds.Count > 2 && time - speeds.First.Value.Time > SlowWindow))
speeds.RemoveFirst();
if (!double.IsNaN(lastPosition))
{
var newSpeed = (position - lastPosition) / (time - lastTime).TotalSeconds;
speeds.AddLast(new Point(time, newSpeed));
}
lastTime = time;
lastPosition = position;
Average = ComputeNextAverage();
}
private double ComputeNextAverage()
{
if (speeds.Count == 0)
return 0;
else if (speeds.Count == 1)
return speeds.Last.Value.Velocity;
else
{
var n_newest = speeds.Count(s => s.Time > lastTime.Subtract(FastWindow));
var n_oldest = speeds.Count - n_newest;
if (speeds.Take(n_oldest).T_Test_2By(s => s.Velocity, speeds.TakeLast(n_newest), FastSignificance))
{
//Speeds in FastWindow are significantly different from reset of speeds in SlowWindow.
//Discard older speeds and keep only speeds in FastWindow
for (; n_oldest > 0; n_oldest--)
speeds.RemoveFirst();
return speeds.Average(s => s.Velocity);
}
else
return
speeds.T_Test_1By(s => s.Velocity, Average, SlowSignificance)
? speeds.Average(s => s.Velocity)
: Average;
}
}
}

View File

@@ -1,8 +1,7 @@
using System;
using AAXClean;
using Dinah.Core;
using System.IO;
using System.Text;
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
{
@@ -16,15 +15,14 @@ namespace AaxDecrypter
var startOffset = chapters.StartOffset;
var trackCount = 0;
var trackCount = 1;
foreach (var c in chapters.Chapters)
{
var startTime = c.StartOffset - startOffset;
trackCount++;
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds / 1000d * 75)}");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
}
return stringBuilder.ToString();
@@ -46,7 +44,7 @@ namespace AaxDecrypter
for (var i = 0; i < cueContents.Length; i++)
{
var line = cueContents[i];
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
continue;
var fileTypeBegins = line.LastIndexOf(" ") + 1;

View File

@@ -1,30 +0,0 @@
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
{
public class DownloadOptions
{
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public ChapterInfo ChapterInfo { get; set; }
public NAudio.Lame.LameConfig LameConfig { get; set; }
public bool Downsample { get; set; }
public bool MatchSourceBitrate { get; set; }
public DownloadOptions(string downloadUrl, string userAgent)
{
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
// no null/empty check for key/iv. unencrypted files do not have them
}
}
}

View File

@@ -0,0 +1,32 @@
using AAXClean;
using System;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public interface IDownloadOptions
{
event EventHandler<long> DownloadSpeedChanged;
string DownloadUrl { get; }
string UserAgent { get; }
string AudibleKey { get; }
string AudibleIV { get; }
TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
bool DownloadClipsBookmarks { get; }
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
NAudio.Lame.LameConfig LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitle(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarksAsync(string fileName);
}
}

View File

@@ -0,0 +1,41 @@
using AAXClean;
using NAudio.Lame;
using System;
namespace AaxDecrypter
{
public static class MpegUtil
{
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
{
double bitrateMultiple = 1;
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
{
lameConfig.OutputSampleRate = mp4File.TimeScale;
}
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
{
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
}
if (mp4File.AudioChannels == 2)
{
if (downsample)
bitrateMultiple /= 2;
else
lameConfig.Mode = MPEGMode.Stereo;
}
if (matchSourceBitrate)
{
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
if (lameConfig.VBR is null)
lameConfig.BitRate = kbps;
else if (lameConfig.VBR == VBRMode.ABR)
lameConfig.ABRRateKbps = kbps;
}
}
}
}

View File

@@ -1,6 +1,4 @@
using System;
using System.IO;
using FileManager;
namespace AaxDecrypter
{
@@ -10,16 +8,6 @@ namespace AaxDecrypter
public int PartsPosition { get; set; }
public int PartsTotal { get; set; }
public string Title { get; set; }
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
{
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
return fileNamingTemplate.GetFilePath();
}
public DateTime FileDate { get; } = DateTime.Now;
}
}

View File

@@ -1,91 +1,58 @@
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Threading;
using System.Threading.Tasks;
namespace AaxDecrypter
{
/// <summary>
/// A <see cref="CookieContainer"/> for a single Uri.
/// </summary>
public class SingleUriCookieContainer : CookieContainer
{
private Uri baseAddress;
public Uri Uri
{
get => baseAddress;
set
{
baseAddress = new UriBuilder(value.Scheme, value.Host).Uri;
}
}
public CookieCollection GetCookies()
{
return GetCookies(Uri);
}
}
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <summary>A resumable, simultaneous file downloader and reader. </summary>
public class NetworkFileStream : Stream, IUpdatable
{
public event EventHandler Updated;
public event EventHandler DownloadCompleted;
#region Public Properties
/// <summary>
/// Location to save the downloaded data.
/// </summary>
/// <summary> Location to save the downloaded data. </summary>
[JsonProperty(Required = Required.Always)]
public string SaveFilePath { get; }
/// <summary>
/// Http(s) address of the file to download.
/// </summary>
/// <summary> Http(s) address of the file to download. </summary>
[JsonProperty(Required = Required.Always)]
public Uri Uri { get; private set; }
/// <summary>
/// All cookies set by caller or by the remote server.
/// </summary>
/// <summary> Http headers to be sent to the server with the request. </summary>
[JsonProperty(Required = Required.Always)]
public SingleUriCookieContainer CookieContainer { get; }
public Dictionary<string, string> RequestHeaders { get; private set; }
/// <summary>
/// Http headers to be sent to the server with the request.
/// </summary>
[JsonProperty(Required = Required.Always)]
public WebHeaderCollection RequestHeaders { get; private set; }
/// <summary>
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
/// </summary>
/// <summary> The position in <see cref="SaveFilePath"/> that has been written and flushed to disk. </summary>
[JsonProperty(Required = Required.Always)]
public long WritePosition { get; private set; }
/// <summary>
/// The total length of the <see cref="Uri"/> file to download.
/// </summary>
/// <summary> The total length of the <see cref="Uri"/> file to download. </summary>
[JsonProperty(Required = Required.Always)]
public long ContentLength { get; private set; }
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
private long _speedLimit = 0;
/// <summary>bytes per second</summary>
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
#endregion
#region Private Properties
private HttpWebRequest HttpRequest { get; set; }
private FileStream _writeFile { get; }
private FileStream _readFile { get; }
private Stream _networkStream { get; set; }
private bool hasBegunDownloading { get; set; }
public bool IsCancelled { get; private set; }
private EventWaitHandle downloadEnded { get; set; }
private EventWaitHandle downloadedPiece { get; set; }
private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle _downloadedPiece { get; set; }
private Task _backgroundDownloadTask { get; set; }
#endregion
@@ -98,32 +65,32 @@ namespace AaxDecrypter
//DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024;
//Number of times per second the download rate is checkd and throttled
private const int THROTTLE_FREQUENCY = 8;
//Minimum throttle rate. The minimum amount of data that can be throttled
//on each iteration of the download loop is DOWNLOAD_BUFF_SZ.
public const int MIN_BYTES_PER_SECOND = DOWNLOAD_BUFF_SZ * THROTTLE_FREQUENCY;
#endregion
#region Constructor
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <summary> A resumable, simultaneous file downloader and reader. </summary>
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
/// <param name="uri">Http(s) address of the file to download.</param>
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
SaveFilePath = saveFilePath;
Uri = uri;
WritePosition = writePosition;
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
RequestHeaders = requestHeaders ?? new();
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
{
@@ -139,18 +106,21 @@ namespace AaxDecrypter
#region Downloader
/// <summary>
/// Update the <see cref="JsonFilePersister"/>.
/// </summary>
private void Update()
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
private void OnUpdate()
{
RequestHeaders = HttpRequest.Headers;
Updated?.Invoke(this, EventArgs.Empty);
RequestHeaders["Range"] = $"bytes={WritePosition}-";
try
{
Updated?.Invoke(this, EventArgs.Empty);
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "An error was encountered while saving the download progress to JSON");
}
}
/// <summary>
/// Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/>
/// </summary>
/// <summary> Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/> </summary>
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
public void SetUriForSameFile(Uri uriToSameFile)
{
@@ -158,37 +128,32 @@ namespace AaxDecrypter
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (hasBegunDownloading)
if (_backgroundDownloadTask is not null)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
HttpRequest = WebRequest.CreateHttp(Uri);
HttpRequest.CookieContainer = CookieContainer;
HttpRequest.Headers = RequestHeaders;
//If NetworkFileStream is resuming, Header will already contain a range.
HttpRequest.Headers.Remove("Range");
HttpRequest.AddRange(WritePosition);
RequestHeaders["Range"] = $"bytes={WritePosition}-";
}
/// <summary>
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
/// </summary>
private void BeginDownloading()
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
/// <returns>The downloader <see cref="Task"/></returns>
public async Task BeginDownloadingAsync()
{
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
if (ContentLength != 0 && WritePosition == ContentLength)
{
hasBegunDownloading = true;
downloadEnded.Set();
_backgroundDownloadTask = Task.CompletedTask;
return;
}
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var response = HttpRequest.GetResponse() as HttpWebResponse;
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
var response = await new HttpClient().SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
@@ -196,156 +161,80 @@ namespace AaxDecrypter
//Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0-
if (WritePosition == 0)
ContentLength = response.ContentLength;
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
_networkStream = response.GetResponseStream();
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
var networkStream = await response.Content.ReadAsStreamAsync(_cancellationSource.Token);
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
new Thread(() => DownloadFile())
{ IsBackground = true }
.Start();
hasBegunDownloading = true;
return;
_backgroundDownloadTask = Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
}
/// <summary>
/// Downlod <see cref="Uri"/> to <see cref="SaveFilePath"/>.
/// </summary>
private void DownloadFile()
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
private async Task DownloadFile(Stream networkStream)
{
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ];
do
try
{
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
if (downloadPosition > nextFlush)
DateTime startTime = DateTime.Now;
long bytesReadSinceThrottle = 0;
int bytesRead;
do
{
_writeFile.Flush();
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
}
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
} while (downloadPosition < ContentLength && !IsCancelled);
downloadPosition += bytesRead;
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
if (downloadPosition > nextFlush)
{
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition;
OnUpdate();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
_downloadedPiece.Set();
}
downloadedPiece.Set();
downloadEnded.Set();
#region throttle
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
bytesReadSinceThrottle += bytesRead;
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token);
}
startTime = DateTime.Now;
bytesReadSinceThrottle = 0;
}
#endregion
#endregion
#region Json Connverters
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
public static JsonSerializerSettings GetJsonSerializerSettings()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(new CookieContainerConverter());
settings.Converters.Add(new WebHeaderCollectionConverter());
return settings;
}
WritePosition = downloadPosition;
internal class CookieContainerConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(SingleUriCookieContainer);
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new SingleUriCookieContainer()
{
Uri = new Uri(jObj["Uri"].Value<string>()),
Capacity = jObj["Capacity"].Value<int>(),
MaxCookieSize = jObj["MaxCookieSize"].Value<int>(),
PerDomainCapacity = jObj["PerDomainCapacity"].Value<int>()
};
var cookieList = jObj["Cookies"].ToList();
foreach (var cookie in cookieList)
{
result.Add(
new Cookie
{
Comment = cookie["Comment"].Value<string>(),
HttpOnly = cookie["HttpOnly"].Value<bool>(),
Discard = cookie["Discard"].Value<bool>(),
Domain = cookie["Domain"].Value<string>(),
Expired = cookie["Expired"].Value<bool>(),
Expires = cookie["Expires"].Value<DateTime>(),
Name = cookie["Name"].Value<string>(),
Path = cookie["Path"].Value<string>(),
Port = cookie["Port"].Value<string>(),
Secure = cookie["Secure"].Value<bool>(),
Value = cookie["Value"].Value<string>(),
Version = cookie["Version"].Value<int>(),
});
}
return result;
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
catch (TaskCanceledException)
{
var cookies = value as SingleUriCookieContainer;
var obj = (JObject)JToken.FromObject(value);
var container = cookies.GetCookies();
var propertyNames = container.Select(c => JToken.FromObject(c));
obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames)));
obj.WriteTo(writer);
Serilog.Log.Information("Download was cancelled");
}
}
internal class WebHeaderCollectionConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(WebHeaderCollection);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
finally
{
var jObj = JObject.Load(reader);
var result = new WebHeaderCollection();
foreach (var kvp in jObj)
result.Add(kvp.Key, kvp.Value.Value<string>());
return result;
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var jObj = new JObject();
var type = value.GetType();
var headers = value as WebHeaderCollection;
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
jObj.Add(jHeaders);
jObj.WriteTo(writer);
networkStream.Close();
_writeFile.Close();
_downloadedPiece.Set();
OnUpdate();
DownloadCompleted?.Invoke(this, null);
}
}
@@ -354,10 +243,10 @@ namespace AaxDecrypter
#region Download Stream Reader
[JsonIgnore]
public override bool CanRead => true;
public override bool CanRead => _readFile.CanRead;
[JsonIgnore]
public override bool CanSeek => true;
public override bool CanSeek => _readFile.CanSeek;
[JsonIgnore]
public override bool CanWrite => false;
@@ -367,8 +256,8 @@ namespace AaxDecrypter
{
get
{
if (!hasBegunDownloading)
BeginDownloading();
if (_backgroundDownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
return ContentLength;
}
}
@@ -385,18 +274,18 @@ namespace AaxDecrypter
[JsonIgnore]
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
public override void Flush() => throw new NotImplementedException();
public override void SetLength(long value) => throw new NotImplementedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
public override void Flush() => throw new InvalidOperationException();
public override void SetLength(long value) => throw new InvalidOperationException();
public override void Write(byte[] buffer, int offset, int count) => throw new InvalidOperationException();
public override int Read(byte[] buffer, int offset, int count)
{
if (!hasBegunDownloading)
BeginDownloading();
if (_backgroundDownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return _readFile.Read(buffer, offset, count);
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
@@ -412,32 +301,43 @@ namespace AaxDecrypter
return _readFile.Position = newPosition;
}
/// <summary>
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
/// </summary>
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (requiredPosition > WritePosition && !IsCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
while (WritePosition < requiredPosition
&& _backgroundDownloadTask?.IsCompleted is false
&& !IsCancelled)
{
_downloadedPiece.WaitOne(50);
}
}
public override void Close()
private bool disposed = false;
/*
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
*
* In derived classes, do not override the Close() method, instead, put all of the
* Stream cleanup logic in the Dispose(Boolean) method.
*/
protected override void Dispose(bool disposing)
{
IsCancelled = true;
if (disposing && !disposed)
{
_cancellationSource.Cancel();
_backgroundDownloadTask?.GetAwaiter().GetResult();
_downloadedPiece?.Dispose();
_cancellationSource?.Dispose();
_readFile.Dispose();
_writeFile.Dispose();
OnUpdate();
}
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
_readFile.Close();
_writeFile.Close();
_networkStream?.Close();
Update();
disposed = true;
base.Dispose(disposing);
}
#endregion
~NetworkFileStream()
{
downloadEnded?.Close();
downloadedPiece?.Close();
}
}
}

View File

@@ -1,11 +1,9 @@
using Dinah.Core.IO;
using Newtonsoft.Json;
namespace AaxDecrypter
{
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
/// <summary>Alias for Target </summary>
public NetworkFileStream NetworkFileStream => Target;
@@ -17,7 +15,11 @@ namespace AaxDecrypter
public NetworkFileStreamPersister(string path, string jsonPath = null)
: base(path, jsonPath) { }
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
}
protected override void Dispose(bool disposing)
{
if (disposing)
NetworkFileStream?.Dispose();
base.Dispose(disposing);
}
}
}

View File

@@ -1,76 +1,46 @@
using System;
using System.Threading;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
using FileManager;
using System;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
{
protected override StepSequence Steps { get; }
protected override long InputFilePosition => InputFileStream.WritePosition;
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, DownloadOptions dlLic)
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
{
Name = "Download Mp3 Audiobook",
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
["Step 3: Create Cue"] = Step_CreateCue,
["Step 4: Cleanup"] = Step_Cleanup,
};
AsyncSteps.Name = "Download Unencrypted Audiobook";
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
}
public override void Cancel()
public override Task CancelAsync()
{
IsCanceled = true;
CloseInputFileStream();
FinalizeDownload();
return Task.CompletedTask;
}
protected bool Step_GetMetadata()
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
OnRetrievedCoverArt(null);
TaskCompletionSource completionSource = new();
return !IsCanceled;
}
InputFileStream.DownloadCompleted += (_, _) => completionSource.SetResult();
private bool Step_DownloadAudiobookAsSingleFile()
{
DateTime startTime = DateTime.Now;
await completionSource.Task;
// MUST put InputFileStream.Length first, because it starts background downloader.
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
if (IsCanceled)
return false;
else
{
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
Thread.Sleep(200);
FinalizeDownload();
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
OnFileCreated(OutputFileName);
return true;
}
CloseInputFileStream();
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName);
SetOutputFileName(realOutputFileName);
OnFileCreated(realOutputFileName);
return !IsCanceled;
}
}
}

View File

@@ -1,4 +0,0 @@
{
"//": "https://github.com/BalassaMarton/MSBump",
BumpRevision: true
}

View File

@@ -1,21 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Version>7.7.1.1</Version>
<TargetFramework>net7.0</TargetFramework>
<Version>9.4.2.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSBump" Version="2.3.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Octokit" Version="0.51.0" />
<PackageReference Include="Octokit" Version="5.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>
</Project>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -3,18 +3,61 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
using System.Runtime.InteropServices;
using Newtonsoft.Json.Linq;
using Serilog;
namespace AppScaffolding
{
public enum ReleaseIdentifier
{
None,
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
}
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
[Flags]
public enum Variety
{
None,
Classic = 0x10000,
Chardonnay = 0x20000,
}
public static class LibationScaffolding
{
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static Variety Variety { get; private set; }
public static void SetReleaseIdentifier(Variety varietyType)
{
Variety = Enum.IsDefined(varietyType) ? varietyType : Variety.None;
var releaseID = (ReleaseIdentifier)((int)varietyType | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
if (Enum.IsDefined(releaseID))
ReleaseIdentifier = releaseID;
else
{
ReleaseIdentifier = ReleaseIdentifier.None;
Serilog.Log.Logger.Warning("Unknown release identifier @{DebugInfo}", new { Variety = varietyType, Configuration.OS, RuntimeInformation.ProcessArchitecture });
}
}
// AppScaffolding
private static Assembly _executingAssembly;
private static Assembly ExecutingAssembly
@@ -32,11 +75,12 @@ namespace AppScaffolding
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
.Max(a => a.Version);
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
{
// must occur before access to Configuration instance
Migrations.migrate_to_v5_2_0__pre_config();
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
//***********************************************//
// //
@@ -50,7 +94,6 @@ namespace AppScaffolding
public static void RunPostConfigMigrations(Configuration config)
{
AudibleApiStorage.EnsureAccountsSettingsFileExists();
PopulateMissingConfigValues(config);
//
// migrations go below here
@@ -59,92 +102,16 @@ namespace AppScaffolding
Migrations.migrate_to_v6_6_9(config);
}
public static void PopulateMissingConfigValues(Configuration config)
{
config.InProgress ??= Configuration.WinTemp;
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.CreateCueSheet)))
config.CreateCueSheet = true;
if (!config.Exists(nameof(config.RetainAaxFile)))
config.RetainAaxFile = false;
if (!config.Exists(nameof(config.SplitFilesByChapter)))
config.SplitFilesByChapter = false;
if (!config.Exists(nameof(config.StripUnabridged)))
config.StripUnabridged = false;
if (!config.Exists(nameof(config.StripAudibleBrandAudio)))
config.StripAudibleBrandAudio = false;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
if (!config.Exists(nameof(config.LameTargetBitrate)))
config.LameTargetBitrate = false;
if (!config.Exists(nameof(config.LameDownsampleMono)))
config.LameDownsampleMono = true;
if (!config.Exists(nameof(config.LameBitrate)))
config.LameBitrate = 64;
if (!config.Exists(nameof(config.LameConstantBitrate)))
config.LameConstantBitrate = false;
if (!config.Exists(nameof(config.LameMatchSourceBR)))
config.LameMatchSourceBR = true;
if (!config.Exists(nameof(config.LameVBRQuality)))
config.LameVBRQuality = 2;
if (!config.Exists(nameof(config.BadBook)))
config.BadBook = Configuration.BadBookAction.Ask;
if (!config.Exists(nameof(config.ShowImportedStats)))
config.ShowImportedStats = true;
if (!config.Exists(nameof(config.ImportEpisodes)))
config.ImportEpisodes = true;
if (!config.Exists(nameof(config.DownloadEpisodes)))
config.DownloadEpisodes = true;
if (!config.Exists(nameof(config.FolderTemplate)))
config.FolderTemplate = Templates.Folder.DefaultTemplate;
if (!config.Exists(nameof(config.FileTemplate)))
config.FileTemplate = Templates.File.DefaultTemplate;
if (!config.Exists(nameof(config.ChapterFileTemplate)))
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
if (!config.Exists(nameof(config.AutoScan)))
config.AutoScan = true;
if (!config.Exists(nameof(config.GridColumnsVisibilities)))
config.GridColumnsVisibilities = new Dictionary<string, bool>();
if (!config.Exists(nameof(config.GridColumnsDisplayIndices)))
config.GridColumnsDisplayIndices = new Dictionary<string, int>();
if (!config.Exists(nameof(config.GridColumnsWidths)))
config.GridColumnsWidths = new Dictionary<string, int>();
if (!config.Exists(nameof(config.DownloadCoverArt)))
config.DownloadCoverArt = true;
}
/// <summary>Initialize logging. Run after migration</summary>
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Configuration config)
{
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
// all else should occur after logging
wireUpSystemEvents(config);
}
private static void ensureSerilogConfig(Configuration config)
@@ -183,7 +150,7 @@ namespace AppScaffolding
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetObject("Serilog", serilogObj);
config.SetNonString(serilogObj, "Serilog");
}
// to restore original: Console.SetOut(origOut);
@@ -201,8 +168,8 @@ namespace AppScaffolding
// However, empirical testing so far has shown no issues.
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
#region Console => Serilog tests
/*
#region Console => Serilog tests
/*
// all below apply to "Console." and "Console.Out."
// captured
@@ -241,27 +208,33 @@ namespace AppScaffolding
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
Console.Write("{0}", new object[] { "arr" });
*/
#endregion
#endregion
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
private static void logStartupState(Configuration config)
{
#if DEBUG
var mode = "Debug";
#else
var mode = "Release";
#endif
if (System.Diagnostics.Debugger.IsAttached)
mode += " (Debugger attached)";
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
Log.Logger.Information("Begin. {@DebugInfo}", new
{
AppName = EntryAssembly.GetName().Name,
Version = BuildVersion.ToString(),
#if DEBUG
Mode = "Debug",
#else
Mode = "Release",
#endif
ReleaseIdentifier,
Configuration.OS,
InteropFactory.InteropFunctionsType,
Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
@@ -269,7 +242,9 @@ namespace AppScaffolding
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.LibationFiles,
config.BetaOptIn,
config.UseCoverAsFolderIcon,
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
config.InProgress,
@@ -280,13 +255,23 @@ namespace AppScaffolding
AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
});
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
}
public static UpgradeProperties GetLatestRelease()
{
// timed out
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null)
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null || zip is null)
return null;
var latestVersionString = latest.TagName.Trim('v');
@@ -298,7 +283,7 @@ namespace AppScaffolding
return null;
// we have an update
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new
@@ -308,13 +293,13 @@ namespace AppScaffolding
zipUrl
});
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body);
}
private static Octokit.Release getLatestRelease(TimeSpan timeout)
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
{
try
{
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
var task = getLatestRelease();
if (task.Wait(timeout))
return task.Result;
@@ -324,56 +309,30 @@ namespace AppScaffolding
{
Log.Logger.Error(aggEx, "Checking for new version too often");
}
return null;
return (null, null);
}
private static Octokit.Release getLatestRelease()
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
{
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
const string ownerAccount = "rmcrackan";
const string repoName = "Libation";
// https://octokitnet.readthedocs.io/en/latest/releases/
var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
var latest = releases.First(r => !r.Draft && !r.Prerelease);
return latest;
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
//Download the release index
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
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
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
}
}
internal static class Migrations
{
#region migrate to v5.2.0
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
public static void migrate_to_v5_2_0__pre_config()
{
{
var settingsKey = "DownloadsInProgressEnum";
if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
{
UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
}
}
{
UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
}
{ // appsettings.json
var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
}
}
private static string translatePath(string path)
=> path switch
{
"AppDir" => @".\LibationFiles",
"MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
"UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
_ => path
};
#endregion
public static void migrate_to_v6_6_9(Configuration config)
{
var writeToPath = $"Serilog.WriteTo";

View File

@@ -3,6 +3,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -17,12 +18,14 @@ namespace AppScaffolding
///
///
/// </summary>
internal static class UNSAFE_MigrationHelper
public static class UNSAFE_MigrationHelper
{
#region appsettings.json
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
public static string SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
#region appsettings.json
public static bool APPSETTINGS_TryGet(string key, out string value)
{
@@ -56,11 +59,7 @@ namespace AppScaffolding
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!APPSETTINGS_Json_Exists)
return;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
JObject jObj;
try
@@ -83,23 +82,15 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath
{
get
{
var success = APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value);
return !success || value is null ? null : Path.Combine(value, SETTINGS_JSON);
}
}
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static bool Settings_TryGet(string key, out string value)
@@ -267,5 +258,10 @@ namespace AppScaffolding
System.Threading.Thread.Sleep(100);
}
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}
}

View File

@@ -1,6 +1,27 @@
using System;
using NPOI.XWPF.UserModel;
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding
{
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
public partial record UpgradeProperties
{
public string ZipUrl { get; }
public string HtmlUrl { get; }
public string ZipName { get; }
public Version LatestRelease { get; }
public string Notes { get; }
public UpgradeProperties(string zipUrl, string htmlUrl, string zipName, Version latestRelease, string notes)
{
ZipName = zipName;
HtmlUrl = htmlUrl;
ZipUrl = zipUrl;
LatestRelease = latestRelease;
Notes = LinkStripRegex().Replace(notes, "$1");
}
[GeneratedRegex(@"\[(.*)\]\(.*\)")]
private static partial Regex LinkStripRegex();
}
}

View File

@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="27.2.1" />
<PackageReference Include="NPOI" Version="2.5.6" />
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="NPOI" Version="2.6.0" />
</ItemGroup>
<ItemGroup>
@@ -14,4 +14,12 @@
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,78 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
namespace ApplicationServices
{
public class BulkSetDownloadStatus
{
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
public int Count => actionSets.Count;
public IEnumerable<string> Messages => actionSets.Select(a => a.message);
public string AggregateMessage => $"Are you sure you want to set {Messages.Aggregate((a, b) => $"{a} and {b}")}?";
private List<LibraryBook> _libraryBooks;
private bool _setDownloaded;
private bool _setNotDownloaded;
public BulkSetDownloadStatus(List<LibraryBook> libraryBooks, bool setDownloaded, bool setNotDownloaded)
{
_libraryBooks = libraryBooks;
_setDownloaded = setDownloaded;
_setNotDownloaded = setNotDownloaded;
}
public int Discover()
{
var bookExistsList = _libraryBooks
.Select(libraryBook => new
{
libraryBook.Book,
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
})
.ToList();
if (_setDownloaded)
{
var books2change = bookExistsList
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
.Select(a => a.Book)
.ToList();
if (books2change.Any())
actionSets.Add((
$"{"book".PluralizeWithCount(books2change.Count)} to 'Downloaded'",
LiberatedStatus.Liberated,
books2change));
}
if (_setNotDownloaded)
{
var books2change = bookExistsList
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
.Select(a => a.Book)
.ToList();
if (books2change.Any())
actionSets.Add((
$"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'",
LiberatedStatus.NotLiberated,
books2change));
}
return Count;
}
public void Execute()
{
foreach (var a in actionSets)
a.Books.UpdateBookStatus(a.newStatus);
}
}
}

View File

@@ -12,10 +12,10 @@ namespace ApplicationServices
=> LibationContext.Create(SqliteStorage.ConnectionString);
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking();
return context.GetLibrary_Flat_NoTracking(includeParents);
}
}
}

View File

@@ -13,197 +13,205 @@ using static DtoImporterService.PerfLogger;
namespace ApplicationServices
{
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler ScanEnd;
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
static LibraryCommands()
{
ScanBegin += (_, __) => Scanning = true;
ScanEnd += (_, __) => Scanning = false;
}
static LibraryCommands()
{
ScanBegin += (_, __) => Scanning = true;
ScanEnd += (_, __) => Scanning = false;
}
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
lock (_lock)
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (accounts is null || accounts.Length == 0)
return new List<LibraryBook>();
return new List<LibraryBook>();
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
}
}
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
{
lock (_lock)
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
if (Scanning)
return (0, 0);
ScanBegin?.Invoke(null, accounts.Length);
}
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
{
lock (_lock)
{
if (Scanning)
return (0, 0);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
if (totalCount == 0)
return default;
if (totalCount == 0)
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
}
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
}
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
}
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
});
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
});
logTime($"pre scanAccountAsync {account.AccountName}");
logTime($"pre scanAccountAsync {account.AccountName}");
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
}
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
}
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
{
logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext();
var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = saveChanges(context);
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
// 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)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
@@ -211,144 +219,249 @@ namespace ApplicationServices
return newCount;
}
private static int saveChanges(LibationContext context)
{
try
{
return context.SaveChanges();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
{
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culpret is the "WithExceptionDetails" serilog extension
public static int SaveContext(LibationContext context)
{
try
{
return context.SaveChanges();
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
{
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
if (ex.InnerException is null)
throw new Exception($"{msg}{format(ex)}");
throw new Exception(
$"{msg}{format(ex)}",
new Exception($"Inner Exception{format(ex.InnerException)}"));
}
var msg = "Microsoft.EntityFrameworkCore.DbUpdateException";
if (ex.InnerException is null)
throw new Exception($"{msg}{format(ex)}");
throw new Exception(
$"{msg}{format(ex)}",
new Exception($"Inner Exception{format(ex.InnerException)}"));
}
}
#endregion
#region remove books
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
{
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
#region remove/restore books
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
private static int removeBooks(List<string> idsToRemove)
{
try
{
if (idsToRemove is null || !idsToRemove.Any())
return 0;
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
context.LibraryBooks.RemoveRange(removeLibraryBooks);
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
lb.IsDeleted = true;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
return removeLibraryBooks;
}
#endregion
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange()
{
SearchEngineCommands.FullReIndex();
LibrarySizeChanged?.Invoke(null, null);
}
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error removing books");
throw;
}
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler BookUserDefinedItemCommitted;
using var context = DbContexts.GetContext();
#region Update book details
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
{
try
{
if (books is null || !books.Any())
return 0;
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
using var context = DbContexts.GetContext();
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
// Attach() NoTracking entities before SaveChanges()
foreach (var book in books)
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
#endregion
var qtyChanges = context.SaveChanges();
if (qtyChanges == 0)
return 0;
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
// semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
// I did not benchmark before choosing the number here
if (qtyChanges > 15)
SearchEngineCommands.FullReIndex();
else
{
foreach (var book in books)
{
SearchEngineCommands.UpdateLiberatedStatus(book);
SearchEngineCommands.UpdateBookTags(book);
}
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
BookUserDefinedItemCommitted?.Invoke(null, null);
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
throw;
}
}
#endregion
#region Update book details
public static int UpdateUserDefinedItem(
this Book book,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
public static int UpdateUserDefinedItem(
this IEnumerable<Book> books,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
=> updateUserDefinedItem(
books,
udi => {
// blank tags are expected. null tags are not
if (tags is not null)
udi.Tags = tags;
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
if (bookStatus.HasValue)
udi.BookStatus = bookStatus.Value;
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
// method handles null logic
udi.SetPdfStatus(pdfStatus);
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
public static LibraryStats GetCounts()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
if (rating is not null)
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
var boolResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
public static int UpdateTags(this Book book, string tags)
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<Book> books, string tags)
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this LibraryBook libraryBook, string tags)
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> libraryBook.Book.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
}
}
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
{
try
{
if (books is null || !books.Any())
return 0;
foreach (var book in books)
action?.Invoke(book.UserDefinedItem);
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var book in books)
{
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, books);
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Error updating {nameof(Book.UserDefinedItem)}");
throw;
}
}
#endregion
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
var boolResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
}
}
}

View File

@@ -100,7 +100,13 @@ namespace ApplicationServices
[Name("Content Type")]
public string ContentType { get; set; }
}
[Name("Audio Format")]
public string AudioFormat { get; set; }
[Name("Language")]
public string Language { get; set; }
}
public static class LibToDtos
{
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
@@ -132,7 +138,9 @@ namespace ApplicationServices
MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.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
}).ToList();
}
public static class LibraryExporter
@@ -176,34 +184,36 @@ namespace ApplicationServices
var row = sheet.CreateRow(rowIndex);
var columns = new[] {
nameof (ExportDto.Account),
nameof (ExportDto.DateAdded),
nameof (ExportDto.AudibleProductId),
nameof (ExportDto.Locale),
nameof (ExportDto.Title),
nameof (ExportDto.AuthorNames),
nameof (ExportDto.NarratorNames),
nameof (ExportDto.LengthInMinutes),
nameof (ExportDto.Description),
nameof (ExportDto.Publisher),
nameof (ExportDto.HasPdf),
nameof (ExportDto.SeriesNames),
nameof (ExportDto.SeriesOrder),
nameof (ExportDto.CommunityRatingOverall),
nameof (ExportDto.CommunityRatingPerformance),
nameof (ExportDto.CommunityRatingStory),
nameof (ExportDto.PictureId),
nameof (ExportDto.IsAbridged),
nameof (ExportDto.DatePublished),
nameof (ExportDto.CategoriesNames),
nameof (ExportDto.MyRatingOverall),
nameof (ExportDto.MyRatingPerformance),
nameof (ExportDto.MyRatingStory),
nameof (ExportDto.MyLibationTags),
nameof (ExportDto.BookStatus),
nameof (ExportDto.PdfStatus),
nameof (ExportDto.ContentType)
};
nameof(ExportDto.Account),
nameof(ExportDto.DateAdded),
nameof(ExportDto.AudibleProductId),
nameof(ExportDto.Locale),
nameof(ExportDto.Title),
nameof(ExportDto.AuthorNames),
nameof(ExportDto.NarratorNames),
nameof(ExportDto.LengthInMinutes),
nameof(ExportDto.Description),
nameof(ExportDto.Publisher),
nameof(ExportDto.HasPdf),
nameof(ExportDto.SeriesNames),
nameof(ExportDto.SeriesOrder),
nameof(ExportDto.CommunityRatingOverall),
nameof(ExportDto.CommunityRatingPerformance),
nameof(ExportDto.CommunityRatingStory),
nameof(ExportDto.PictureId),
nameof(ExportDto.IsAbridged),
nameof(ExportDto.DatePublished),
nameof(ExportDto.CategoriesNames),
nameof(ExportDto.MyRatingOverall),
nameof(ExportDto.MyRatingPerformance),
nameof(ExportDto.MyRatingStory),
nameof(ExportDto.MyLibationTags),
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language)
};
var col = 0;
foreach (var c in columns)
{
@@ -268,8 +278,10 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
rowIndex++;
rowIndex++;
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);

View File

@@ -0,0 +1,198 @@
using AudibleApi.Common;
using CsvHelper;
using DataLayer;
using Newtonsoft.Json.Linq;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.Linq;
namespace ApplicationServices
{
public static class RecordExporter
{
public static void ToXlsx(string saveFilePath, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
using var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Records");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new List<string>
{
nameof(Type.Name),
nameof(IRecord.Created),
nameof(IRecord.Start) + "_ms",
};
if (records.OfType<IAnnotation>().Any())
{
columns.Add(nameof(IAnnotation.AnnotationId));
columns.Add(nameof(IAnnotation.LastModified));
}
if (records.OfType<IRangeAnnotation>().Any())
{
columns.Add(nameof(IRangeAnnotation.End) + "_ms");
columns.Add(nameof(IRangeAnnotation.Text));
}
if (records.OfType<Clip>().Any())
columns.Add(nameof(Clip.Title));
var col = 0;
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
cell.SetCellValue(c);
cell.CellStyle = detailSubtotalCellStyle;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
// Add data rows
foreach (var record in records)
{
col = 0;
row = sheet.CreateRow(++rowIndex);
row.CreateCell(col++).SetCellValue(record.GetType().Name);
var dateCreatedCell = row.CreateCell(col++);
dateCreatedCell.CellStyle = dateStyle;
dateCreatedCell.SetCellValue(record.Created.DateTime);
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
if (record is IAnnotation annotation)
{
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
var lastModifiedCell = row.CreateCell(col++);
lastModifiedCell.CellStyle = dateStyle;
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
if (annotation is IRangeAnnotation rangeAnnotation)
{
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
if (rangeAnnotation is Clip clip)
row.CreateCell(col++).SetCellValue(clip.Title);
}
}
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
}
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
var recordsEx = extendRecords(records);
var recordsObj = new JObject
{
{ "title", libraryBook.Book.Title},
{ "asin", libraryBook.Book.AudibleProductId},
{ "exportTime", DateTime.Now},
{ "records", JArray.FromObject(recordsEx) }
};
System.IO.File.WriteAllText(saveFilePath, recordsObj.ToString(Newtonsoft.Json.Formatting.Indented));
}
public static void ToCsv(string saveFilePath, IEnumerable<IRecord> records)
{
if (!records.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
//Write headers for the present record type that has the most properties
if (records.OfType<Clip>().Any())
csv.WriteHeader(typeof(ClipEx));
else if (records.OfType<Note>().Any())
csv.WriteHeader(typeof(NoteEx));
else if (records.OfType<Bookmark>().Any())
csv.WriteHeader(typeof(BookmarkEx));
else
csv.WriteHeader(typeof(LastHeardEx));
var recordsEx = extendRecords(records);
csv.NextRecord();
csv.WriteRecords(recordsEx.OfType<ClipEx>());
csv.WriteRecords(recordsEx.OfType<NoteEx>());
csv.WriteRecords(recordsEx.OfType<BookmarkEx>());
csv.WriteRecords(recordsEx.OfType<LastHeardEx>());
}
private static IEnumerable<IRecordEx> extendRecords(IEnumerable<IRecord> records)
=> records
.Select<IRecord, IRecordEx>(
r => r switch
{
Clip c => new ClipEx(nameof(Clip), c),
Note n => new NoteEx(nameof(Note), n),
Bookmark b => new BookmarkEx(nameof(Bookmark), b),
LastHeard l => new LastHeardEx(nameof(LastHeard), l),
_ => throw new InvalidOperationException(),
});
private interface IRecordEx { string Type { get; } }
private record LastHeardEx : LastHeard, IRecordEx
{
public string Type { get; }
public LastHeardEx(string type, LastHeard original) : base(original)
{
Type = type;
}
}
private record BookmarkEx : Bookmark, IRecordEx
{
public string Type { get; }
public BookmarkEx(string type, Bookmark original) : base(original)
{
Type = type;
}
}
private record NoteEx : Note, IRecordEx
{
public string Type { get; }
public NoteEx(string type, Note original) : base(original)
{
Type = type;
}
}
private record ClipEx : Clip, IRecordEx
{
public string Type { get; }
public ClipEx(string type, Clip original) : base(original)
{
Type = type;
}
}
}
}

View File

@@ -1,5 +1,7 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using LibationSearchEngine;
@@ -7,40 +9,12 @@ namespace ApplicationServices
{
public static class SearchEngineCommands
{
public static void FullReIndex(SearchEngine engine = null)
{
engine ??= new SearchEngine();
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
}
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
#region Search
public static SearchResultSet Search(string searchString) => performSafeQuery(e =>
e.Search(searchString)
);
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
);
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
e.UpdateLiberatedStatus(book)
);
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
{
var engine = new SearchEngine();
try
{
action(engine);
}
catch (FileNotFoundException)
{
FullReIndex(engine);
action(engine);
}
}
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
private static T performSafeQuery<T>(Func<SearchEngine, T> func)
{
var engine = new SearchEngine();
try
@@ -49,9 +23,79 @@ namespace ApplicationServices
}
catch (FileNotFoundException)
{
FullReIndex(engine);
fullReIndex(engine);
return func(engine);
}
}
#endregion
public static event EventHandler SearchEngineUpdated;
#region Update
private static bool isUpdating;
public static void UpdateBooks(IEnumerable<Book> books)
{
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
// I did not benchmark before choosing the number here
if (books.Count() > 15)
FullReIndex();
else
{
foreach (var book in books)
UpdateUserDefinedItems(book);
}
}
public static void FullReIndex() => performSafeCommand(fullReIndex);
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
{
e.UpdateLiberatedStatus(book);
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
e.UpdateUserRatings(book);
}
);
private static void performSafeCommand(Action<SearchEngine> action)
{
try
{
update(action);
}
catch (FileNotFoundException)
{
fullReIndex(new SearchEngine());
update(action);
}
}
private static void update(Action<SearchEngine> action)
{
if (action is null)
return;
// support nesting incl recursion
var prevIsUpdating = isUpdating;
try
{
isUpdating = true;
action(new SearchEngine());
if (!prevIsUpdating)
SearchEngineUpdated?.Invoke(null, null);
}
finally
{
isUpdating = prevIsUpdating;
}
}
private static void fullReIndex(SearchEngine engine)
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
}
#endregion
}
}

View File

@@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Polly;
using Polly.Retry;
@@ -35,42 +39,6 @@ namespace AudibleUtilities
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginCallback),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginCallback,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with external browser</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginExternal),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginExternal,
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)
{
@@ -118,31 +86,43 @@ namespace AudibleUtilities
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
{
var items = new List<Item>();
#if DEBUG
//// this will not work for multi accounts
//var library_json = "library.json";
//library_json = System.IO.Path.GetFullPath(library_json);
//if (System.IO.File.Exists(library_json))
//{
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//}
#endif
Serilog.Log.Logger.Debug("Begin initial library scan");
Serilog.Log.Logger.Debug("Beginning library scan.");
if (!items.Any())
items = await Api.GetAllLibraryItemsAsync(libraryOptions);
List<Task<List<Item>>> getChildEpisodesTasks = new();
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
await manageEpisodesAsync(items, importEpisodes);
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
{
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
}
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
Serilog.Log.Logger.Debug("Episode scan complete");
count++;
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
//await and add all episodes from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
Serilog.Log.Logger.Debug("Completed library scan.");
#if DEBUG
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
//// this will not work for multi accounts
//var library_json = "library.json";
//library_json = System.IO.Path.GetFullPath(library_json);
//if (System.IO.File.Exists(library_json))
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
#endif
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
@@ -151,60 +131,88 @@ namespace AudibleUtilities
if (exceptions is not null && exceptions.Any())
throw new AggregateException(exceptions);
}
return items;
}
#region episodes and podcasts
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
private static List<IValidator> getValidators()
{
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
try
{
// get parents
var parents = items.Where(i => i.IsEpisodes).ToList();
#if DEBUG
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText("parents.json", parentsDebug);
#endif
var type = typeof(IValidator);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
if (!parents.Any())
return;
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
items.RemoveAll(i => i.IsEpisodes);
if (importEpisodes)
{
// add children
var children = await getEpisodesAsync(parents);
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
items.AddRange(children);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
}
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
}
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
#region episodes and podcasts
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
{
var results = new List<Item>();
await concurrencySemaphore.WaitAsync();
foreach (var parent in parents)
try
{
var children = await getEpisodeChildrenAsync(parent);
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
// actual individual episode, not the parent of a series.
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
if (!children.Any())
List<Item> children;
if (parent.IsEpisodes)
{
results.Add(parent);
continue;
//The 'parent' is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
children = new() { parent };
var parentAsins = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//so we can figure out what to do about those special cases, and don't
//import the episode.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters =
{
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
return new List<Item>();
}
var realParent = seriesParents.Single(p => p.IsSeriesParent);
realParent.PurchaseDate = parent.PurchaseDate;
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
parent = realParent;
}
else
{
children = await getEpisodeChildrenAsync(parent);
if (!children.Any())
return new();
}
//A series parent will always have exactly 1 Series
parent.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
@@ -217,25 +225,22 @@ namespace AudibleUtilities
{
Asin = parent.Asin,
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin).Sort.ToString(),
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
// overload (read: abuse) IsEpisodes flag
child.Relationships = new Relationship[]
{
new Relationship
{
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode
}
};
}
results.AddRange(children);
}
children.Add(parent);
return results;
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children;
}
finally
{
concurrencySemaphore.Release();
}
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
@@ -261,8 +266,8 @@ namespace AudibleUtilities
{
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
#if DEBUG
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
#endif
}
catch (Exception ex)
@@ -277,7 +282,7 @@ namespace AudibleUtilities
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
@@ -295,22 +300,12 @@ namespace AudibleUtilities
if (childrenIds.Count != results.Count)
{
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
Serilog.Log.Logger.Error(ex, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
throw ex;
}
return results;
}
#endregion
private static List<IValidator> getValidators()
{
var type = typeof(IValidator);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
}
}
}

View File

@@ -1,15 +1,23 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="2.8.3.1" />
<PackageReference Include="AudibleApi" Version="8.1.0.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,200 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AudibleUtilities
{
public partial class Mkb79Auth : IIdentityMaintainer
{
[JsonProperty("website_cookies")]
private JObject _websiteCookies { get; set; }
[JsonProperty("adp_token")]
public string AdpToken { get; private set; }
[JsonProperty("access_token")]
public string AccessToken { get; private set; }
[JsonProperty("refresh_token")]
public string RefreshToken { get; private set; }
[JsonProperty("device_private_key")]
public string DevicePrivateKey { get; private set; }
[JsonProperty("store_authentication_cookie")]
private JObject _storeAuthenticationCookie { get; set; }
[JsonProperty("device_info")]
public DeviceInfo DeviceInfo { get; private set; }
[JsonProperty("customer_info")]
public CustomerInfo CustomerInfo { get; private set; }
[JsonProperty("expires")]
private double _expires { get; set; }
[JsonProperty("locale_code")]
public string LocaleCode { get; private set; }
[JsonProperty("activation_bytes")]
public string ActivationBytes { get; private set; }
[JsonIgnore]
public Dictionary<string, string> WebsiteCookies
{
get => _websiteCookies.ToObject<Dictionary<string, string>>();
private set => _websiteCookies = JObject.Parse(JsonConvert.SerializeObject(value, Converter.Settings));
}
[JsonIgnore]
public string StoreAuthenticationCookie
{
get => _storeAuthenticationCookie.ToObject<Dictionary<string, string>>()["cookie"];
private set => _storeAuthenticationCookie = JObject.Parse(JsonConvert.SerializeObject(new Dictionary<string, string>() { { "cookie", value } }, Converter.Settings));
}
[JsonIgnore]
public DateTime AccessTokenExpires
{
get => DateTimeOffset.FromUnixTimeMilliseconds((long)(_expires * 1000)).DateTime;
private set => _expires = new DateTimeOffset(value).ToUnixTimeMilliseconds() / 1000d;
}
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
public Task<AccessToken> GetAccessTokenAsync()
=> Task.FromResult(new AccessToken(AccessToken, AccessTokenExpires));
public Task<AdpToken> GetAdpTokenAsync()
=> Task.FromResult(new AdpToken(AdpToken));
public Task<PrivateKey> GetPrivateKeyAsync()
=> Task.FromResult(new PrivateKey(DevicePrivateKey));
}
public partial class CustomerInfo
{
[JsonProperty("account_pool")]
public string AccountPool { get; set; }
[JsonProperty("user_id")]
public string UserId { get; set; }
[JsonProperty("home_region")]
public string HomeRegion { get; set; }
[JsonProperty("name")]
public string Name { get; set; }
[JsonProperty("given_name")]
public string GivenName { get; set; }
}
public partial class DeviceInfo
{
[JsonProperty("device_name")]
public string DeviceName { get; set; }
[JsonProperty("device_serial_number")]
public string DeviceSerialNumber { get; set; }
[JsonProperty("device_type")]
public string DeviceType { get; set; }
}
public partial class Mkb79Auth
{
public static Mkb79Auth FromJson(string json)
=> JsonConvert.DeserializeObject<Mkb79Auth>(json, Converter.Settings);
public string ToJson()
=> JObject.Parse(JsonConvert.SerializeObject(this, Converter.Settings)).ToString(Formatting.Indented);
public async Task<Account> ToAccountAsync()
{
var refreshToken = new RefreshToken(RefreshToken);
var authorize = new Authorize(Locale);
var newToken = await authorize.RefreshAccessTokenAsync(refreshToken);
AccessToken = newToken.TokenValue;
AccessTokenExpires = newToken.Expires;
var api = new Api(this);
var email = await api.GetEmailAsync();
var account = new Account(email)
{
DecryptKey = ActivationBytes,
AccountName = $"{email} - {Locale.Name}",
IdentityTokens = new Identity(Locale)
};
account.IdentityTokens.Update(
await GetPrivateKeyAsync(),
await GetAdpTokenAsync(),
await GetAccessTokenAsync(),
refreshToken,
WebsiteCookies.Select(c => new KeyValuePair<string, string>(c.Key, c.Value)),
DeviceSerialNumber,
DeviceType,
AmazonAccountId,
DeviceInfo.DeviceName,
StoreAuthenticationCookie);
return account;
}
public static Mkb79Auth FromAccount(Account account)
=> new()
{
AccessToken = account.IdentityTokens.ExistingAccessToken.TokenValue,
ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey,
AdpToken = account.IdentityTokens.AdpToken.Value,
CustomerInfo = new CustomerInfo
{
AccountPool = "Amazon",
GivenName = string.Empty,
HomeRegion = "NA",
Name = string.Empty,
UserId = account.IdentityTokens.AmazonAccountId
},
DeviceInfo = new DeviceInfo
{
DeviceName = account.IdentityTokens.DeviceName,
DeviceSerialNumber = account.IdentityTokens.DeviceSerialNumber,
DeviceType = account.IdentityTokens.DeviceType,
},
DevicePrivateKey = account.IdentityTokens.PrivateKey,
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
LocaleCode = account.Locale.CountryCode,
RefreshToken = account.IdentityTokens.RefreshToken.Value,
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
WebsiteCookies = new(account.IdentityTokens.Cookies),
};
}
public static class Serialize
{
public static string ToJson(this Mkb79Auth self)
=> JObject.Parse(JsonConvert.SerializeObject(self, Converter.Settings)).ToString(Formatting.Indented);
}
internal static class Converter
{
public static readonly JsonSerializerSettings Settings = new JsonSerializerSettings
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
};
}
}

View File

@@ -12,11 +12,13 @@ namespace DataLayer.Configurations
entity.OwnsOne(b => b.Rating);
entity.Property(nameof(Book._audioFormat));
//
// CRUCIAL: ignore unmapped collections, even get-only
//
entity.Ignore(nameof(Book.Authors));
entity.Ignore(nameof(Book.Narrators));
entity.Ignore(nameof(Book.AudioFormat));
//// these don't seem to matter
//entity.Ignore(nameof(Book.AuthorNames));
//entity.Ignore(nameof(Book.NarratorNames));

View File

@@ -9,6 +9,9 @@ namespace DataLayer.Configurations
{
entity.HasKey(c => c.CategoryId);
entity.HasIndex(c => c.AudibleCategoryId);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
entity.HasData(Category.GetEmpty());
}
}
}

View File

@@ -17,6 +17,9 @@ namespace DataLayer.Configurations
.Metadata
.FindNavigation(nameof(Contributor.BooksLink))
.SetPropertyAccessMode(PropertyAccessMode.Field);
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
entity.HasData(Contributor.GetEmpty());
}
}
}

View File

@@ -1,32 +1,35 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<ApplicationIcon />
<OutputType>Library</OutputType>
<StartupObject />
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.3">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<None Update="migrate.json">

View File

@@ -0,0 +1,61 @@
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,
}
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);
}
}

View File

@@ -16,8 +16,15 @@ namespace DataLayer
}
}
// enum will be easier than bool to extend later
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
// enum will be easier than bool to extend later.
public enum ContentType
{
Unknown = 0,
Product = 1,
Episode = 2,
Parent = 4,
}
public class Book
{
@@ -32,6 +39,10 @@ namespace DataLayer
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
internal AudioFormatEnum _audioFormat;
public AudioFormat AudioFormat { get => AudioFormat.FromEnum(_audioFormat); set => _audioFormat = value.ToEnum(); }
// mutable
public string PictureId { get; set; }
public string PictureLarge { get; set; }
@@ -39,6 +50,7 @@ namespace DataLayer
// book details
public bool IsAbridged { get; private set; }
public DateTime? DatePublished { get; private set; }
public string Language { get; private set; }
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
@@ -204,11 +216,12 @@ namespace DataLayer
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
{
// don't overwrite with default values
IsAbridged |= isAbridged;
DatePublished = datePublished ?? DatePublished;
Language = language?.FirstCharToUpper() ?? Language;
}
public void UpdateCategory(Category category, DbContext context = null)

View File

@@ -11,6 +11,8 @@ namespace DataLayer
public DateTime DateAdded { get; private set; }
public string Account { get; private set; }
public bool IsDeleted { get; set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{

View File

@@ -12,7 +12,7 @@ namespace DataLayer
public float StoryRating { get; private set; }
private Rating() { }
internal Rating(float overallRating, float performanceRating, float storyRating)
public Rating(float overallRating, float performanceRating, float storyRating)
{
OverallRating = overallRating;
PerformanceRating = performanceRating;

View File

@@ -2,7 +2,6 @@
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{

View File

@@ -20,7 +20,7 @@ namespace DataLayer
PartialDownload = 0x1000
}
public class UserDefinedItem
public partial class UserDefinedItem
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
@@ -30,10 +30,6 @@ namespace DataLayer
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
// import previously saved tags
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
}
#region Tags
@@ -55,18 +51,23 @@ namespace DataLayer
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
private static string sanitize(string input)
/// <summary>
/// only legal chars are letters numbers underscores and separating whitespace
///
/// technically, the only char.s which aren't easily supported are \ [ ]
/// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
/// it's easy to expand whitelist as needed
/// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
///
/// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
/// full list of characters which must be escaped:
/// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
/// </summary>
[GeneratedRegex(@"[^\w\d\s_]")]
private static partial Regex IllegalCharacterRegex();
private static string sanitize(string input)
{
if (string.IsNullOrWhiteSpace(input))
return "";
@@ -77,9 +78,9 @@ namespace DataLayer
// assume a hyphen is supposed to be an underscore
.Replace("-", "_");
var unique = regex
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
var unique = IllegalCharacterRegex()
// turn illegal characters into a space. this will also take care of turning new lines into spaces
.Replace(str, " ")
// split and remove excess spaces
.Split(new[] { ' ' }, StringSplitOptions.RemoveEmptyEntries)
// de-dup
@@ -150,10 +151,19 @@ namespace DataLayer
}
}
}
public void SetPdfStatus(LiberatedStatus? pdfStatus)
{
// don't change whether pdf is actually available. if null, leave as null. if not null, only assign non-null
// null => non-null : only when adding a supplement
if (pdfStatus.HasValue && PdfStatus.HasValue)
PdfStatus = pdfStatus;
}
public LiberatedStatus? PdfStatus
{
get => _pdfStatus;
set
internal set
{
if (_pdfStatus != value)
{

View File

@@ -93,10 +93,12 @@ namespace DataLayer
var starString = new string(STAR, fullStars);
if (score - fullStars >= 0.25f)
starString += HALF;
if (score - fullStars >= 0.75f)
starString += STAR;
else if (score - fullStars >= 0.25f)
starString += HALF;
return starString;
return starString;
}
}
}

View File

@@ -1,10 +1,9 @@
using DataLayer.Configurations;
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public class LibationContext : InterceptableDbContext
public class LibationContext : DbContext
{
// IMPORTANT: USING DbSet<>
// ========================
@@ -35,14 +34,6 @@ namespace DataLayer
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
internal LibationContext(DbContextOptions options) : base(options) { }
// called on each instantiation
protected override void OnConfiguring(DbContextOptionsBuilder optionsBuilder)
{
AddInterceptor(new TagPersistenceInterceptor());
base.OnConfiguring(optionsBuilder);
}
// typically only called once per execution; NOT once per instantiation
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
@@ -56,15 +47,6 @@ namespace DataLayer
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig());
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
modelBuilder
.Entity<Category>()
.HasData(Category.GetEmpty());
modelBuilder
.Entity<Contributor>()
.HasData(Contributor.GetEmpty());
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
}

View File

@@ -0,0 +1,397 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20220624214932_AddAudioFormat")]
partial class AddAudioFormat
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.6");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,26 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
public partial class AddAudioFormat : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<long>(
name: "_audioFormat",
table: "Books",
type: "INTEGER",
nullable: false,
defaultValue: 0L);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "_audioFormat",
table: "Books");
}
}
}

View File

@@ -0,0 +1,401 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20221214205106_LibraryBookIsDeleted")]
partial class LibraryBookIsDeleted
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class LibraryBookIsDeleted : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<bool>(
name: "IsDeleted",
table: "LibraryBooks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsDeleted",
table: "LibraryBooks");
}
}
}

View File

@@ -0,0 +1,404 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20230201162454_AddBookLanguage")]
partial class AddBookLanguage
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddBookLanguage : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Language",
table: "Books",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Language",
table: "Books");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "6.0.4");
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
modelBuilder.Entity("DataLayer.Book", b =>
{
@@ -41,6 +41,9 @@ namespace DataLayer.Migrations
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
@@ -56,6 +59,9 @@ namespace DataLayer.Migrations
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
@@ -157,6 +163,9 @@ namespace DataLayer.Migrations
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");

View File

@@ -35,5 +35,17 @@ namespace DataLayer
.Include(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(b => b.Category).ThenInclude(c => c.ParentCategory);
public static bool IsProduct(this Book book)
=> book.ContentType is not ContentType.Episode and not ContentType.Parent;
public static bool IsEpisodeChild(this Book book)
=> book.ContentType is ContentType.Episode;
public static bool IsEpisodeParent(this Book book)
=> book.ContentType is ContentType.Parent;
public static bool HasLiberated(this Book book)
=> book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated ||
book.UserDefinedItem.PdfStatus is not null and LiberatedStatus.Liberated;
}
}

View File

@@ -15,11 +15,13 @@ namespace DataLayer
// .GetLibrary()
// .ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.AsEnumerable()
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
@@ -35,10 +37,78 @@ namespace DataLayer
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
=> library
.Where(lb => !lb.IsDeleted)
.getLibrary();
public static List<LibraryBook> GetDeletedLibraryBooks(this LibationContext context)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.Where(lb => lb.IsDeleted)
.getLibrary()
.ToList();
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
private static IQueryable<LibraryBook> getLibrary(this IQueryable<LibraryBook> library)
=> library
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(libraryBooks.FindChildren);
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks
.Where(lb => lb.Book.IsEpisodeChild())
.ExceptBy(
libraryBooks
.ParentedEpisodes()
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
#nullable enable
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> libraryBooks, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return libraryBooks.FirstOrDefault(
lb =>
lb.Book.IsEpisodeParent() &&
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (System.Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
#nullable disable
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList
.Where(
lb =>
lb.Book.IsEpisodeChild() &&
lb.Book.SeriesLink?
.Any(
s =>
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
) == true
).ToList();
public static IEnumerable<LibraryBook> UnLiberated(this IEnumerable<LibraryBook> bookList)
=> bookList
.Where(
lb =>
lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
|| lb.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload
);
}
}

View File

@@ -1,30 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core.Collections.Generic;
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
internal class TagPersistenceInterceptor : IDbInterceptor
{
public void Executed(DbContext context) { }
public void Executing(DbContext context)
{
var tagsCollection
= context
.ChangeTracker
.Entries()
.Where(e => e.State.In(EntityState.Modified, EntityState.Added))
.Select(e => e.Entity as UserDefinedItem)
.Where(udi => udi is not null)
// do NOT filter out entires with blank tags. blank is the valid way to show the absence of tags
.Select(t => (t.Book.AudibleProductId, t.Tags))
.ToList();
LibationFileManager.TagsPersistence.Save(tagsCollection);
}
}
}

View File

@@ -75,7 +75,7 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
var contentType = GetContentType(item);
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
@@ -84,7 +84,8 @@ namespace DtoImporterService
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = item
.Authors
.Select(a => contributorImporter.Cache[a.Name])
.DistinctBy(a => a.Name)
.Select(a => contributorImporter.Cache[a.Name])
.ToList();
var narrators
@@ -94,14 +95,16 @@ namespace DtoImporterService
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
: item
.Narrators
.Select(n => contributorImporter.Cache[n.Name])
.DistinctBy(a => a.Name)
.Select(n => contributorImporter.Cache[n.Name])
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is also possible
// CATEGORY HACK: only use the 1st 2 categories
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var lastCategory
= item.Categories.Length == 0 ? ""
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
@@ -149,9 +152,9 @@ namespace DtoImporterService
book.ReplacePublisher(publisher);
}
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
if (item.PdfUrl is not null)
if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
return book;
@@ -161,6 +164,9 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
var codec = item.AvailableCodecs?.Max(f => AudioFormat.FromString(f.EnhancedCodec)) ?? new AudioFormat();
book.AudioFormat = codec;
// set/update book-specific info which may have changed
if (item.PictureId is not null)
book.PictureId = item.PictureId;
@@ -168,7 +174,15 @@ namespace DtoImporterService
if (item.PictureLarge is not null)
book.PictureLarge = item.PictureLarge;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
// 2023-02-01
// updateBook must update language on books which were imported before the migration which added language.
// Can eventually delete this
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
book.UpdateProductRating(
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
@@ -184,5 +198,15 @@ namespace DtoImporterService
}
}
}
private static DataLayer.ContentType GetContentType(Item item)
{
if (item.IsEpisodes)
return DataLayer.ContentType.Episode;
else if (item.IsSeriesParent)
return DataLayer.ContentType.Parent;
else
return DataLayer.ContentType.Product;
}
}
}

View File

@@ -91,7 +91,7 @@ namespace DtoImporterService
return hash.Count;
}
private Contributor addContributor(string name, string id = null)
private Contributor addContributor(string name, string id = null)
{
try
{
@@ -108,6 +108,6 @@ namespace DtoImporterService
Serilog.Log.Logger.Error(ex, "Error adding contributor. {@DebugInfo}", new { name, id });
throw;
}
}
}
}
}
}

View File

@@ -1,7 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>

View File

@@ -55,7 +55,7 @@ namespace DtoImporterService
protected ItemsImporterBase(LibationContext context) : base(context) { }
protected abstract IValidator Validator { get; }
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
public sealed override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems)
=> Validator.Validate(importItems.Select(i => i.DtoItem));
}
}

View File

@@ -49,7 +49,7 @@ namespace DtoImporterService
// just use the first
var hash = newItems.ToDictionarySafe(dto => dto.DtoItem.ProductId);
foreach (var kvp in hash)
{
{
var newItem = kvp.Value;
var libraryBook = new LibraryBook(

View File

@@ -1,16 +1,48 @@
using System;
using LibationFileManager;
using NAudio.Lame;
using System;
using System.Threading.Tasks;
namespace FileLiberator
{
public abstract class AudioDecodable : Processable
{
public event EventHandler<Action<byte[]>> RequestCoverArt;
public delegate byte[] RequestCoverArtHandler(object sender, EventArgs eventArgs);
public event RequestCoverArtHandler RequestCoverArt;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageDiscovered;
public abstract void Cancel();
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(object _, string title)
{
@@ -32,10 +64,10 @@ namespace FileLiberator
NarratorsDiscovered?.Invoke(this, narrators);
}
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
protected byte[] OnRequestCoverArt()
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
RequestCoverArt?.Invoke(this, setCoverArtDel);
return RequestCoverArt?.Invoke(this, new());
}
protected void OnCoverImageDiscovered(byte[] coverImage)

View File

@@ -1,56 +1,56 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using Dinah.Core;
using FileManager;
using LibationFileManager;
namespace FileLiberator
{
public static class AudioFileStorageExt
{
private class MultipartRenamer
{
private LibraryBook libraryBook { get; }
public static class AudioFileStorageExt
{
/// <summary>
/// DownloadDecryptBook:
/// File path for where to move files into.
/// Path: directory nested inside of Books directory
/// File name: n/a
/// </summary>
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
{
if (libraryBook.Book.IsEpisodeChild() && Configuration.Instance.SavePodcastsToParentFolder)
{
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
var seriesParent = ApplicationServices.DbContexts.GetContext().GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
if (seriesParent is not null)
{
var baseDir = Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");
return Templates.Folder.GetFilename(libraryBook.ToDto(), baseDir, "");
}
}
}
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
}
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
}
/// <summary>
/// DownloadDecryptBook:
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
=> new MultipartRenamer(libraryBook).MultipartFilename;
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// DownloadDecryptBook:
/// File path for where to move files into.
/// Path: directory nested inside of Books directory
/// File name: n/a
/// </summary>
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
=> Templates.Folder.GetFilename(libraryBook.ToDto());
/// <summary>
/// DownloadDecryptBook:
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file already exists
/// </summary>
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
}
/// <summary>
/// PDF: audio file already exists
/// </summary>
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
}
}

View File

@@ -1,10 +1,10 @@
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AAXClean;
using AAXClean.Codecs;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using FileManager;
@@ -12,90 +12,112 @@ using LibationFileManager;
namespace FileLiberator
{
public class ConvertToMp3 : AudioDecodable
{
public override string Name => "Convert to Mp3";
private Mp4File m4bBook;
public class ConvertToMp3 : AudioDecodable
{
public override string Name => "Convert to Mp3";
private Mp4Operation Mp4Operation;
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
private long fileSize;
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
private bool cancelled = false;
public override void Cancel()
{
m4bBook?.Cancel();
cancelled = true;
}
public static bool ValidateMp3(LibraryBook libraryBook)
public static bool ValidateMp3(LibraryBook libraryBook)
{
var path = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
}
var paths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
return paths.Any(path => path?.ToString()?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path)));
}
public override bool Validate(LibraryBook libraryBook) => ValidateMp3(libraryBook);
public override bool Validate(LibraryBook libraryBook) => ValidateMp3(libraryBook);
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
OnStreamingBegin($"Begin converting {libraryBook} to mp3");
try
{
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
try
{
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
foreach (var m4bPath in m4bPaths)
{
var proposedMp3Path = Mp3FileName(m4bPath);
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
fileSize = m4bBook.InputStream.Length;
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
OnTitleDiscovered(m4bBook.AppleTags.Title);
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
OnTitleDiscovered(m4bBook.AppleTags.Title);
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
var config = Configuration.Instance;
var lameConfig = GetLameOptions(config);
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File));
m4bBook.InputStream.Close();
mp3File.Close();
//Finishing configuring lame encoder.
AaxDecrypter.MpegUtil.ConfigureLameOptions(
m4bBook,
lameConfig,
config.LameDownsampleMono,
config.LameMatchSourceBR);
var proposedMp3Path = Mp3FileName(m4bPath);
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
OnFileCreated(libraryBook, realMp3Path);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
try
{
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
await Mp4Operation;
if (result == ConversionResult.Failed)
return new StatusHandler { "Conversion failed" };
else if (result == ConversionResult.Cancelled)
return new StatusHandler { "Cancelled" };
else
return new StatusHandler();
}
finally
{
OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
OnCompleted(libraryBook);
}
}
if (Mp4Operation.IsCanceled)
{
FileUtility.SaferDelete(mp3File.Name);
return new StatusHandler { "Cancelled" };
}
else
{
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
OnFileCreated(libraryBook, realMp3Path);
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "AAXClean error");
return new StatusHandler { "Conversion failed" };
}
finally
{
if (Mp4Operation is not null)
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = m4bBook.Duration;
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
m4bBook.InputStream.Close();
mp3File.Close();
}
}
}
finally
{
OnCompleted(libraryBook);
}
return new StatusHandler();
}
if (double.IsNormal(estTimeRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
OnStreamingProgressChanged(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(fileSize * progressPercent),
TotalBytesToReceive = fileSize
});
}
}
if (double.IsNormal(estTimeRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.FractionCompleted;
OnStreamingProgressChanged(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
});
}
}
}

View File

@@ -4,7 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using AudibleApi;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -20,7 +20,7 @@ namespace FileLiberator
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override void Cancel() => abDownloader?.Cancel();
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
@@ -67,23 +67,25 @@ namespace FileLiberator
FileUtility.SaferDelete(tmpFile.Path);
return abDownloader?.IsCanceled == true ?
new StatusHandler { "Cancelled" } :
new StatusHandler { "Cancelled" } :
new StatusHandler { "Decrypt failed" };
}
// moves new files from temp dir to final dest.
// This could take a few seconds if moving hundreds of files.
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
var finalStorageDir = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
// decrypt failed
if (!movedAudioFile)
if (finalStorageDir is null)
return new StatusHandler { "Cannot find final audio file after decryption" };
if (Configuration.Instance.DownloadCoverArt)
DownloadCoverArt(libraryBook);
downloadCoverArt(libraryBook);
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
// contains logic to check for config setting and OS
WindowsDirectory.SetCoverAsFolderIcon(pictureId: libraryBook.Book.PictureId, directory: finalStorageDir);
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
return new StatusHandler();
}
@@ -95,129 +97,210 @@ namespace FileLiberator
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
{
OnStreamingBegin($"Begin decrypting {libraryBook}");
var config = Configuration.Instance;
try
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
using var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
else
{
var config = Configuration.Instance;
AaxcDownloadConvertBase converter
= config.SplitFilesByChapter ?
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
downloadValidation(libraryBook);
if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var audiobookDlLic = BuildDownloadOptions(config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, audiobookDlLic.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic);
else
{
AaxcDownloadConvertBase converter
= config.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
outFileName, cacheDir, audiobookDlLic,
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook))
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic);
if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
abDownloader = converter;
}
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
abDownloader.RetrievedTitle += OnTitleDiscovered;
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
var success = await Task.Run(abDownloader.Run);
return success;
}
finally
{
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
abDownloader = converter;
}
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
abDownloader.RetrievedTitle += OnTitleDiscovered;
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
return await abDownloader.RunAsync();
}
private static DownloadOptions BuildDownloadOptions(Configuration config, AudibleApi.Common.ContentLicense contentLic)
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
{
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
//I also assume that if DrmType != Adrm, the file will be an mp3.
//These assumptions may be wrong, and only time and bug reports will tell.
//If DrmType != Adrm the delivered file is an unencrypted mp3.
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
var outputFormat
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
? OutputFormat.Mp3
: OutputFormat.M4b;
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
OutputFormat.Mp3 : OutputFormat.M4b;
long chapterStartMs
= config.StripAudibleBrandAudio
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
var audiobookDlLic = new DownloadOptions
(
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
Resources.USER_AGENT
)
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
{
AudibleKey = contentLic?.Voucher?.Key,
AudibleIV = contentLic?.Voucher?.Iv,
OutputFormat = outputFormat,
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
RetainEncryptedFile = config.RetainAaxFile && encrypted,
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
CreateCueSheet = config.CreateCueSheet
};
OutputFormat = outputFormat,
LameConfig = GetLameOptions(config),
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
};
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).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;
long startMs = audiobookDlLic.TrimOutputToChapterLength ?
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
if (i == 0)
chapLenMs -= chapterStartMs;
audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
for (int i = 0; i < contentLic.ContentMetadata.ChapterInfo.Chapters.Length; i++)
{
var chapter = contentLic.ContentMetadata.ChapterInfo.Chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= startMs;
if (config.StripAudibleBrandAudio && i == contentLic.ContentMetadata.ChapterInfo.Chapters.Length - 1)
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
audiobookDlLic.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
audiobookDlLic.LameConfig = new();
audiobookDlLic.LameConfig.Mode = NAudio.Lame.MPEGMode.Mono;
return dlOptions;
}
if (config.LameTargetBitrate)
{
if (config.LameConstantBitrate)
audiobookDlLic.LameConfig.BitRate = config.LameBitrate;
else
{
audiobookDlLic.LameConfig.ABRRateKbps = config.LameBitrate;
audiobookDlLic.LameConfig.VBR = NAudio.Lame.VBRMode.ABR;
audiobookDlLic.LameConfig.WriteVBRTag = true;
/*
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<AudibleApi.Common.Chapter> flattenChapters(IList<AudibleApi.Common.Chapter> chapters, string titleConcat = ": ")
{
List<AudibleApi.Common.Chapter> chaps = new();
foreach (var c in chapters)
{
if (c.Chapters is null)
chaps.Add(c);
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);
c.Chapters = null;
}
}
else
{
audiobookDlLic.LameConfig.VBR = NAudio.Lame.VBRMode.Default;
audiobookDlLic.LameConfig.VBRQuality = config.LameVBRQuality;
audiobookDlLic.LameConfig.WriteVBRTag = true;
}
return chaps;
}
return audiobookDlLic;
public static void combineCredits(IList<AudibleApi.Common.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]);
}
}
private static void downloadValidation(LibraryBook libraryBook)
@@ -247,12 +330,12 @@ namespace FileLiberator
if (e is not null)
OnCoverImageDiscovered(e);
else if (Configuration.Instance.AllowLibationFixup)
OnRequestCoverArt(abDownloader.SetCoverArt);
abDownloader.SetCoverArt(OnRequestCoverArt());
}
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private static string moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
{
// create final directory. move each file into it
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
@@ -260,56 +343,51 @@ namespace FileLiberator
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
if (getFirstAudio() == default)
return false;
if (getFirstAudio() == default)
return null;
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
// propogate 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 };
}
// 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 };
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
AudibleFileStorage.Audio.Refresh();
AudibleFileStorage.Audio.Refresh();
return true;
}
return destinationDir;
}
private void DownloadCoverArt(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
private static void downloadCoverArt(LibraryBook libraryBook)
{
var coverPath = "[null]";
try
{
if (File.Exists(coverPath))
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath);
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
(libraryBook.Book.PictureId, PictureSize.Native) :
(libraryBook.Book.PictureLarge, PictureSize.Native);
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
var picBytes = PictureStorage.GetPictureSynchronously(new(libraryBook.Book.PictureLarge ?? libraryBook.Book.PictureId, PictureSize.Native));
if (picBytes.Length > 0)
File.WriteAllBytes(coverPath, picBytes);
}
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.");
}
}
}
}
}

View File

@@ -0,0 +1,96 @@
using AaxDecrypter;
using AAXClean;
using Dinah.Core;
using DataLayer;
using LibationFileManager;
using System.Threading.Tasks;
using System;
using System.IO;
using ApplicationServices;
namespace FileLiberator
{
public class DownloadOptions : IDownloadOptions, IDisposable
{
public event EventHandler<long> DownloadSpeedChanged;
public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public TimeSpan RuntimeLength { get; init; }
public OutputFormat OutputFormat { get; init; }
public ChapterInfo ChapterInfo { get; init; }
public NAudio.Lame.LameConfig LameConfig { get; init; }
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => config.AllowLibationFixup && config.StripAudibleBrandAudio;
public bool StripUnabridged => config.AllowLibationFixup && config.StripUnabridged;
public bool CreateCueSheet => config.CreateCueSheet;
public bool DownloadClipsBookmarks => config.DownloadClipsBookmarks;
public long DownloadSpeedBps => config.DownloadSpeedLimit;
public bool RetainEncryptedFile => config.RetainAaxFile;
public bool FixupFile => config.AllowLibationFixup;
public bool Downsample => config.AllowLibationFixup && config.LameDownsampleMono;
public bool MatchSourceBitrate => config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate;
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
public string GetMultipartFileName(MultiConvertFileProperties props)
{
var baseDir = Path.GetDirectoryName(props.OutputFileName);
var extension = Path.GetExtension(props.OutputFileName);
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir, extension);
}
public string GetMultipartTitle(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
{
if (DownloadClipsBookmarks)
{
var format = config.ClipsBookmarksFileFormat;
var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension);
var api = await LibraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
switch(format)
{
case Configuration.ClipBookmarkFormat.CSV:
RecordExporter.ToCsv(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Xlsx:
RecordExporter.ToXlsx(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Json:
RecordExporter.ToJson(filePath, LibraryBook, records);
break;
}
return filePath;
}
return string.Empty;
}
private readonly Configuration config;
private readonly IDisposable cancellation;
public void Dispose() => cancellation?.Dispose();
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
{
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
// no null/empty check for key/iv. unencrypted files do not have them
LibraryBookDto = LibraryBook.ToDto();
cancellation =
config
.ObservePropertyChanged<long>(
nameof(Configuration.DownloadSpeedLimit),
newVal => DownloadSpeedChanged?.Invoke(this, newVal));
}
}
}

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ApplicationServices;
using DataLayer;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
@@ -23,22 +24,30 @@ namespace FileLiberator
{
OnBegin(libraryBook);
try
try
{
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
return result;
}
return result;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error downloading PDF");
var result = new StatusHandler();
result.AddError($"Error downloading PDF. See log for details. Error summary: {ex.Message}");
return result;
}
finally
{
OnCompleted(libraryBook);
}
}
}
}
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
{
@@ -57,27 +66,18 @@ namespace FileLiberator
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
{
OnStreamingBegin(proposedDownloadFilePath);
var api = await libraryBook.GetApiAsync();
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
try
{
var api = await libraryBook.GetApiAsync();
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
var client = new HttpClient();
var client = new HttpClient();
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated(libraryBook, actualDownloadedFilePath);
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated(libraryBook, actualDownloadedFilePath);
OnStatusUpdate(actualDownloadedFilePath);
return actualDownloadedFilePath;
}
finally
{
OnStreamingCompleted(proposedDownloadFilePath);
}
OnStatusUpdate(actualDownloadedFilePath);
return actualDownloadedFilePath;
}
private static StatusHandler verifyDownload(string actualDownloadedFilePath)

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
@@ -11,4 +11,13 @@
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -5,17 +5,22 @@ using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using LibationFileManager;
namespace FileLiberator
{
public abstract class Processable : Streamable
public abstract class Processable
{
public abstract string Name { get; }
public event EventHandler<LibraryBook> Begin;
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
public event EventHandler<string> StatusUpdate;
/// <summary>Fired when a file is successfully saved to disk</summary>
public event EventHandler<(string id, string path)> FileCreated;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public event EventHandler<LibraryBook> Completed;
@@ -29,7 +34,7 @@ namespace FileLiberator
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
=> library.Where(libraryBook =>
Validate(libraryBook)
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes)
&& (!libraryBook.Book.IsEpisodeChild() || Configuration.Instance.DownloadEpisodes)
);
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
@@ -49,6 +54,8 @@ namespace FileLiberator
= (await ProcessAsync(libraryBook))
?? new StatusHandler { "Processable should never return a null status" };
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
return status;
}
@@ -69,6 +76,23 @@ namespace FileLiberator
StatusUpdate?.Invoke(this, statusUpdate);
}
protected void OnFileCreated(LibraryBook libraryBook, string path)
{
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), libraryBook.Book.AudibleProductId, path });
FilePathCache.Insert(libraryBook.Book.AudibleProductId, path);
FileCreated?.Invoke(this, (libraryBook.Book.AudibleProductId, path));
}
protected void OnStreamingProgressChanged(DownloadProgress progress)
=> OnStreamingProgressChanged(null, progress);
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
=> StreamingProgressChanged?.Invoke(this, progress);
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
=> OnStreamingTimeRemaining(null, timeRemaining);
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
=> StreamingTimeRemaining?.Invoke(this, timeRemaining);
protected void OnCompleted(LibraryBook libraryBook)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });

View File

@@ -1,47 +0,0 @@
using System;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public abstract class Streamable
{
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public event EventHandler<string> StreamingCompleted;
/// <summary>Fired when a file is successfully saved to disk</summary>
public event EventHandler<(string id, string path)> FileCreated;
protected void OnStreamingBegin(string filePath)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = filePath });
StreamingBegin?.Invoke(this, filePath);
}
protected void OnStreamingProgressChanged(DownloadProgress progress) => OnStreamingProgressChanged(null, progress);
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
{
StreamingProgressChanged?.Invoke(this, progress);
}
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining) => OnStreamingTimeRemaining(null, timeRemaining);
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
{
StreamingTimeRemaining?.Invoke(this, timeRemaining);
}
protected void OnStreamingCompleted(string filePath)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = filePath });
StreamingCompleted?.Invoke(this, filePath);
}
protected void OnFileCreated(DataLayer.LibraryBook libraryBook, string path) => OnFileCreated(libraryBook.Book.AudibleProductId, path);
protected void OnFileCreated(string id, string path)
{
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), id, path });
LibationFileManager.FilePathCache.Insert(id, path);
FileCreated?.Invoke(this, (id, path));
}
}
}

View File

@@ -27,17 +27,26 @@ namespace FileLiberator
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
{
Account = libraryBook.Account,
DateAdded = libraryBook.DateAdded,
AudibleProductId = libraryBook.Book.AudibleProductId,
Title = libraryBook.Book.Title ?? "",
Locale = libraryBook.Book.Locale,
YearPublished = libraryBook.Book.DatePublished?.Year,
DatePublished = libraryBook.Book.DatePublished,
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
SeriesNumber = (int?)libraryBook.Book.SeriesLink.FirstOrDefault()?.Index,
IsPodcast = libraryBook.Book.IsEpisodeChild(),
BitRate = libraryBook.Book.AudioFormat.Bitrate,
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
Channels = libraryBook.Book.AudioFormat.Channels,
Language = libraryBook.Book.Language
};
}
}

View File

@@ -1 +0,0 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]

View File

@@ -12,7 +12,7 @@ namespace FileManager
/// </summary>
public class BackgroundFileSystem
{
public string RootDirectory { get; private set; }
public LongPath RootDirectory { get; private set; }
public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; }
@@ -21,9 +21,9 @@ namespace FileManager
private Task backgroundScanner { get; set; }
private object fsCacheLocker { get; } = new();
private List<string> fsCache { get; } = new();
private List<LongPath> fsCache { get; } = new();
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
public BackgroundFileSystem(LongPath rootDirectory, string searchPattern, SearchOption searchOptions)
{
RootDirectory = rootDirectory;
SearchPattern = searchPattern;
@@ -32,12 +32,18 @@ namespace FileManager
Init();
}
public string FindFile(System.Text.RegularExpressions.Regex regex)
public LongPath FindFile(System.Text.RegularExpressions.Regex regex)
{
lock (fsCacheLocker)
return fsCache.FirstOrDefault(s => regex.IsMatch(s));
}
public List<LongPath> FindFiles(System.Text.RegularExpressions.Regex regex)
{
lock (fsCacheLocker)
return fsCache.Where(s => regex.IsMatch(s)).ToList();
}
public void RefreshFiles()
{
lock (fsCacheLocker)
@@ -73,8 +79,13 @@ namespace FileManager
//Stop raising events
fileSystemWatcher?.Dispose();
//Calling CompleteAdding() will cause background scanner to terminate.
directoryChangesEvents?.CompleteAdding();
try
{
//Calling CompleteAdding() will cause background scanner to terminate.
directoryChangesEvents?.CompleteAdding();
}
// if directoryChangesEvents is non-null and isDisposed, this exception is thrown. there's no other way to check >:(
catch (ObjectDisposedException) { }
//Wait for background scanner to terminate before reinitializing.
backgroundScanner?.Wait();
@@ -124,28 +135,33 @@ namespace FileManager
}
}
private void RemovePath(string path)
private void RemovePath(LongPath path)
{
var pathsToRemove = fsCache.Where(p => p.StartsWith(path)).ToArray();
path = path.LongPathName;
var pathsToRemove = fsCache.Where(p => ((string)p).StartsWith(path)).ToArray();
foreach (var p in pathsToRemove)
fsCache.Remove(p);
}
private void AddPath(string path)
private void AddPath(LongPath path)
{
if (!File.Exists(path)) return;
path = path.LongPathName;
if (!File.Exists(path) && !Directory.Exists(path))
return;
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
AddUniqueFiles(FileUtility.SaferEnumerateFiles(path, SearchPattern, SearchOption));
else
AddUniqueFile(path);
}
private void AddUniqueFiles(IEnumerable<string> newFiles)
private void AddUniqueFiles(IEnumerable<LongPath> newFiles)
{
foreach (var file in newFiles)
AddUniqueFile(file);
}
private void AddUniqueFile(string newFile)
private void AddUniqueFile(LongPath newFile)
{
if (!fsCache.Contains(newFile))
fsCache.Add(newFile);

View File

@@ -1,12 +1,20 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<TargetFramework>net7.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="4.4.0.1" />
<PackageReference Include="Dinah.Core" Version="7.2.2.1" />
<PackageReference Include="Polly" Version="7.2.3" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>

View File

@@ -1,64 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
namespace FileManager
{
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
public class FileNamingTemplate
{
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: &lt;name&gt;</summary>
public string Template { get; }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public FileNamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
/// <summary>Convenience method</summary>
public void AddParameterReplacement(string key, object value)
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
=> ParameterReplacements.Add(key, value);
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
public int? ParameterMaxSize { get; set; } = 50;
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
public string IllegalCharacterReplacements { get; set; }
/// <summary>Generate a valid path for this file or directory</summary>
public string GetFilePath(bool returnFirstExisting = false)
{
var filename = Template;
foreach (var r in ParameterReplacements)
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements, returnFirstExisting);
}
private static string formatKey(string key)
=> key
.Replace("<", "")
.Replace(">", "");
private string formatValue(object value)
{
if (value is null)
return "";
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
// Esp important for file templates.
var val = value
.ToString()
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
return
ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
? val.Truncate(ParameterMaxSize.Value)
: val;
}
}
}

View File

@@ -6,267 +6,248 @@ using System.Text.RegularExpressions;
using Dinah.Core;
using Polly;
using Polly.Retry;
using Dinah.Core.Collections.Generic;
namespace FileManager
{
public static class FileUtility
{
/// <summary>
/// "txt" => ".txt"
/// <br />".txt" => ".txt"
/// <br />null or whitespace => ""
/// </summary>
public static string GetStandardizedExtension(string extension)
=> string.IsNullOrWhiteSpace(extension)
? (extension ?? "")?.Trim()
: '.' + extension.Trim().Trim('.');
public static class FileUtility
{
/// <summary>
/// Return position with correct number of leading zeros.
/// <br />- 2 of 9 => "2"
/// <br />- 2 of 90 => "02"
/// <br />- 2 of 900 => "002"
/// </summary>
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
public static string GetSequenceFormatted(int position, int total)
{
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
if (position > total)
throw new ArgumentException($"{position} may not be greater than {total}");
return position.ToString().PadLeft(total.ToString().Length, '0');
}
/// <summary>
/// "txt" => ".txt"
/// <br />".txt" => ".txt"
/// <br />null or whitespace => ""
/// </summary>
public static string GetStandardizedExtension(string extension)
=> string.IsNullOrWhiteSpace(extension)
? (extension ?? "")?.Trim()
: '.' + extension.Trim().Trim('.');
private const int MAX_FILENAME_LENGTH = 255;
private const int MAX_DIRECTORY_LENGTH = 247;
/// <summary>
/// Return position with correct number of leading zeros.
/// <br />- 2 of 9 => "2"
/// <br />- 2 of 90 => "02"
/// <br />- 2 of 900 => "002"
/// </summary>
/// <param name="position">position in sequence. The 'x' in 'x of y'</param>
/// <param name="total">total qty in sequence. The 'y' in 'x of y'</param>
public static string GetSequenceFormatted(int position, int total)
{
ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
if (position > total)
throw new ArgumentException($"{position} may not be greater than {total}");
/// <summary>
/// Ensure valid file name path:
/// <br/>- remove invalid chars
/// <br/>- ensure uniqueness
/// <br/>- enforce max file length
/// </summary>
public static string GetValidFilename(string path, string illegalCharacterReplacements = "", bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
return position.ToString().PadLeft(total.ToString().Length, '0');
}
// remove invalid chars
path = GetSafePath(path, illegalCharacterReplacements);
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path);
dir = dir.Truncate(MAX_DIRECTORY_LENGTH);
/// <summary>
/// Ensure valid file name path:
/// <br/>- remove invalid chars
/// <br/>- ensure uniqueness
/// <br/>- enforce max file length
/// </summary>
public static LongPath GetValidFilename(LongPath path, ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
ArgumentValidator.EnsureNotNull(fileExtension, nameof(fileExtension));
fileExtension = GetStandardizedExtension(fileExtension);
var filename = Path.GetFileNameWithoutExtension(path);
var fileStem = Path.Combine(dir, filename);
// remove invalid chars
path = GetSafePath(path, replacements);
var extension = Path.GetExtension(path);
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path);
dir = dir?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
var fileName = Path.GetFileName(path);
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
var filenameWithoutExtension = extIndex >= 0 ? fileName.Remove(extIndex, fileExtension.Length) : fileName;
var fileStem
= Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length))
.TruncateFilename(LongPath.MaxPathLength - fileExtension.Length);
fullfilename = removeInvalidWhitespace(fullfilename);
var fullfilename = removeInvalidWhitespace(fileStem) + fileExtension;
var i = 0;
while (File.Exists(fullfilename) && !returnFirstExisting)
{
var increm = $" ({++i})";
fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension;
}
var i = 0;
while (File.Exists(fullfilename) && !returnFirstExisting)
{
var increm = $" ({++i})";
fullfilename = fileStem.TruncateFilename(LongPath.MaxPathLength - increm.Length - fileExtension.Length) + increm + fileExtension;
}
return fullfilename;
}
return fullfilename;
}
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
/// <summary>Use with full path, not file name. Valid path characters which are invalid file name characters will be retained: '\\', '/'</summary>
public static LongPath GetSafePath(LongPath path, ReplacementCharacters replacements)
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
/// <summary>Use with file name, not full path. Valid path charaters which are invalid file name characters will be replaced: ':', '\\', '/'</summary>
public static string GetSafeFileName(string str, string illegalCharacterReplacements = "")
=> string.Join(illegalCharacterReplacements ?? "", str.Split(Path.GetInvalidFileNameChars()));
var pathNoPrefix = path.PathWithoutPrefix;
/// <summary>Use with full path, not file name. Valid path charaters which are invalid file name characters will be retained: '\\', '/'</summary>
public static string GetSafePath(string path, string illegalCharacterReplacements = "")
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
pathNoPrefix = replacements.ReplacePathChars(pathNoPrefix);
pathNoPrefix = removeDoubleSlashes(pathNoPrefix);
path = replaceInvalidChars(path, illegalCharacterReplacements);
path = standardizeSlashes(path);
path = replaceColons(path, illegalCharacterReplacements);
path = removeDoubleSlashes(path);
return pathNoPrefix;
}
return path;
}
private static string removeDoubleSlashes(string path)
{
if (path.Length < 2)
return path;
private static char[] invalidChars { get; } = Path.GetInvalidPathChars().Union(new[] {
'*', '?',
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
// In live code, Path.GetInvalidPathChars() does not include them
'"', '<', '>'
}).ToArray();
private static string replaceInvalidChars(string path, string illegalCharacterReplacements)
=> string.Join(illegalCharacterReplacements ?? "", path.Split(invalidChars));
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
private static string standardizeSlashes(string path)
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
var remainder = path[1..];
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (remainder.Contains(dblSeparator))
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
private static string replaceColons(string path, string illegalCharacterReplacements)
{
// replace all colons except within the first 2 chars
var builder = new System.Text.StringBuilder();
for (var i = 0; i < path.Length; i++)
{
var c = path[i];
if (i >= 2 && c == ':')
builder.Append(illegalCharacterReplacements);
else
builder.Append(c);
}
return builder.ToString();
}
return path[0] + remainder;
}
private static string removeDoubleSlashes(string path)
{
if (path.Length < 2)
return path;
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
/// <summary>no part of the path may begin or end in whitespace</summary>
private static string removeInvalidWhitespace(string fullfilename)
{
// no whitespace at beginning or end
// replace whitespace around path slashes
// regex (with space added for clarity)
// \s* \\ \s* => \
// no ending dots. beginning dots are valid
var remainder = path[1..];
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (remainder.Contains(dblSeparator))
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
// regex is easier by ending with separator
fullfilename += Path.DirectorySeparatorChar;
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
// take separator back off
fullfilename = RemoveLastCharacter(fullfilename);
return path[0] + remainder;
}
fullfilename = removeDoubleSlashes(fullfilename);
return fullfilename;
}
private static string removeInvalidWhitespace_pattern { get; } = $@"[\s\.]*\{Path.DirectorySeparatorChar}\s*";
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
/// <summary>no part of the path may begin or end in whitespace</summary>
private static string removeInvalidWhitespace(string fullfilename)
{
// no whitespace at beginning or end
// replace whitespace around path slashes
// regex (with space added for clarity)
// \s* \\ \s* => \
// no ending dots. beginning dots are valid
public static string TruncateFilename(this string filenameStr, int limit)
{
if (LongPath.IsWindows) return filenameStr.Truncate(limit);
// regex is easier by ending with separator
fullfilename += Path.DirectorySeparatorChar;
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, Path.DirectorySeparatorChar.ToString());
// take seperator back off
fullfilename = RemoveLastCharacter(fullfilename);
int index = filenameStr.Length;
fullfilename = removeDoubleSlashes(fullfilename);
return fullfilename;
}
while (index > 0 && System.Text.Encoding.UTF8.GetByteCount(filenameStr, 0, index) > limit)
index--;
public static string RemoveLastCharacter(this string str) => string.IsNullOrEmpty(str) ? str : str[..^1];
return filenameStr[..index];
}
/// <summary>
/// Move file.
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
/// <br/>- Perform <see cref="SaferMove"/>
/// <br/>- Return valid path
/// </summary>
public static string SaferMoveToValidPath(string source, string destination)
{
destination = GetValidFilename(destination);
SaferMove(source, destination);
return destination;
}
/// <summary>
/// Move file.
/// <br/>- Ensure valid file name path: remove invalid chars, ensure uniqueness, enforce max file length
/// <br/>- Perform <see cref="SaferMove"/>
/// <br/>- Return valid path
/// </summary>
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null)
{
extension = extension ?? Path.GetExtension(source);
destination = GetValidFilename(destination, replacements, extension);
SaferMove(source, destination);
return destination;
}
private static int maxRetryAttempts { get; } = 3;
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
private static RetryPolicy retryPolicy { get; } =
Policy
.Handle<Exception>()
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
private static int maxRetryAttempts { get; } = 3;
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
private static RetryPolicy retryPolicy { get; } =
Policy
.Handle<Exception>()
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
public static void SaferDelete(string source)
=> retryPolicy.Execute(() =>
{
try
{
if (!File.Exists(source))
{
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
return;
}
/// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
public static void SaferDelete(LongPath source)
=> retryPolicy.Execute(() =>
{
try
{
if (!File.Exists(source))
{
Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source });
return;
}
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
File.Delete(source);
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
}
catch (Exception e)
{
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
throw;
}
});
Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source });
File.Delete(source);
Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source });
}
catch (Exception e)
{
Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source });
throw;
}
});
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
public static void SaferMove(string source, string destination)
=> retryPolicy.Execute(() =>
{
try
{
if (!File.Exists(source))
{
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
return;
}
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
public static void SaferMove(LongPath source, LongPath destination)
=> retryPolicy.Execute(() =>
{
try
{
if (!File.Exists(source))
{
Serilog.Log.Logger.Debug("No file to move: {@DebugText}", new { source });
return;
}
SaferDelete(destination);
SaferDelete(destination);
var dir = Path.GetDirectoryName(destination);
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir);
var dir = Path.GetDirectoryName(destination);
Serilog.Log.Logger.Debug("Attempt to create directory: {@DebugText}", new { dir });
Directory.CreateDirectory(dir);
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
File.Move(source, destination);
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
}
catch (Exception e)
{
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
throw;
}
});
Serilog.Log.Logger.Debug("Attempt to move file: {@DebugText}", new { source, destination });
File.Move(source, destination);
Serilog.Log.Logger.Information("File successfully moved: {@DebugText}", new { source, destination });
}
catch (Exception e)
{
Serilog.Log.Logger.Error(e, "Failed to move file: {@DebugText}", new { source, destination });
throw;
}
});
/// <summary>
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
/// </summary>
/// <param name="rootPath">Starting directory</param>
/// <param name="patternMatch">Filename pattern match</param>
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
/// <returns>List of files</returns>
public static IEnumerable<string> SaferEnumerateFiles(string path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var foundFiles = Enumerable.Empty<string>();
/// <summary>
/// A safer way to get all the files in a directory and sub directory without crashing on UnauthorizedException or PathTooLongException
/// </summary>
/// <param name="rootPath">Starting directory</param>
/// <param name="patternMatch">Filename pattern match</param>
/// <param name="searchOption">Search subdirectories or only top level directory for files</param>
/// <returns>List of files</returns>
public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly)
{
var foundFiles = Enumerable.Empty<LongPath>();
if (searchOption == SearchOption.AllDirectories)
{
try
{
IEnumerable<string> subDirs = Directory.EnumerateDirectories(path);
// Add files in subdirectories recursively to the list
foreach (string dir in subDirs)
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
}
catch (UnauthorizedAccessException) { }
catch (PathTooLongException) { }
}
try
{
if (searchOption == SearchOption.AllDirectories)
{
IEnumerable<LongPath> subDirs = Directory.EnumerateDirectories(path).Select(p => (LongPath)p);
// Add files in subdirectories recursively to the list
foreach (string dir in subDirs)
foundFiles = foundFiles.Concat(SaferEnumerateFiles(dir, searchPattern, searchOption));
}
try
{
// Add files from the current directory
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern));
}
catch (UnauthorizedAccessException) { }
// Add files from the current directory
foundFiles = foundFiles.Concat(Directory.EnumerateFiles(path, searchPattern).Select(f => (LongPath)f));
}
catch (UnauthorizedAccessException) { }
catch (PathTooLongException) { }
// Symbolic links will result in DirectoryNotFoundException. Ohter logical directories might also. Just skip them. Don't want to risk (or have to handle) infinite recursion
catch (DirectoryNotFoundException) { }
return foundFiles;
}
}
return foundFiles;
}
}
}

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