Compare commits

...

266 Commits

Author SHA1 Message Date
Robert McRackan
96ffa619ec Bug fix: podcast episodes with no title cause library import failure 2021-09-21 15:06:01 -04:00
Robert McRackan
de1147ac1b External login completed 2021-09-20 15:31:07 -04:00
Robert McRackan
926a7a1148 * include new external login interfaces in ApiExtended
* new ext method to simplify api access in file liberator
2021-09-17 16:09:49 -04:00
Robert McRackan
51020ef99e * upgrade api. lay the groundwork for supporting external logins. When complete, this will be v6.0
* replace AudibleApiActions with ApiExtended
2021-09-17 15:30:06 -04:00
Robert McRackan
5a1303c33a update dependency 2021-09-17 14:02:26 -04:00
Robert McRackan
a0e2d78b9b * upgraded AAXClean addresses issues #104 , #110
* update dependencies
2021-09-16 07:53:34 -04:00
rmcrackan
6b711190c3 Merge pull request #116 from Mbucari/master
Correctly write mp3 Xing header.
2021-09-16 07:46:03 -04:00
Michael Bucari-Tovo
b4a6342513 Revert accidental change. 2021-09-15 15:32:34 -06:00
Michael Bucari-Tovo
988b137d67 Updated AAXClean version. 2021-09-15 15:26:40 -06:00
Michael Bucari-Tovo
dae9c9c9b6 Add read permission to file stream. 2021-09-15 15:16:08 -06:00
Robert McRackan
420b7529c6 Bug fix: issues #113 , #114
Forcing a new build order. Seems to fix the issue. Since the issue was already intermittent (race condition?) it's difficult to be sure
2021-09-14 09:05:56 -04:00
Robert McRackan
4cf999c84d null book description throws 2021-09-13 11:05:44 -04:00
Robert McRackan
8fe3896d76 build error bug fix 2021-09-13 10:35:41 -04:00
Robert McRackan
adcba34560 'bad book' setting labels 2021-09-11 08:01:58 -04:00
Robert McRackan
8e09d7e617 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-09-10 22:59:02 -04:00
Robert McRackan
197b50e3ac issue #111 -- setting for bad book abort/retry/ignore. default: ask 2021-09-10 22:58:34 -04:00
rmcrackan
ac2114e270 Add CLI documentation 2021-09-10 17:20:13 -04:00
Robert McRackan
29461701cd I meant to increm the minor version for cli 2021-09-10 17:04:47 -04:00
Robert McRackan
0f130c70f5 LibationCli and structural changes to support it incl: no LibationLauncher, just LibationWinForms. LibationWinForms builds to a different output dir so cli can be deployed easily. Versioning number is moved to scaffolding library shared by both apps 2021-09-10 16:54:32 -04:00
Robert McRackan
995637e843 add logging. helpful when viewing in console 2021-09-10 12:51:07 -04:00
Robert McRackan
9501687f86 Change build path. Necessary for possibly building a non-winforms exe to the same output dir 2021-09-09 16:04:17 -04:00
Robert McRackan
248dea3402 write to log, not console 2021-09-09 13:26:00 -04:00
Robert McRackan
1d420f5430 File Liberators should log their own progresss and not depend on controller or forms to do so. This means that LogMe will create duplicate log entries for non-form and non-controller calls but that's a refactor for a rainy day. 2021-09-09 11:27:03 -04:00
Robert McRackan
5f0a6b8526 bug fix: installer bug for users who say 'return user' but don't have valid files 2021-09-07 16:58:04 -04:00
Robert McRackan
c337c0b44e library book composite key comments 2021-09-07 13:35:59 -04:00
Robert McRackan
89207866f3 make sure that __log is never empty so that .Last() can't throw 2021-09-04 18:17:29 -04:00
Robert McRackan
9e11086d49 bug fix 2021-09-04 18:09:51 -04:00
rmcrackan
58b172f816 Merge pull request #103 from Mbucari/master
Minor fix and changes for form size and location persistance.
2021-09-04 14:15:50 -04:00
Mbucari
0b8084bc03 Added form size and position persistance to audio decode forms. 2021-09-03 23:00:35 -06:00
Mbucari
37970222f3 Make SaveSizeAndLocation and RestoreSizeAndLocation a form extension. 2021-09-03 22:44:02 -06:00
Mbucari
bcab2dd440 Adjust display parameters. 2021-09-03 22:43:03 -06:00
Mbucari
d402128d1d GetNonString now handles values and classes. 2021-09-03 22:41:21 -06:00
Mbucari
3ae0f2daa2 Fixed FindInactiveBooks to work with new logger. 2021-09-03 22:40:35 -06:00
Robert McRackan
126919d578 update dependencies 2021-09-03 23:02:28 -04:00
Robert McRackan
437e85fd12 mp3 bugfix 2021-09-03 18:22:31 -04:00
Robert McRackan
de34e5c795 import speed improvements 2021-09-03 16:35:31 -04:00
Robert McRackan
8ffcefd6ae massive speed-up for import contributors 2021-09-03 15:30:17 -04:00
Robert McRackan
e59ab9b483 remove v3=>4 migration work-arounds 2021-09-03 13:42:58 -04:00
Robert McRackan
57fa1bd763 cross thread issue. add temp time logging in ImportAccountAsync 2021-09-03 11:36:55 -04:00
rmcrackan
dccb2d73d6 Merge pull request #101 from Mbucari/master
Fixed cross thread bug and Enumerable change bug.
2021-09-03 08:15:37 -04:00
Mbucari
77fc865636 Fixed crossthread. 2021-09-02 21:21:36 -06:00
Mbucari
1040a347c6 Fixed cross thread bug and Enumerable change bug. 2021-09-02 21:04:16 -06:00
Robert McRackan
6ed1307443 v5.6.3.1 : support for episodes ( issue #96 ) 2021-09-02 16:05:23 -04:00
Robert McRackan
c2c732b2b1 central event for library altered: books added or removed 2021-09-02 15:55:12 -04:00
Robert McRackan
9e0caf34d6 Rename db table Library => LibraryBooks 2021-09-02 15:26:20 -04:00
Robert McRackan
802763a4fb minor 2021-09-02 15:19:55 -04:00
Robert McRackan
b4803c42a5 Streamline GetLibrary_Flat_NoTracking with better context dispose 2021-09-02 14:51:06 -04:00
Robert McRackan
62c98c66a3 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-09-02 14:15:10 -04:00
Robert McRackan
6b289445e2 update dependencies 2021-09-02 14:14:25 -04:00
rmcrackan
52bf91f8aa Merge pull request #99 from Mbucari/master
Fixed crash when change RemoveBooksDialog checkbox via spacebar.
2021-09-02 13:47:57 -04:00
Michael Bucari-Tovo
6d2dff1a98 Code Cleanup 2021-09-02 11:21:20 -06:00
Mbucari
7c9970c0cb Merge branch 'rmcrackan:master' into master 2021-09-02 11:18:32 -06:00
Michael Bucari-Tovo
d2892f9076 Fix crash when checkbox checked via spacebar. 2021-09-02 11:11:40 -06:00
Robert McRackan
89f60a7ca3 fix wording 2021-09-02 11:35:32 -04:00
rmcrackan
ea37c09081 Merge pull request #98 from Mbucari/master
Added AAXClean as nuget package.
2021-09-02 10:54:42 -04:00
Michael Bucari-Tovo
76cb280933 Added AAXClean as nuget package. 2021-09-02 08:13:42 -06:00
Robert McRackan
0a54a8104c update counts label to reflect recent workflow changes 2021-09-02 09:54:02 -04:00
Robert McRackan
7464336535 remove WinFormsDesigner 2021-09-02 09:52:41 -04:00
Robert McRackan
dc0dd3474b separate the concepts of UserDefinedItem being updated in memory vs successful persistence 2021-09-02 09:51:17 -04:00
Robert McRackan
7b9c5c0f4f Add episode/podcast search engine bool 2021-09-01 16:56:09 -04:00
Robert McRackan
ad87f1851e Add episodes content type to Books in db 2021-09-01 16:51:59 -04:00
Robert McRackan
e8423341ef bug fix: bottom count numbers and menu options weren't updating on liberate-all 2021-09-01 14:28:01 -04:00
Robert McRackan
a9d3494af1 Added support for episodic content incl podcasts. Not yet complete. Need to mark them as such in the db somehow and also add search engine bools 2021-09-01 12:47:59 -04:00
Robert McRackan
90731a8948 'Convert all M4b to Mp3': add confirmation dialog with better explanation 2021-08-31 09:32:50 -04:00
Robert McRackan
e723467ca6 book liberation status Error:
* show system error icon in grid instead of stoplight
* list error count in bottom right #s
* SearchEngine bool: LiberatedError
2021-08-27 17:01:00 -04:00
Robert McRackan
722c33bf61 Add readme to help/annoy collaborators 2021-08-27 15:14:42 -04:00
Robert McRackan
f080215cbb Book details dialog: tags should get initial focus 2021-08-27 14:07:06 -04:00
Robert McRackan
d5c74d629f update dependencies 2021-08-27 11:16:13 -04:00
Robert McRackan
d12c246f6d version increm 2021-08-26 16:09:37 -04:00
Robert McRackan
8969c216af comments 2021-08-26 16:08:26 -04:00
Robert McRackan
9a4903f0dd Bug fix: after successful pdf download, this state wasn't being saved 2021-08-26 15:53:33 -04:00
Robert McRackan
3eda498a5e new AudibleApi nuget no longer relies on external json and js files which caused issues 2021-08-26 12:51:55 -04:00
Robert McRackan
8af7f28f04 (hopefully) final nuget pkg: Dinah.Core.WindowsDesktop 2021-08-26 12:49:37 -04:00
Robert McRackan
d9d7dfe1f7 update depandecies 2021-08-26 12:48:05 -04:00
Robert McRackan
b9c4d11946 remove TestCommon 2021-08-25 17:07:26 -04:00
Robert McRackan
68a5d7a58d nuget. done until I can figure out how to build .net5-windows nuget from github actions 2021-08-25 16:24:02 -04:00
Robert McRackan
4d69b222c5 nuget: Dinah.EntityFrameworkCore 2021-08-25 15:55:31 -04:00
Robert McRackan
42f94e7f6c more nuget migration 2021-08-25 15:33:09 -04:00
Robert McRackan
381d52be72 Better audible api to reduce captcha occurances 2021-08-24 13:42:14 -04:00
Robert McRackan
f16ad30891 bug fix from my last bug fix :( 2021-08-24 09:28:13 -04:00
Robert McRackan
ef53a6a8cb Bug fix: issue #92 2021-08-23 16:29:23 -04:00
Robert McRackan
9a37d434f1 FileLiberator is not db ignorant. It doesn't make context calls but still heavily uses the classes defined in the domain. Also uses internal util.s 2021-08-23 16:27:22 -04:00
Robert McRackan
d7eb190f69 reduce use of Book.Supplements 2021-08-23 16:16:08 -04:00
Robert McRackan
f19c46ee45 Book: add pdf url as is, not Absolute 2021-08-23 16:07:19 -04:00
rmcrackan
343c3b62d6 Merge pull request #90 from Mbucari/master
Fully implemented the MVVM pattern
2021-08-22 21:06:00 -04:00
Michael Bucari-Tovo
b1de10a71a Fix filtering. 2021-08-22 13:29:01 -06:00
Michael Bucari-Tovo
6beb5cc74a Made changes discussed. 2021-08-22 13:27:39 -06:00
Michael Bucari-Tovo
3767c3574a Merge branch 'master' of https://github.com/Mbucari/Libation 2021-08-21 22:09:27 -06:00
Michael Bucari-Tovo
4ceb4f9c03 Change back. 2021-08-21 22:09:13 -06:00
Mbucari
0f5149f7b4 Merge pull request #2 from rmcrackan/master
bug fix. race condition. can't check for book-exists-state before set…
2021-08-21 22:08:51 -06:00
Michael Bucari-Tovo
673451dc11 Git resolve 2021-08-21 22:08:35 -06:00
Michael Bucari-Tovo
e4257afc14 Version Num 2021-08-21 22:06:54 -06:00
Michael Bucari-Tovo
2a7e185dc3 Finish MVVM conversion 2021-08-21 22:03:16 -06:00
Michael Bucari-Tovo
9e06c343c1 Don't check if values have changed when updating the database. 2021-08-21 21:15:25 -06:00
Michael Bucari-Tovo
40b3a9990d FileLiberator is now DB ignorant. IProcessables update UserDaefinedData which notifies the view model. 2021-08-21 20:49:54 -06:00
Robert McRackan
d66c112a1e bug fix. race condition. can't check for book-exists-state before setting this state 2021-08-21 22:32:45 -04:00
Michael Bucari-Tovo
d826885728 Fix display for new LiberatedStatus values. 2021-08-21 18:37:07 -06:00
Michael Bucari-Tovo
263222d8cc Changed method signature. 2021-08-21 18:21:22 -06:00
Michael Bucari-Tovo
f25734334d Add separate command for updating tags 2021-08-21 18:16:24 -06:00
Michael Bucari-Tovo
ede8397f13 Needed to add check for actual file since Audio_Exists is now an application state. 2021-08-21 18:15:39 -06:00
Michael Bucari-Tovo
1369ee575a Replaced LiberatedState with LiberatedStatus and PdfState with LiberatedStatus? 2021-08-21 16:29:16 -06:00
Robert McRackan
c8e2418af7 incr ver for new liberation status handling 2021-08-21 14:04:48 -04:00
rmcrackan
2da25edafd Merge pull request #87 from Mbucari/master
Make sure DataGridView updates the display immediately after Details are changed.
2021-08-21 13:37:05 -04:00
Michael Bucari-Tovo
f60964f4c7 Unsubscribe IStreamable events from Disposed instead of FormClosed. 2021-08-21 08:47:24 -06:00
Michael Bucari-Tovo
3183f99153 Remove unnecessary overrides. 2021-08-21 08:45:43 -06:00
Michael Bucari-Tovo
2a22cff67c Revert "Fixed PDF download form disposed error"
This reverts commit 7fbe8ae769.
2021-08-21 08:14:10 -06:00
Michael Bucari-Tovo
7fbe8ae769 Fixed PDF download form disposed error 2021-08-21 08:03:40 -06:00
Robert McRackan
f9df466ad8 retain seeded file locations 2021-08-21 09:59:06 -04:00
Michael Bucari-Tovo
0b129fcf7c Fixed NetworkFileStream not resuming from cancellation. 2021-08-20 21:05:29 -06:00
Robert McRackan
2be5fd5af3 Omit '.libhack' skip/error files 2021-08-20 22:16:46 -04:00
Robert McRackan
c9727f84ab (hopefully) complete minimum viable product with stateful is-liberated status 2021-08-20 21:22:52 -04:00
Robert McRackan
aa56bb74a1 refactor out most of TransitionalFileLocator. Almost done with new stateful is-liberated paradigm 2021-08-20 20:51:37 -04:00
Michael Bucari-Tovo
85a6e21dcf Make sure network file isn't left open. 2021-08-20 17:03:15 -06:00
Michael Bucari-Tovo
8c620c25ab Separate concerns. 2021-08-20 16:10:05 -06:00
Michael Bucari-Tovo
813d91dfa4 Better naming 2021-08-20 15:40:16 -06:00
Michael Bucari-Tovo
d0d66c6135 Update using NotifyPropertyChanged instead of Row.Invalidate 2021-08-20 15:38:30 -06:00
Michael Bucari-Tovo
a8d609676e Null check. 2021-08-20 14:57:23 -06:00
Michael Bucari-Tovo
8386da5ec6 Make gridview update the row after details changed. 2021-08-20 14:56:52 -06:00
Michael Bucari-Tovo
f5089e7e29 Use local rowIndex instead of DataGridViewCell.RowIndex 2021-08-20 14:53:12 -06:00
Robert McRackan
a639857ec6 Book details changes liberated status in db and search engine. Minor changes to audible api to hopefully fix the weird log-in edge cases 2021-08-20 16:06:01 -04:00
Robert McRackan
35b5d7370c book details form now has a way for user to toggle is vs is-not liberated for book and pdf. NOT yet wired up 2021-08-20 15:21:43 -04:00
Robert McRackan
c9f988acf8 Book details form: quick and dirty 1st draft 2021-08-20 14:45:28 -04:00
Robert McRackan
6dfef09ea3 begin process of changing 'edit tags' => book details 2021-08-20 13:26:12 -04:00
rmcrackan
7e288c0c08 Merge pull request #86 from Mbucari/master
Use new Dinah.Core.Threading and remove unnecessary DataGridViewImageButtonColumn
2021-08-19 17:32:00 -04:00
Mbucari
dbcf6f25db Merge branch 'rmcrackan:master' into master 2021-08-19 15:14:56 -06:00
rmcrackan
88133652e9 unofficial linux/mac support 2021-08-19 11:17:42 -04:00
Robert McRackan
e768466943 Add hoopla script to repo. Currently unused. A Libation user passed it on to me. Notes are in _README.txt 2021-08-19 11:12:16 -04:00
Michael Bucari-Tovo
0cc55fd1e8 Widen Liberate column so sort arrow is shown. 2021-08-18 15:53:35 -06:00
Michael Bucari-Tovo
e36ea70cd1 Removed unnecessary class and simplified. 2021-08-18 15:53:09 -06:00
Michael Bucari-Tovo
a86185e644 Updated to use new Dinah.Core.Threading 2021-08-18 14:29:25 -06:00
Michael Bucari-Tovo
64a8f007a5 Use new Dinah.Core string extensions. 2021-08-18 14:11:25 -06:00
Michael Bucari-Tovo
215a626c92 SynchronizeInvoker => Dinah.Core.Threading.SynchronizeInvoker 2021-08-18 14:08:48 -06:00
Michael Bucari-Tovo
de93047192 Use new Dinah.Core.Threading 2021-08-18 14:08:11 -06:00
Robert McRackan
79c9a094b5 Liberate > "Convert all M4b to Mp3" : Visible=true 2021-08-18 15:24:18 -04:00
Robert McRackan
012a92ea30 SortableBindingList2 => Dinah.Core SortableBindingList 2021-08-18 15:06:52 -04:00
Robert McRackan
2e60d2accf Lots of churn over the last few weeks. For now, incrementing minor version. Better reviews to happen this week 2021-08-18 11:25:22 -04:00
Robert McRackan
565d34cec9 bottom numbers formatting 2021-08-18 11:22:47 -04:00
Robert McRackan
dd6967e88b minor NFO change 2021-08-18 11:17:59 -04:00
rmcrackan
fb7f57ab69 Merge pull request #85 from Mbucari/master
"F*ck it, we'll do it live!"
2021-08-18 11:03:23 -04:00
Mbucari
88253cdb55 Update LiberationBaseForm.cs 2021-08-16 10:08:21 -06:00
Michael Bucari-Tovo
560880b53d Fixed typos. 2021-08-16 08:04:03 -06:00
Michael Bucari-Tovo
27ae5facbe Improved PictureStorage thread safety and more intuitive naming. 2021-08-15 14:09:43 -06:00
Michael Bucari-Tovo
7a90d9fba9 Typo 2021-08-14 00:31:16 -06:00
Michael Bucari-Tovo
f74b0d78db Improved BackgroundFileSystem thread safety 2021-08-14 00:25:32 -06:00
Michael Bucari-Tovo
52fb0a27ce Code cleanup. 2021-08-13 23:18:52 -06:00
Michael Bucari-Tovo
7bdcf4eef0 Improved NetworkFileStream asynchronous operation. 2021-08-13 22:53:17 -06:00
Michael Bucari-Tovo
a44c46333f Improved cross threaded invocation. 2021-08-13 16:34:09 -06:00
Michael Bucari-Tovo
766d427b19 Improved cross thread execution and minor refactoring. 2021-08-13 10:19:43 -06:00
Michael Bucari-Tovo
0e7930f2b6 Removed intermediate class. 2021-08-12 18:50:51 -06:00
Michael Bucari-Tovo
081878b6f7 Remove IStreamProcessable. IProcessible inherits IStreamable. 2021-08-12 18:43:34 -06:00
Michael Bucari-Tovo
f925d10d2b Renamed. 2021-08-12 18:02:49 -06:00
Michael Bucari-Tovo
e37a2ccca9 Typos and small error correction. 2021-08-12 17:56:36 -06:00
Michael Bucari-Tovo
3e2d69606b Consolidated base forms 2021-08-12 17:43:46 -06:00
Michael Bucari-Tovo
2c20d03506 Refactored StreamBaseForm 2021-08-12 13:48:10 -06:00
Michael Bucari-Tovo
97730d1793 Removed unnecessary cast. 2021-08-12 13:14:45 -06:00
Michael Bucari-Tovo
5ab4183f9b Fixed event handling. 2021-08-12 12:47:02 -06:00
Michael Bucari-Tovo
7acaac7bd3 Fixed subscription ordering. 2021-08-12 12:28:58 -06:00
Michael Bucari-Tovo
448fd78b8f Added Dispose on IProcessable.Completed in case form is created but never shown. 2021-08-12 12:23:55 -06:00
Michael Bucari-Tovo
56a48c04bf Minor refactoring. 2021-08-12 10:39:55 -06:00
Michael Bucari-Tovo
65027fd001 Renamed data grid columns. 2021-08-12 09:21:58 -06:00
Michael Bucari-Tovo
f57a46c772 fixed mistake. 2021-08-12 00:19:30 -06:00
Michael Bucari-Tovo
a45ab61929 Typos. 2021-08-12 00:14:59 -06:00
Michael Bucari-Tovo
cd67e7136b Fixed control logic. 2021-08-12 00:13:33 -06:00
Michael Bucari-Tovo
265ad3a782 Minor refactoring. 2021-08-11 23:44:04 -06:00
Michael Bucari-Tovo
9f49a88000 Removed comments 2021-08-11 23:28:11 -06:00
Michael Bucari-Tovo
b5d941d479 Refactoring and documenting. 2021-08-11 23:22:45 -06:00
Michael Bucari-Tovo
79ed92f303 Refinment 2021-08-11 21:19:38 -06:00
Michael Bucari-Tovo
1c239dc546 More process control refinements 2021-08-11 21:07:07 -06:00
Michael Bucari-Tovo
687591e08e Refined changes to BookLiberation 2021-08-11 20:22:36 -06:00
Michael Bucari-Tovo
0045cf05ef Redesign DookLiberation control flow. 2021-08-11 18:08:38 -06:00
Michael Bucari-Tovo
963d632208 Removed space. 2021-08-10 21:14:33 -06:00
Michael Bucari-Tovo
9c1f620223 Revert indenting 2021-08-10 20:54:43 -06:00
Michael Bucari-Tovo
de75543b33 Removed WindowsDesktopUtilities 2021-08-10 20:39:11 -06:00
Michael Bucari-Tovo
689ffc71a2 Removed WindowsDesktopUtilities dependency. 2021-08-10 20:38:17 -06:00
Michael Bucari-Tovo
d795244247 Updated PictureCached event and removed dependence on WinAudibleImageServer 2021-08-10 20:33:15 -06:00
Michael Bucari-Tovo
4989cda93c Added synchronous Picture downloader. 2021-08-10 20:16:34 -06:00
Michael Bucari-Tovo
2f3c0e8a95 Revert "Removed WinFormsDesigner because it's not being used."
This reverts commit 54d24a7b09.
2021-08-10 18:26:21 -06:00
Michael Bucari-Tovo
560523b99d Revert "Removed WinFormsDesigner project."
This reverts commit d5e9e49517.
2021-08-10 18:26:10 -06:00
Michael Bucari-Tovo
d5e9e49517 Removed WinFormsDesigner project. 2021-08-10 17:06:58 -06:00
Michael Bucari-Tovo
54d24a7b09 Removed WinFormsDesigner because it's not being used. 2021-08-10 17:05:04 -06:00
Michael Bucari-Tovo
19a710e080 Fix xml formatting. 2021-08-10 16:36:46 -06:00
Michael Bucari-Tovo
7bdf71a29b Code Cleanup 2021-08-10 16:33:59 -06:00
Michael Bucari-Tovo
ef35c2aee9 Code Cleanup 2021-08-10 16:15:32 -06:00
Michael Bucari-Tovo
95766a43c5 Refactoring. 2021-08-10 14:57:18 -06:00
Michael Bucari-Tovo
e1dfefbadf Comments and renaming. 2021-08-10 10:17:02 -06:00
Michael Bucari-Tovo
f81552565a Re-added double buffering. 2021-08-10 00:33:24 -06:00
Michael Bucari-Tovo
957bec1c7f Removed excessive declarations. 2021-08-10 00:28:48 -06:00
Michael Bucari-Tovo
5c8ad72a5e Removed unnecessary using. 2021-08-10 00:24:21 -06:00
Michael Bucari-Tovo
0b1d513f50 Reorganized. 2021-08-10 00:18:43 -06:00
Michael Bucari-Tovo
d770109d86 Removed double buffering. 2021-08-10 00:15:07 -06:00
Michael Bucari-Tovo
235d0acede Removed unnecessary code. 2021-08-10 00:05:18 -06:00
Michael Bucari-Tovo
6f184273b8 Removed unnecessary declarations. 2021-08-09 23:48:32 -06:00
Michael Bucari-Tovo
a4cb934611 Updated resource file. 2021-08-09 23:31:59 -06:00
Michael Bucari-Tovo
6aefdfca9d Fixed tool strip menu for book removal. 2021-08-09 23:25:41 -06:00
Michael Bucari-Tovo
c7454ea5d2 Updated RemoveBooksDialog to use latest GridEntry 2021-08-09 23:11:37 -06:00
Michael Bucari-Tovo
2ef746a94c Add debug constants and don't check updates in debug.
Refactored cell formatting

Made GridEntry thread safe

Moved PictureStorage set defaults into constructor.
2021-08-09 22:28:07 -06:00
Michael Bucari-Tovo
ab82e7c99c Added comment. 2021-08-09 19:15:41 -06:00
Michael Bucari-Tovo
5f8ca9a0b5 Changed sort method. 2021-08-09 19:07:00 -06:00
Michael Bucari-Tovo
d48bd5ad07 Major UI refactoring. 2021-08-09 18:56:06 -06:00
Michael Bucari-Tovo
af48641281 Added remove books context menu item. 2021-08-09 01:08:58 -06:00
Michael Bucari-Tovo
f621ca63e8 Added RemoveBooksDialog 2021-08-09 01:08:35 -06:00
Michael Bucari-Tovo
35f54779f0 Add library command to get books removed from library. 2021-08-09 01:08:00 -06:00
Michael Bucari-Tovo
f68f374b78 Use resource user-agent. 2021-08-09 01:07:24 -06:00
rmcrackan
7e89386173 Merge pull request #80 from Mbucari/master
Fixed AAXC not downloading.
2021-08-03 09:17:03 -04:00
Michael Bucari-Tovo
7685613e8c Version update. 2021-08-03 06:42:48 -06:00
Michael Bucari-Tovo
727d1479bb Updated user-agent to fix AAXC files not downloading. 2021-08-03 06:41:44 -06:00
Michael Bucari-Tovo
bb46021f20 Fuxed possible null reference. 2021-08-03 06:41:22 -06:00
Robert McRackan
c45e6d526c New statuses added to export files: json, csv, excel 2021-07-29 15:11:42 -04:00
Robert McRackan
a72c3f069b ver # 2021-07-29 14:56:38 -04:00
Robert McRackan
1fcacb9cfb Much faster for grid refresh 2021-07-29 14:55:48 -04:00
Robert McRackan
a3542c53e2 Remove duplicate logic 2021-07-29 11:32:16 -04:00
Robert McRackan
9e44a95ba2 Double buffer grid 2021-07-29 10:57:55 -04:00
Robert McRackan
204e77008b TransitionalFileLocator to trust Book values, not hit db directly 2021-07-29 10:20:27 -04:00
Robert McRackan
621fb68cd8 _2faCodeDialog login debug 2021-07-29 08:40:03 -04:00
Robert McRackan
0c265a9010 Centralize audio/aaxc files GetPath and Exists into temp TransitionalFileLocator 2021-07-29 07:50:37 -04:00
Robert McRackan
d4fbb03577 Login debugging 2021-07-29 07:39:14 -04:00
Robert McRackan
69a7ab5b0c tiny changes 2021-07-28 18:04:39 -04:00
Robert McRackan
53a46b5dfc Pull non-presentation logic out of main form 2021-07-28 17:20:16 -04:00
Robert McRackan
fb3126b0c6 ver # 2021-07-28 16:05:27 -04:00
Robert McRackan
5c6b5c0af2 Populate new values for book tracking state. Not using them yet, but getting much closer 2021-07-28 16:05:00 -04:00
Robert McRackan
8de8e50829 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-07-28 14:54:34 -04:00
rmcrackan
5d15d6c2c7 Merge pull request #75 from Mbucari/master
Fixed race condition.
2021-07-28 14:54:19 -04:00
Robert McRackan
85c18c8334 NoTracking() to simplify confusing EF Core state. Tracking is now only used during mass import, not in UI 2021-07-28 14:51:35 -04:00
Michael Bucari-Tovo
9de85b649b Fixed race condition. 2021-07-28 12:25:05 -06:00
Robert McRackan
3c1db55a95 tiny bug fix 2021-07-28 10:38:16 -04:00
Robert McRackan
4e6011711a Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-07-28 10:02:27 -04:00
rmcrackan
1440b3fcf6 Merge pull request #74 from Mbucari/master
Added Convert all M4b to Mp3 action.
2021-07-28 10:02:10 -04:00
Robert McRackan
f2f0725c68 comment out until after vacation 2021-07-28 09:58:09 -04:00
Robert McRackan
75f1d987fc Next iterative step toward replacing live scanning with db state. FilePaths.json => db 2021-07-28 09:40:27 -04:00
Mbucari
de8589fb84 Update ProcessorAutomationController.cs 2021-07-28 00:34:31 -06:00
Michael Bucari-Tovo
54ceba816a Minor refactoring. 2021-07-27 22:50:50 -06:00
Michael Bucari-Tovo
05d52e64e5 Added Convert all M4b to Mp3 action. 2021-07-27 22:20:38 -06:00
Robert McRackan
5c6bf300c6 Start laying the ground work to transition from background file scan => saved liberated-status state 2021-07-27 14:55:44 -04:00
rmcrackan
10ff95161b Merge pull request #73 from Mbucari/master
Removed redundant logging
2021-07-27 13:43:38 -04:00
Michael Bucari-Tovo
112671cf9f Merge branch 'master' of https://github.com/Mbucari/Libation 2021-07-27 11:40:15 -06:00
Michael Bucari-Tovo
1a37b2346e Logging is redundant because download license is logged in the api call. 2021-07-27 11:40:13 -06:00
Robert McRackan
54cceba4e3 New background file watcher for file location 2021-07-27 13:23:26 -04:00
rmcrackan
1502936cd0 Merge pull request #72 from Mbucari/master
Added a background file system scanner.
2021-07-27 13:20:33 -04:00
Michael Bucari-Tovo
f06b04ede4 Fixed possible race condition. 2021-07-27 10:26:15 -06:00
Michael Bucari-Tovo
406aea6ead More thread safety. 2021-07-27 10:23:34 -06:00
Michael Bucari-Tovo
5f8c40962a Merge branch 'master' of https://github.com/Mbucari/Libation 2021-07-27 10:21:38 -06:00
Michael Bucari-Tovo
a77405c632 Make thread safe. 2021-07-27 10:21:17 -06:00
Mbucari
fdff31b69f Merge branch 'rmcrackan:master' into master 2021-07-27 10:13:55 -06:00
Michael Bucari-Tovo
f5e1667368 Added a background file system watcher. 2021-07-27 10:13:37 -06:00
Robert McRackan
af81367b46 Revert "rename BookTags.json to UserDefinedItems.json"
This reverts commit cd418e877d.
2021-07-27 11:54:47 -04:00
Robert McRackan
cd418e877d rename BookTags.json to UserDefinedItems.json 2021-07-27 11:12:40 -04:00
Robert McRackan
b6c9a82c68 Minor refactoring 2021-07-27 11:03:26 -04:00
Robert McRackan
efca1f9c1d added license debugging 2021-07-26 18:30:13 -04:00
Robert McRackan
ca14db79b9 Found the NRE. Underlying problem persists. Now it will be reported correctly 2021-07-26 17:12:40 -04:00
Robert McRackan
9d00da006c Trim title 2021-07-26 17:11:49 -04:00
Robert McRackan
b479096fc2 Added logging. Bug fix in MFA login form 2021-07-25 16:49:07 -04:00
rmcrackan
ad09d36588 Merge pull request #69 from Mbucari/master
Added logging of download license.
2021-07-24 19:56:55 -04:00
Mbucari
1a9c0188a4 Update AaxcDownloadConverter.cs 2021-07-24 16:12:11 -06:00
Mbucari
ca75b55da4 Merge branch 'rmcrackan:master' into master 2021-07-24 15:12:12 -06:00
Michael Bucari-Tovo
285b1e7b45 Removed dll. 2021-07-24 15:11:50 -06:00
Michael Bucari-Tovo
6912a499d0 Moved download licnse from debug log to verbose log. 2021-07-24 15:11:05 -06:00
Robert McRackan
4e70365150 increm ver # 2021-07-24 16:27:17 -04:00
Robert McRackan
811a95aedf After LogLevel changed in settings: warn if Verbose logging level 2021-07-24 16:26:50 -04:00
Michael Bucari-Tovo
20971124ab Add DLL temporarily. 2021-07-24 11:29:49 -06:00
Michael Bucari-Tovo
fa66a361dc Add logging of download license in DebugInfo 2021-07-24 11:28:58 -06:00
Robert McRackan
61d7f5a5cb version 2021-07-22 22:44:07 -04:00
Robert McRackan
f8c788297e Better error info when offering to skip problematic book (issue #65) 2021-07-22 22:08:36 -04:00
Robert McRackan
79e5545fd3 config bug fix 2021-07-22 14:08:15 -04:00
Robert McRackan
b4def2e2d6 Remember screen position. Issue #61 2021-07-21 23:13:14 -04:00
Robert McRackan
281d615649 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-07-21 14:45:22 -04:00
Robert McRackan
c2c6a31716 Remove "advanced settings". Too error prone 2021-07-21 14:43:59 -04:00
198 changed files with 9725 additions and 7207 deletions

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\AAXClean\AAXClean.csproj" />
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
<PackageReference Include="AAXClean" Version="0.1.9" />
<PackageReference Include="Dinah.Core" Version="1.1.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,48 +1,45 @@
using AAXClean;
using Dinah.Core;
using Dinah.Core.Diagnostics;
using Dinah.Core.IO;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using System;
using System.IO;
namespace AaxDecrypter
{
public enum OutputFormat
{
Mp4a,
Mp3
}
public enum OutputFormat { Mp4a, Mp3 }
public class AaxcDownloadConverter
{
public event EventHandler<AppleTags> RetrievedTags;
public event EventHandler<byte[]> RetrievedCoverArt;
public event EventHandler<int> DecryptProgressUpdate;
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public string AppName { get; set; } = nameof(AaxcDownloadConverter);
public string OutputFileName { get; private set; }
public string AppName { get; set; } = nameof(AaxcDownloadConverter);
private string outputFileName { get; }
private string cacheDir { get; }
private DownloadLicense downloadLicense { get; }
private AaxFile aaxFile;
private byte[] coverArt;
private OutputFormat OutputFormat;
private StepSequence steps { get; }
private NetworkFileStreamPersister nfsPersister;
private bool isCanceled { get; set; }
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(OutputFileName) + ".json");
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(outputFileName) + ".json");
private string tempFile => PathLib.ReplaceExtension(jsonDownloadState, ".aaxc");
public AaxcDownloadConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
OutputFileName = outFileName;
var outDir = Path.GetDirectoryName(OutputFileName);
outputFileName = outFileName;
var outDir = Path.GetDirectoryName(outputFileName);
if (!Directory.Exists(outDir))
throw new ArgumentNullException(nameof(outDir), "Directory does not exist");
if (File.Exists(OutputFileName))
File.Delete(OutputFileName);
if (File.Exists(outputFileName))
File.Delete(outputFileName);
if (!Directory.Exists(cacheDirectory))
throw new ArgumentNullException(nameof(cacheDirectory), "Directory does not exist");
@@ -63,11 +60,15 @@ namespace AaxDecrypter
};
}
/// <summary>
/// Setting cover art by this method will insert the art into the audiobook metadata
/// </summary>
public void SetCoverArt(byte[] coverArt)
{
if (coverArt is null) return;
this.coverArt = coverArt;
aaxFile?.AppleTags.SetCoverArt(coverArt);
RetrievedCoverArt?.Invoke(this, coverArt);
}
@@ -82,8 +83,7 @@ namespace AaxDecrypter
}
var speedup = (int)(aaxFile.Duration.TotalSeconds / (long)Elapsed.TotalSeconds);
Console.WriteLine("Speedup is " + speedup + "x realtime.");
Console.WriteLine("Done");
Serilog.Log.Logger.Information($"Speedup is {speedup}x realtime.");
return true;
}
@@ -96,7 +96,7 @@ namespace AaxDecrypter
try
{
nfsPersister = new NetworkFileStreamPersister(jsonDownloadState);
//If More thaan ~1 hour has elapsed since getting the download url, it will expire.
//If More than ~1 hour has elapsed since getting the download url, it will expire.
//The new url will be to the same file.
nfsPersister.NetworkFileStream.SetUriForSameFile(new Uri(downloadLicense.DownloadUrl));
}
@@ -111,39 +111,44 @@ namespace AaxDecrypter
{
nfsPersister = NewNetworkFilePersister();
}
nfsPersister.NetworkFileStream.BeginDownloading();
aaxFile = new AaxFile(nfsPersister.NetworkFileStream);
coverArt = aaxFile.AppleTags.Cover;
RetrievedTags?.Invoke(this, aaxFile.AppleTags);
RetrievedCoverArt?.Invoke(this, coverArt);
RetrievedCoverArt?.Invoke(this, aaxFile.AppleTags.Cover);
return !isCanceled;
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection();
headers.Add("User-Agent", downloadLicense.UserAgent);
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", downloadLicense.UserAgent }
};
NetworkFileStream networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
var networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
public bool Step2_DownloadAndCombine()
{
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
};
DecryptProgressUpdate?.Invoke(this, 0);
DecryptProgressUpdate?.Invoke(this, zeroProgress);
if (File.Exists(OutputFileName))
FileExt.SafeDelete(OutputFileName);
if (File.Exists(outputFileName))
FileExt.SafeDelete(outputFileName);
FileStream outFile = File.OpenWrite(OutputFileName);
FileStream outFile = File.Open(outputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
aaxFile.SetDecryptionKey(downloadLicense.AudibleKey, downloadLicense.AudibleIV);
aaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
var decryptionResult = OutputFormat == OutputFormat.Mp4a ? aaxFile.ConvertToMp4a(outFile, downloadLicense.ChapterInfo) : aaxFile.ConvertToMp3(outFile);
aaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
@@ -151,21 +156,9 @@ namespace AaxDecrypter
downloadLicense.ChapterInfo = aaxFile.Chapters;
if (decryptionResult == ConversionResult.NoErrorsDetected
&& coverArt is not null
&& OutputFormat == OutputFormat.Mp4a)
{
//This handles a special case where the aaxc file doesn't contain cover art and
//Libation downloaded it instead (Animal Farm). Currently only works for Mp4a files.
using var decryptedBook = new Mp4File(OutputFileName, FileAccess.ReadWrite);
decryptedBook.AppleTags?.SetCoverArt(coverArt);
decryptedBook.Save();
decryptedBook.Close();
}
nfsPersister.Dispose();
DecryptProgressUpdate?.Invoke(this, 0);
DecryptProgressUpdate?.Invoke(this, zeroProgress);
return decryptionResult == ConversionResult.NoErrorsDetected && !isCanceled;
}
@@ -181,7 +174,13 @@ namespace AaxDecrypter
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
DecryptProgressUpdate?.Invoke(this, (int)progressPercent);
DecryptProgressUpdate?.Invoke(this,
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(nfsPersister.NetworkFileStream.Length * progressPercent),
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
});
}
public bool Step3_CreateCue()
@@ -189,7 +188,7 @@ namespace AaxDecrypter
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(OutputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(OutputFileName), downloadLicense.ChapterInfo));
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), downloadLicense.ChapterInfo));
}
catch (Exception ex)
{
@@ -203,7 +202,7 @@ namespace AaxDecrypter
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(OutputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
}
catch (Exception ex)
{
@@ -223,6 +222,9 @@ namespace AaxDecrypter
{
isCanceled = true;
aaxFile?.Cancel();
aaxFile?.Dispose();
nfsPersister?.NetworkFileStream?.Close();
nfsPersister?.Dispose();
}
}
}

View File

@@ -5,7 +5,7 @@ namespace AaxDecrypter
{
public static class NFO
{
public static string CreateContents(string ripper, AAXClean.Mp4File aaxcTagLib, ChapterInfo chapters)
public static string CreateContents(string ripper, Mp4File aaxcTagLib, ChapterInfo chapters)
{
var _hours = (int)aaxcTagLib.Duration.TotalHours;
var myDuration
@@ -30,7 +30,7 @@ namespace AaxDecrypter
+ "\r\n"
+ "Media Information\r\n"
+ "======================\r\n"
+ " Source Format: Audible AAX\r\n"
+ " Source Format: Audible AAXC\r\n"
+ $" Source Sample Rate: {aaxcTagLib.TimeScale} Hz\r\n"
+ $" Source Channels: {aaxcTagLib.AudioChannels}\r\n"
+ $" Source Bitrate: {aaxcTagLib.AverageBitrate} Kbps\r\n"

View File

@@ -6,7 +6,6 @@ using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
using System.Threading.Tasks;
namespace AaxDecrypter
{
@@ -27,7 +26,7 @@ namespace AaxDecrypter
public CookieCollection GetCookies()
{
return base.GetCookies(Uri);
return GetCookies(Uri);
}
}
@@ -79,15 +78,14 @@ namespace AaxDecrypter
#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; }
private bool isCancelled { get; set; }
private bool finishedDownloading { get; set; }
private Action downloadThreadCompleteCallback { get; set; }
private EventWaitHandle downloadEnded { get; set; }
private EventWaitHandle downloadedPiece { get; set; }
#endregion
@@ -147,7 +145,7 @@ namespace AaxDecrypter
private void Update()
{
RequestHeaders = HttpRequest.Headers;
Updated?.Invoke(this, new EventArgs());
Updated?.Invoke(this, EventArgs.Empty);
}
/// <summary>
@@ -160,8 +158,8 @@ 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 && !finishedDownloading)
throw new Exception("Cannot change Uri during a download operation.");
if (hasBegunDownloading)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
HttpRequest = WebRequest.CreateHttp(Uri);
@@ -176,25 +174,27 @@ namespace AaxDecrypter
/// <summary>
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
/// </summary>
public void BeginDownloading()
private void BeginDownloading()
{
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
if (ContentLength != 0 && WritePosition == ContentLength)
{
hasBegunDownloading = true;
finishedDownloading = true;
downloadEnded.Set();
return;
}
if (ContentLength != 0 && WritePosition > ContentLength)
throw new Exception($"Specified write position (0x{WritePosition:X10}) is larger than the file size.");
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var response = HttpRequest.GetResponse() as HttpWebResponse;
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new Exception($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
if (response.Headers.GetValues("Accept-Ranges").FirstOrDefault(r => r.EqualsInsensitive("bytes")) is null)
throw new Exception($"Server at {Uri.Host} does not support Http ranges");
throw new WebException($"Server at {Uri.Host} does not support Http ranges");
//Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0-
@@ -202,10 +202,12 @@ namespace AaxDecrypter
ContentLength = response.ContentLength;
_networkStream = response.GetResponseStream();
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
Thread downloadThread = new Thread(() => DownloadFile());
downloadThread.Start();
new Thread(() => DownloadFile())
{ IsBackground = true }
.Start();
hasBegunDownloading = true;
return;
@@ -216,13 +218,13 @@ namespace AaxDecrypter
/// </summary>
private void DownloadFile()
{
long downloadPosition = WritePosition;
long nextFlush = downloadPosition + DATA_FLUSH_SZ;
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
byte[] buff = new byte[DOWNLOAD_BUFF_SZ];
var buff = new byte[DOWNLOAD_BUFF_SZ];
do
{
int bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
@@ -233,23 +235,25 @@ namespace AaxDecrypter
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
}
} while (downloadPosition < ContentLength && !isCancelled);
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
_networkStream.Close();
downloadedPiece.Set();
downloadEnded.Set();
if (!isCancelled && WritePosition < ContentLength)
throw new Exception("File download ended before finishing.");
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength)
throw new Exception("Downloaded file is larger than expected.");
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
finishedDownloading = true;
downloadThreadCompleteCallback?.Invoke();
}
#endregion
@@ -330,9 +334,7 @@ namespace AaxDecrypter
var result = new WebHeaderCollection();
foreach (var kvp in jObj)
{
result.Add(kvp.Key, kvp.Value.Value<string>());
}
return result;
}
@@ -341,8 +343,8 @@ namespace AaxDecrypter
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
JObject jObj = new JObject();
Type type = value.GetType();
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);
@@ -364,13 +366,21 @@ namespace AaxDecrypter
public override bool CanWrite => false;
[JsonIgnore]
public override long Length => ContentLength;
public override long Length
{
get
{
if (!hasBegunDownloading)
BeginDownloading();
return ContentLength;
}
}
[JsonIgnore]
public override long Position { get => _readFile.Position; set => Seek(value, SeekOrigin.Begin); }
[JsonIgnore]
public override bool CanTimeout => base.CanTimeout;
public override bool CanTimeout => false;
[JsonIgnore]
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
@@ -387,63 +397,39 @@ namespace AaxDecrypter
if (!hasBegunDownloading)
BeginDownloading();
long toRead = Math.Min(count, Length - Position);
long requiredPosition = Position + toRead;
//read operation will block until file contains enough data
//to fulfil the request, or until cancelled.
while (requiredPosition > WritePosition && !isCancelled)
Thread.Sleep(2);
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
long newPosition;
var newPosition = origin switch
{
SeekOrigin.Current => Position + offset,
SeekOrigin.End => ContentLength + offset,
_ => offset,
};
switch (origin)
{
case SeekOrigin.Current:
newPosition = Position + offset;
break;
case SeekOrigin.End:
newPosition = ContentLength + offset;
break;
default:
newPosition = offset;
break;
}
ReadToPosition(newPosition);
_readFile.Position = newPosition;
return newPosition;
WaitToPosition(newPosition);
return _readFile.Position = newPosition;
}
/// <summary>
/// Ensures that the file has downloaded to at least <paramref name="neededPosition"/>, then returns.
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
/// </summary>
/// <param name="neededPosition">The minimum required data length in <see cref="SaveFilePath"/>.</param>
private void ReadToPosition(long neededPosition)
{
byte[] buff = new byte[DOWNLOAD_BUFF_SZ];
do
{
Read(buff, 0, DOWNLOAD_BUFF_SZ);
} while (neededPosition > WritePosition);
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (requiredPosition > WritePosition && !isCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
}
public override void Close()
{
isCancelled = true;
downloadThreadCompleteCallback = CloseAction;
//ensure that close will run even if called after callback was fired.
if (finishedDownloading)
CloseAction();
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
}
private void CloseAction()
{
_readFile.Close();
_writeFile.Close();
_networkStream?.Close();
@@ -451,5 +437,10 @@ namespace AaxDecrypter
}
#endregion
~NetworkFileStream()
{
downloadEnded?.Close();
downloadedPiece?.Close();
}
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Version>6.0.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.50.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,333 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using FileManager;
using InternalUtilities;
using Newtonsoft.Json.Linq;
using Serilog;
namespace AppScaffolding
{
public static class LibationScaffolding
{
// AppScaffolding
private static Assembly _executingAssembly;
private static Assembly ExecutingAssembly
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
// LibationWinForms or LibationCli
private static Assembly _entryAssembly;
private static Assembly EntryAssembly
=> _entryAssembly ??= Assembly.GetEntryAssembly();
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
private static Version _buildVersion;
public static Version BuildVersion
=> _buildVersion
??= 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()
{
// must occur before access to Configuration instance
Migrations.migrate_to_v5_2_0__pre_config();
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
return Configuration.Instance;
}
public static void RunPostConfigMigrations()
{
AudibleApiStorage.EnsureAccountsSettingsFileExists();
var config = Configuration.Instance;
//
// migrations go below here
//
Migrations.migrate_to_v5_2_0__post_config(config);
Migrations.migrate_to_v5_7_1(config);
}
/// <summary>Initialize logging. Run after migration</summary>
public static void RunPostMigrationScaffolding()
{
var config = Configuration.Instance;
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
}
private static void ensureSerilogConfig(Configuration config)
{
if (config.GetObject("Serilog") != null)
return;
// "Serilog": {
// "MinimumLevel": "Information"
// "WriteTo": [
// {
// "Name": "Console"
// },
// {
// "Name": "File",
// "Args": {
// "rollingInterval": "Day",
// "outputTemplate": ...
// }
// }
// ],
// "Using": [ "Dinah.Core" ],
// "Enrich": [ "WithCaller" ]
// }
var serilogObj = new JObject
{
{ "MinimumLevel", "Information" },
{ "WriteTo", new JArray
{
new JObject { {"Name", "Console" } },
new JObject
{
{ "Name", "File" },
{ "Args",
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" }
}
}
}
}
},
{ "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller" } },
};
config.SetObject("Serilog", serilogObj);
}
// to restore original: Console.SetOut(origOut);
private static TextWriter origOut { get; } = Console.Out;
private static void configureLogging(Configuration config)
{
config.ConfigureLogging();
// capture most Console.WriteLine() and write to serilog. See below tests for details.
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
//
// Serilog also writes to Console so this might be asking for trouble. ie: infinite loops.
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
// Empirical testing so far has shown no issues.
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
#region Console => Serilog tests
/*
// all below apply to "Console." and "Console.Out."
// captured
Console.WriteLine("str");
Console.WriteLine(new { a = "anon" });
Console.WriteLine("{0}", "format");
Console.WriteLine("{0}{1}", "zero|", "one");
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
Console.WriteLine("{0}", new object[] { "arr" });
// not captured
Console.WriteLine();
Console.WriteLine(true);
Console.WriteLine('0');
Console.WriteLine(1);
Console.WriteLine(2m);
Console.WriteLine(3f);
Console.WriteLine(4d);
Console.WriteLine(5L);
Console.WriteLine((uint)6);
Console.WriteLine((ulong)7);
Console.Write("str");
Console.Write(true);
Console.Write('0');
Console.Write(1);
Console.Write(2m);
Console.Write(3f);
Console.Write(4d);
Console.Write(5L);
Console.Write((uint)6);
Console.Write((ulong)7);
Console.Write(new { a = "anon" });
Console.Write("{0}", "format");
Console.Write("{0}{1}", "zero|", "one");
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
Console.Write("{0}", new object[] { "arr" });
*/
#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");
}
private static void logStartupState(Configuration config)
{
// 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
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
config.InProgress,
DownloadsInProgressDir = AudibleFileStorage.DownloadsInProgress,
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgress).Count(),
DecryptInProgressDir = AudibleFileStorage.DecryptInProgress,
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgress).Count(),
});
}
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
{
(bool, string, string, string) isFalse = (false, null, null, null);
// timed out
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null)
return isFalse;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return isFalse;
// we're up to date
if (latestRelease <= BuildVersion)
return isFalse;
// 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
{
latestRelease = latestRelease.ToString(),
latest.HtmlUrl,
zipUrl
});
return (true, zipUrl, latest.HtmlUrl, zip.Name);
}
private static Octokit.Release getLatestRelease(TimeSpan timeout)
{
try
{
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
if (task.Wait(timeout))
return task.Result;
Log.Logger.Information("Timed out");
}
catch (AggregateException aggEx)
{
Log.Logger.Error(aggEx, "Checking for new version too often");
}
return null;
}
private static Octokit.Release getLatestRelease()
{
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("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;
}
}
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
};
public static void migrate_to_v5_2_0__post_config(Configuration config)
{
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
}
#endregion
// add config.BadBook
public static void migrate_to_v5_7_1(Configuration config)
{
if (!config.Exists(nameof(config.BadBook)))
config.BadBook = Configuration.BadBookAction.Ask;
}
}
}

View File

@@ -6,13 +6,21 @@ using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace LibationLauncher
namespace AppScaffolding
{
/// <summary>for migrations only. directly manipulatings settings files without going through domain logic</summary>
/// <summary>
///
///
/// directly manipulates settings files without going through domain logic.
///
/// for migrations only. use with caution.
///
///
/// </summary>
internal static class UNSAFE_MigrationHelper
{
#region appsettings.json
private const string APPSETTINGS_JSON = "appsettings.json";
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);

View File

@@ -6,14 +6,11 @@
<ItemGroup>
<PackageReference Include="CsvHelper" Version="27.1.1" />
<PackageReference Include="NPOI" Version="2.5.3" />
<PackageReference Include="NPOI" Version="2.5.4" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
</ItemGroup>

View File

@@ -1,4 +1,5 @@
using System;
using System.Collections.Generic;
using DataLayer;
using FileManager;
@@ -6,11 +7,15 @@ namespace ApplicationServices
{
public static class DbContexts
{
//// idea for future command/query separation
// public static LibationContext GetCommandContext() { }
// public static LibationContext GetQueryContext() { }
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString);
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking();
}
}
}

View File

@@ -8,28 +8,94 @@ using Dinah.Core;
using DtoImporterService;
using InternalUtilities;
using Serilog;
using static DtoImporterService.PerfLogger;
namespace ApplicationServices
{
public static class LibraryCommands
{
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
private static LibraryOptions.ResponseGroupOptions LibraryResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS;
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
LibraryResponseGroups =
LibraryOptions.ResponseGroupOptions.ProductAttrs |
LibraryOptions.ResponseGroupOptions.ProductDesc |
LibraryOptions.ResponseGroupOptions.Relationships;
if (accounts is null || accounts.Length == 0)
return new List<LibraryBook>();
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts);
logTime($"post {nameof(scanAccountsAsync)} all");
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();
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(FileManager.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
{
LibraryResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS;
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
{
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
logTime($"pre {nameof(scanAccountsAsync)} all");
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
Log.Logger.Information($"Import: New count {newCount}");
await Task.Run(() => SearchEngineCommands.FullReIndex());
Log.Logger.Information("FullReIndex: success");
logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
@@ -41,7 +107,8 @@ namespace ApplicationServices
// 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 {
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
@@ -55,20 +122,23 @@ namespace ApplicationServices
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts)
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
var callback = loginCallbackFactoryFunc(account);
// get APIs in serial, esp b/c of logins
var api = await AudibleApiActions.GetApiAsync(callback, account);
// 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(api, account));
tasks.Add(scanAccountAsync(apiExtended, account));
}
// import library in parallel
@@ -77,7 +147,7 @@ namespace ApplicationServices
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
@@ -86,38 +156,131 @@ namespace ApplicationServices
Account = account?.MaskedLogEntry ?? "[null]"
});
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
logTime($"pre scanAccountAsync {account.AccountName}");
var dtoItems = await apiExtended.GetLibraryValidatedAsync(LibraryResponseGroups);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
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)
{
logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext();
var libraryImporter = new LibraryImporter(context);
var libraryImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
context.SaveChanges();
logTime("importIntoDbAsync -- post Import()");
var qtyChanges = context.SaveChanges();
logTime("importIntoDbAsync -- post SaveChanges");
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount;
}
#endregion
public static int UpdateTags(this LibationContext context, Book book, string newTags)
#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();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
context.LibraryBooks.RemoveRange(removeLibraryBooks);
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return removeLibraryBooks;
}
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange()
{
SearchEngineCommands.FullReIndex();
LibrarySizeChanged?.Invoke(null, null);
}
/// <summary>Occurs when books are added or removed from library</summary>
public static event EventHandler LibrarySizeChanged;
/// <summary>
/// Occurs when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/>
/// changed values are successfully persisted.
/// </summary>
public static event EventHandler<string> BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(Book book)
{
try
{
book.UserDefinedItem.Tags = newTags;
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
SearchEngineCommands.UpdateBookTags(book);
{
SearchEngineCommands.UpdateLiberatedStatus(book);
BookUserDefinedItemCommitted?.Invoke(null, book.AudibleProductId);
}
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error updating tags");
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
: FileManager.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 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

@@ -47,8 +47,8 @@ namespace ApplicationServices
[Name("Publisher")]
public string Publisher { get; set; }
[Name("Pdf url")]
public string PdfUrl { get; set; }
[Name("Has PDF")]
public bool HasPdf { get; set; }
[Name("Series Names")]
public string SeriesNames { get; set; }
@@ -88,6 +88,15 @@ namespace ApplicationServices
[Name("My Libation Tags")]
public string MyLibationTags { get; set; }
[Name("Book Liberated Status")]
public string BookStatus { get; set; }
[Name("PDF Liberated Status")]
public string PdfStatus { get; set; }
[Name("Content Type")]
public string ContentType { get; set; }
}
public static class LibToDtos
{
@@ -103,7 +112,7 @@ namespace ApplicationServices
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Publisher = a.Book.Publisher,
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url,
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames,
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Index} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating,
@@ -116,16 +125,17 @@ namespace ApplicationServices
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
MyLibationTags = a.Book.UserDefinedItem.Tags
MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString()
}).ToList();
}
public static class LibraryExporter
{
public static void ToCsv(string saveFilePath)
{
using var context = DbContexts.GetContext();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any())
return;
@@ -139,17 +149,14 @@ namespace ApplicationServices
public static void ToJson(string saveFilePath)
{
using var context = DbContexts.GetContext();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
System.IO.File.WriteAllText(saveFilePath, json);
}
public static void ToXlsx(string saveFilePath)
{
using var context = DbContexts.GetContext();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Library");
@@ -174,7 +181,7 @@ namespace ApplicationServices
nameof (ExportDto.NarratorNames),
nameof (ExportDto.LengthInMinutes),
nameof (ExportDto.Publisher),
nameof (ExportDto.PdfUrl),
nameof (ExportDto.HasPdf),
nameof (ExportDto.SeriesNames),
nameof (ExportDto.SeriesOrder),
nameof (ExportDto.CommunityRatingOverall),
@@ -187,7 +194,10 @@ namespace ApplicationServices
nameof (ExportDto.MyRatingOverall),
nameof (ExportDto.MyRatingPerformance),
nameof (ExportDto.MyRatingStory),
nameof (ExportDto.MyLibationTags)
nameof (ExportDto.MyLibationTags),
nameof (ExportDto.BookStatus),
nameof (ExportDto.PdfStatus),
nameof (ExportDto.ContentType)
};
var col = 0;
foreach (var c in columns)
@@ -224,7 +234,7 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
row.CreateCell(col++).SetCellValue(dto.Publisher);
row.CreateCell(col++).SetCellValue(dto.PdfUrl);
row.CreateCell(col++).SetCellValue(dto.HasPdf);
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
@@ -249,6 +259,9 @@ namespace ApplicationServices
col = createCell(row, col, dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
rowIndex++;
}

View File

@@ -7,10 +7,11 @@ namespace ApplicationServices
{
public static class SearchEngineCommands
{
public static void FullReIndex()
public static void FullReIndex(SearchEngine engine = null)
{
var engine = new SearchEngine(DbContexts.GetContext());
engine.CreateNewIndex();
engine ??= new SearchEngine();
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
}
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
@@ -21,35 +22,35 @@ namespace ApplicationServices
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
);
public static void UpdateIsLiberated(Book book) => performSearchEngineAction_safe(e =>
e.UpdateIsLiberated(book.AudibleProductId)
public static void UpdateLiberatedStatus(Book book) => performSearchEngineAction_safe(e =>
e.UpdateLiberatedStatus(book)
);
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
{
var engine = new SearchEngine(DbContexts.GetContext());
var engine = new SearchEngine();
try
{
action(engine);
}
catch (FileNotFoundException)
{
FullReIndex();
FullReIndex(engine);
action(engine);
}
}
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action)
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
{
var engine = new SearchEngine(DbContexts.GetContext());
var engine = new SearchEngine();
try
{
return action(engine);
return func(engine);
}
catch (FileNotFoundException)
{
FullReIndex();
return action(engine);
FullReIndex(engine);
return func(engine);
}
}
}

View File

@@ -7,6 +7,20 @@ namespace DataLayer.Configurations
{
public void Configure(EntityTypeBuilder<LibraryBook> entity)
{
// to allow same book (incl region) with diff acct.s:
//
// this file:
// - composite key:
// entity.HasKey(b => new { b.BookId, b.Account });
// entity.HasIndex(b => b.BookId);
// entity.HasIndex(b => b.Account);
// - change the below relationship since Book+LibraryBook would no longer be 1:1
//
// other files:
// - change Book class since Book+LibraryBook would no longer be 1:1
// - update LibraryBook import code
// - would likely challenge assumptions throughout Libation which have been true up until now
entity.HasKey(b => b.BookId);
entity

View File

@@ -12,19 +12,19 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5">
<PackageReference Include="Dinah.EntityFrameworkCore" Version="1.0.5.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.10" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.10">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>

View File

@@ -15,6 +15,10 @@ namespace DataLayer
Id = id;
}
}
// enum will be easier than bool to extend later
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
public class Book
{
// implementation detail. set by db only. only used by data layer
@@ -25,8 +29,7 @@ namespace DataLayer
public string Title { get; private set; }
public string Description { get; private set; }
public int LengthInMinutes { get; private set; }
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
// mutable
@@ -51,6 +54,12 @@ namespace DataLayer
// is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; }
// UserDefinedItem convenience properties
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public bool Audio_Exists => UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
/// <summary>True if exists and IsLiberated. Else false</summary>
public bool PDF_Exists => UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
// is owned, not optional 1:1
/// <summary>The product's aggregate community rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
@@ -64,9 +73,11 @@ namespace DataLayer
string title,
string description,
int lengthInMinutes,
ContentType contentType,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
Category category, string localeName)
Category category,
string localeName)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
@@ -88,9 +99,10 @@ namespace DataLayer
Category = category;
// simple assigns
Title = title;
Description = description;
Title = title.Trim();
Description = description?.Trim();
LengthInMinutes = lengthInMinutes;
ContentType = contentType;
// assigns with biz logic
ReplaceAuthors(authors);
@@ -220,8 +232,11 @@ namespace DataLayer
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
if (!_supplements.Any(s => url.EqualsInsensitive(url)))
_supplements.Add(new Supplement(this, url));
if (_supplements.Any(s => url.EqualsInsensitive(url)))
return;
_supplements.Add(new Supplement(this, url));
UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated;
}
#endregion
@@ -244,10 +259,6 @@ namespace DataLayer
Category = category;
}
// needed for v3 => v4 upgrade
public void UpdateLocale(string localeName)
=> Locale ??= localeName;
public override string ToString() => $"[{AudibleProductId}] {Title}";
}
}

View File

@@ -2,6 +2,8 @@
namespace DataLayer
{
public enum Role { Author = 1, Narrator = 2, Publisher = 3 }
public class BookContributor
{
internal int BookId { get; private set; }

View File

@@ -9,8 +9,6 @@ namespace DataLayer
public Book Book { get; private set; }
public DateTime DateAdded { get; private set; }
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
public string Account { get; private set; }
private LibraryBook() { }
@@ -24,10 +22,6 @@ namespace DataLayer
Account = account;
}
// needed for v3 => v4 upgrade
public void UpdateAccount(string account)
=> Account ??= account;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

View File

@@ -1,4 +0,0 @@
namespace DataLayer
{
public enum Role { Author = 1, Narrator = 2, Publisher = 3 }
}

View File

@@ -6,6 +6,20 @@ using Dinah.Core;
namespace DataLayer
{
/// <summary>
/// Do not track in-process state. In-process state is determined by the presence of temp file.
/// </summary>
public enum LiberatedStatus
{
NotLiberated = 0,
Liberated = 1,
/// <summary>Error occurred during liberation. Don't retry</summary>
Error = 2,
/// <summary>Application-state only. Not a valid persistence state.</summary>
PartialDownload = 0x1000
}
public class UserDefinedItem
{
internal int BookId { get; private set; }
@@ -22,11 +36,20 @@ namespace DataLayer
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
}
#region Tags
private string _tags = "";
public string Tags
{
get => _tags;
set => _tags = sanitize(value);
set
{
var newTags = sanitize(value);
if (_tags != newTags)
{
_tags = newTags;
ItemChanged?.Invoke(this, nameof(Tags));
}
}
}
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
@@ -71,14 +94,51 @@ namespace DataLayer
return string.Join(" ", unique);
}
#endregion
#endregion
#region Rating
// owned: not an optional one-to-one
/// <summary>The user's individual book rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
#endregion
public override string ToString() => $"{Book} {Rating} {Tags}";
#region LiberatedStatuses
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public LiberatedStatus BookStatus
{
get => _bookStatus;
set
{
if (_bookStatus != value)
{
_bookStatus = value;
ItemChanged?.Invoke(this, nameof(BookStatus));
}
}
}
public LiberatedStatus? PdfStatus
{
get => _pdfStatus;
set
{
if (_pdfStatus != value)
{
_pdfStatus = value;
ItemChanged?.Invoke(this, nameof(PdfStatus));
}
}
}
#endregion
/// <summary>
/// Occurs when <see cref="Tags"/>, <see cref="BookStatus"/>, or <see cref="PdfStatus"/> values change.
/// This signals the change of the in-memory value; it does not ensure that the new value has been persisted.
/// </summary>
public static event EventHandler<string> ItemChanged;
public override string ToString() => $"{Book} {Rating} {Tags}";
}
}

View File

@@ -19,7 +19,7 @@ namespace DataLayer
// // overwrite collection
// Entry(product).Collection(x => x.Narrators).Load();
// product.Narrators = narrators;
public DbSet<LibraryBook> Library { get; private set; }
public DbSet<LibraryBook> LibraryBooks { get; private set; }
public DbSet<Book> Books { get; private set; }
public DbSet<Contributor> Contributors { get; private set; }
public DbSet<Series> Series { get; private set; }

View File

@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210727180408_AddLiberatedStatus")]
partial class AddLiberatedStatus
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.5");
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<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>("Title")
.HasColumnType("TEXT");
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("Library");
});
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<float?>("Index")
.HasColumnType("REAL");
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<string>("BookLocation")
.HasColumnType("TEXT");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem");
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,44 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class AddLiberatedStatus : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BookLocation",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<int>(
name: "BookStatus",
table: "UserDefinedItem",
type: "INTEGER",
nullable: false,
defaultValue: 0);
migrationBuilder.AddColumn<int>(
name: "PdfStatus",
table: "UserDefinedItem",
type: "INTEGER",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BookLocation",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "BookStatus",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "PdfStatus",
table: "UserDefinedItem");
}
}
}

View File

@@ -0,0 +1,387 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210821012137_RemoveUdiBookLocation")]
partial class RemoveUdiBookLocation
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.5");
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<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>("Title")
.HasColumnType("TEXT");
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("Library");
});
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<float?>("Index")
.HasColumnType("REAL");
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");
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,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class RemoveUdiBookLocation : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BookLocation",
table: "UserDefinedItem");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BookLocation",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
}
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210901205042_BookIsEpisode")]
partial class BookIsEpisode
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.9");
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>("Title")
.HasColumnType("TEXT");
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("Library");
});
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<float?>("Index")
.HasColumnType("REAL");
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");
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,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class BookIsEpisode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ContentType",
table: "Books",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContentType",
table: "Books");
}
}
}

View File

@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210902192153_RenameLibraryBooks")]
partial class RenameLibraryBooks
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.9");
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>("Title")
.HasColumnType("TEXT");
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<float?>("Index")
.HasColumnType("REAL");
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");
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,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class RenameLibraryBooks : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Library_Books_BookId",
table: "Library");
migrationBuilder.DropPrimaryKey(
name: "PK_Library",
table: "Library");
migrationBuilder.RenameTable(
name: "Library",
newName: "LibraryBooks");
migrationBuilder.AddPrimaryKey(
name: "PK_LibraryBooks",
table: "LibraryBooks",
column: "BookId");
migrationBuilder.AddForeignKey(
name: "FK_LibraryBooks_Books_BookId",
table: "LibraryBooks",
column: "BookId",
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_LibraryBooks_Books_BookId",
table: "LibraryBooks");
migrationBuilder.DropPrimaryKey(
name: "PK_LibraryBooks",
table: "LibraryBooks");
migrationBuilder.RenameTable(
name: "LibraryBooks",
newName: "Library");
migrationBuilder.AddPrimaryKey(
name: "PK_Library",
table: "Library",
column: "BookId");
migrationBuilder.AddForeignKey(
name: "FK_Library_Books_BookId",
table: "Library",
column: "BookId",
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
}
}
}

View File

@@ -14,7 +14,7 @@ namespace DataLayer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.5");
.HasAnnotation("ProductVersion", "5.0.9");
modelBuilder.Entity("DataLayer.Book", b =>
{
@@ -28,6 +28,9 @@ namespace DataLayer.Migrations
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
@@ -152,7 +155,7 @@ namespace DataLayer.Migrations
b.HasKey("BookId");
b.ToTable("Library");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
@@ -253,6 +256,12 @@ namespace DataLayer.Migrations
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");

View File

@@ -6,12 +6,14 @@ using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
// only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook
public static class BookQueries
{
public static Book GetBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.Books
.AsNoTracking()
.AsNoTrackingWithIdentityResolution()
.GetBook(productId);
public static Book GetBook(this IQueryable<Book> books, string productId)
@@ -25,6 +27,7 @@ namespace DataLayer
.GetBooks()
.Where(predicate);
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<Book> GetBooks(this IQueryable<Book> books)
=> books
// owned items are always loaded. eg: book.UserDefinedItem, book.Supplements

View File

@@ -1,19 +0,0 @@
using System;
using System.Linq;
namespace DataLayer
{
public static class GenericPaging
{
public static IQueryable<T> Page<T>(this IQueryable<T> query, int pageNumZeroStart, int pageSize)
{
if (pageSize < 1)
throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be at least 1");
if (pageNumZeroStart > 0)
query = query.Skip(pageNumZeroStart * pageSize);
return query.Take(pageSize);
}
}
}

View File

@@ -4,27 +4,35 @@ using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public static class LibraryQueries
// only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook
public static class LibraryBookQueries
{
public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
=> context
.Library
.GetLibrary()
.ToList();
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
//public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
// => context
// .Library
// .GetLibrary()
// .ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
=> context
.Library
.AsNoTracking()
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.Library
.AsNoTracking()
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId);
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)
=> library
@@ -32,10 +40,5 @@ namespace DataLayer
.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 LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(le => le.Book.AudibleProductId == productId);
}
}

View File

@@ -1,57 +0,0 @@
FOR QUICK MIGRATION INSTRUCTIONS:
_DB_NOTES.txt
HOW TO CREATE: EF CORE PROJECT
==============================
example is for sqlite but the same works with MsSql
nuget
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
Microsoft.EntityFrameworkCore.Sqlite
MIGRATIONS
require core, not standard
this can be a problem b/c standard and framework can only reference standard, not core
TO USE MIGRATIONS (core and/or standard)
add to csproj
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
TO USE MIGRATIONS AS *BOTH* CORE AND STANDARD
edit csproj
pluralize this xml tag
from: TargetFramework
to: TargetFrameworks
inside of TargetFrameworks
from: netstandard2.1
to: netcoreapp3.1;netstandard2.1
run. error
SQLite Error 1: 'no such table: Blogs'.
set project "Set as StartUp Project"
Tools >> Nuget Package Manager >> Package Manager Console
default project: Examples\SQLite_NETCore2_0
PM> add-migration InitialCreate
PM> Update-Database
if add-migration xyz throws and error, don't take the error msg at face value. try again with add-migration xyz -verbose
new sqlite .db file created: Copy always/Copy if newer
or copy .db file to destination
relative:
optionsBuilder.UseSqlite("Data Source=blogging.db");
absolute (use fwd slashes):
optionsBuilder.UseSqlite("Data Source=C:/foo/bar/blogging.db");
REFERENCE ARTICLES
------------------
https://docs.microsoft.com/en-us/ef/core/get-started/netcore/new-db-sqlite
https://carlos.mendible.com/2016/07/11/step-by-step-dotnet-core-and-entity-framework-core/
https://www.benday.com/2017/12/19/ef-core-2-0-migrations-without-hard-coded-connection-strings/

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using AudibleApi.Common;
using DataLayer;
using InternalUtilities;
@@ -19,13 +19,13 @@ namespace DtoImporterService
new ContributorImporter(DbContext).Import(importItems);
new SeriesImporter(DbContext).Import(importItems);
new CategoryImporter(DbContext).Import(importItems);
// get distinct
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList();
var productIds = importItems.Select(i => i.DtoItem.ProductId).Distinct().ToList();
// load db existing => .Local
loadLocal_books(productIds);
// upsert
var qtyNew = upsertBooks(importItems);
return qtyNew;
@@ -33,12 +33,30 @@ namespace DtoImporterService
private void loadLocal_books(List<string> productIds)
{
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId);
// if this context has already loaded books, don't need to reload them. vestige from when context was long-lived. in practice, we now typically use a fresh context. this is quick though so no harm in leaving it.
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId).ToList();
var remainingProductIds = productIds
.Distinct()
.Except(localProductIds)
.ToList();
#region // explanation of DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
/*
articles suggest loading to Local with
context.Books.Load();
we want Books and associated fields
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
this is emulating Load() but with also getting associated fields
from: Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
// Summary:
// Enumerates the query. When using Entity Framework, this causes the results of
// the query to be loaded into the associated context. This is equivalent to calling
// ToList and then throwing away the list (without the overhead of actually creating
// the list).
public static void Load<TSource>([NotNullAttribute] this IQueryable<TSource> source);
*/
#endregion
// GetBooks() eager loads Series, category, et al
if (remainingProductIds.Any())
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
@@ -67,8 +85,7 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
//Add any subtitle after the title title.
var title = item.Title + (!string.IsNullOrWhiteSpace(item.Subtitle) ? $": {item.Subtitle}" : "");
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
@@ -105,9 +122,10 @@ namespace DtoImporterService
var book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
title,
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
authors,
narrators,
category,
@@ -123,8 +141,8 @@ namespace DtoImporterService
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
book.AddSupplementDownloadUrl(item.SupplementUrl);
if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
return book;
}
@@ -137,9 +155,6 @@ namespace DtoImporterService
book.PictureId = item.PictureId;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
// needed during v3 => v4 migration
book.UpdateLocale(importItem.LocaleName);
// 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);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using AudibleApi.Common;
using DataLayer;
using InternalUtilities;
@@ -35,7 +35,7 @@ namespace DtoImporterService
private void loadLocal_categories(List<string> categoryIds)
{
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId);
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId).ToList();
var remainingCategoryIds = categoryIds
.Distinct()
.Except(localIds)

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using AudibleApi.Common;
using DataLayer;
using InternalUtilities;
@@ -47,57 +47,61 @@ namespace DtoImporterService
private void loadLocal_contributors(List<string> contributorNames)
{
// must include default/empty/missing
contributorNames.Add(Contributor.GetEmpty().Name);
//// BAD: very inefficient
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
// GOOD: Except() is efficient. Due to hashing, it's close to O(n)
var localNames = DbContext.Contributors.Local.Select(c => c.Name);
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
var remainingContribNames = contributorNames
.Distinct()
.Except(localNames)
.ToList();
// load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingContribNames.Any())
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList();
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
}
// only use after loading contributors => local
private int upsertPeople(List<Person> people)
{
var qtyNew = 0;
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
var newPeople = people
.Select(p => p.Name)
.Distinct()
.Except(localNames)
.ToList();
foreach (var p in people)
var groupby = people.GroupBy(
p => p.Name,
p => p,
(key, g) => new { Name = key, People = g.ToList() }
);
foreach (var name in newPeople)
{
// Should be 'Single' not 'First'. A user had a duplicate get in somehow though so I'm now using 'First' defensively
var person = DbContext.Contributors.Local.FirstOrDefault(c => c.Name == p.Name);
if (person == null)
{
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
qtyNew++;
}
var p = groupby.Single(g => g.Name == name).People.First();
DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
}
return qtyNew;
return newPeople.Count;
}
// only use after loading contributors => local
private int upsertPublishers(List<string> publishers)
{
var qtyNew = 0;
var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
var newPublishers = publishers
.Distinct()
.Except(localNames)
.ToList();
foreach (var publisherName in publishers)
{
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
{
DbContext.Contributors.Add(new Contributor(publisherName));
qtyNew++;
}
}
foreach (var pub in newPublishers)
DbContext.Contributors.Add(new Contributor(pub));
return qtyNew;
return newPublishers.Count;
}
}
}

View File

@@ -5,7 +5,6 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
</ItemGroup>

View File

@@ -1,7 +1,7 @@
using System;
using AudibleApiDTOs;
using AudibleApi.Common;
namespace InternalUtilities
namespace DtoImporterService
{
public class ImportItem
{

View File

@@ -1,15 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using DataLayer;
using InternalUtilities;
namespace DtoImporterService
{
public class LibraryImporter : ItemsImporterBase
public class LibraryBookImporter : ItemsImporterBase
{
public LibraryImporter(LibationContext context) : base(context) { }
public LibraryBookImporter(LibationContext context) : base(context) { }
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
@@ -28,13 +27,14 @@ namespace DtoImporterService
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache. it'll be fixed in pre .net5/efcore5
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
var currentLibraryProductIds = DbContext.Library.Select(l => l.Book.AudibleProductId).ToList();
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
foreach (var newItem in newItems)
@@ -43,16 +43,7 @@ namespace DtoImporterService
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
newItem.DtoItem.DateAdded,
newItem.AccountId);
DbContext.Library.Add(libraryBook);
}
// needed for v3 => v4 upgrade
var toUpdate = DbContext.Library.Where(l => l.Account == null);
foreach (var u in toUpdate)
{
var item = importItems.FirstOrDefault(ii => ii.DtoItem.ProductId == u.Book.AudibleProductId);
if (item != null)
u.UpdateAccount(item.AccountId);
DbContext.LibraryBooks.Add(libraryBook);
}
var qtyNew = newItems.Count;

View File

@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace DtoImporterService
{
public record timeLogEntry(string msg, long totalElapsed, long delta);
public static class PerfLogger
{
private static Stopwatch sw = new Stopwatch();
private static List<timeLogEntry> __log { get; } = new List<timeLogEntry> { new("begin", 0, 0) };
public static void logTime(string s)
{
var totalElapsed = sw.ElapsedMilliseconds;
var prev = __log.Last().totalElapsed;
var delta = totalElapsed - prev;
__log.Add(new(s, totalElapsed, delta));
}
public static void logRestart()
{
__log.Clear();
__log.Add(new("begin", 0, 0));
sw.Restart();
}
public static void stop() => sw.Stop();
public static string logOutput =>
$"{nameof(timeLogEntry.msg)}\t{nameof(timeLogEntry.totalElapsed)}\t{nameof(timeLogEntry.delta)}\r\n"
+ __log.Select(t => $"{t.msg}\t{t.totalElapsed}\t{t.delta}").Aggregate((a, b) => $"{a}\r\n{b}");
}
}

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using AudibleApi.Common;
using DataLayer;
using InternalUtilities;
@@ -29,7 +29,7 @@ namespace DtoImporterService
return qtyNew;
}
private void loadLocal_series(List<AudibleApiDTOs.Series> series)
private void loadLocal_series(List<AudibleApi.Common.Series> series)
{
var seriesIds = series.Select(s => s.SeriesId).ToList();
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
@@ -42,7 +42,7 @@ namespace DtoImporterService
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
}
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries)
private int upsertSeries(List<AudibleApi.Common.Series> requestedSeries)
{
var qtyNew = 0;

View File

@@ -1,57 +0,0 @@
using System;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
using FileManager;
namespace FileLiberator
{
/// <summary>
/// Download DRM book and decrypt audiobook files
///
/// Processes:
/// Download: download aax file: the DRM encrypted audiobook
/// Decrypt: remove DRM encryption from audiobook. Store final book
/// Backup: perform all steps (downloaded, decrypt) still needed to get final book
/// </summary>
public class BackupBook : IProcessable
{
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<LibraryBook> Completed;
public DownloadDecryptBook DecryptBook { get; } = new DownloadDecryptBook();
public DownloadPdf DownloadPdf { get; } = new DownloadPdf();
public bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
// do NOT use ConfigureAwait(false) on ProcessAsync()
// often calls events which prints to forms in the UI context
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
Begin?.Invoke(this, libraryBook);
try
{
{
var statusHandler = await DecryptBook.TryProcessAsync(libraryBook);
if (statusHandler.HasErrors)
return statusHandler;
}
{
var statusHandler = await DownloadPdf.TryProcessAsync(libraryBook);
if (statusHandler.HasErrors)
return statusHandler;
}
return new StatusHandler();
}
finally
{
Completed?.Invoke(this, libraryBook);
}
}
}
}

View File

@@ -0,0 +1,121 @@
using AAXClean;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.IO;
using Dinah.Core.Net.Http;
using FileManager;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace FileLiberator
{
public class ConvertToMp3 : IAudioDecodable
{
private Mp4File m4bBook;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public event EventHandler<Action<byte[]>> RequestCoverArt;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageDiscovered;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<LibraryBook> Completed;
public ConvertToMp3()
{
RequestCoverArt += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
TitleDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = e });
AuthorsDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = e });
NarratorsDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = e });
CoverImageDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = e?.Length });
StreamingBegin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = e });
StreamingCompleted += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = e });
Begin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = e.LogFriendly() });
Completed += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = e.LogFriendly() });
}
private long fileSize;
private string Mp3FileName(string m4bPath) => m4bPath is null ? string.Empty : PathLib.ReplaceExtension(m4bPath, ".mp3");
public void Cancel() => m4bBook?.Cancel();
public bool Validate(LibraryBook libraryBook)
{
var path = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
}
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
Begin?.Invoke(this, libraryBook);
StreamingBegin?.Invoke(this, $"Begin converting {libraryBook} to mp3");
try
{
var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
m4bBook = new Mp4File(m4bPath, FileAccess.Read);
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
fileSize = m4bBook.InputStream.Length;
TitleDiscovered?.Invoke(this, m4bBook.AppleTags.Title);
AuthorsDiscovered?.Invoke(this, m4bBook.AppleTags.FirstAuthor);
NarratorsDiscovered?.Invoke(this, m4bBook.AppleTags.Narrator);
CoverImageDiscovered?.Invoke(this, m4bBook.AppleTags.Cover);
using var mp3File = File.OpenWrite(Path.GetTempFileName());
var result = await Task.Run(() => m4bBook.ConvertToMp3(mp3File));
m4bBook.InputStream.Close();
mp3File.Close();
var mp3Path = Mp3FileName(m4bPath);
FileExt.SafeMove(mp3File.Name, mp3Path);
var statusHandler = new StatusHandler();
if (result == ConversionResult.Failed)
statusHandler.AddError("Conversion failed");
return statusHandler;
}
finally
{
StreamingCompleted?.Invoke(this, $"Completed converting to mp3: {libraryBook.Book.Title}");
Completed?.Invoke(this, libraryBook);
}
}
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = m4bBook.Duration;
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
StreamingTimeRemaining?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
StreamingProgressChanged?.Invoke(this,
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(fileSize * progressPercent),
TotalBytesToReceive = fileSize
});
}
}
}

View File

@@ -1,41 +1,57 @@
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileManager;
using System;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AaxDecrypter;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using FileManager;
namespace FileLiberator
{
public class DownloadDecryptBook : IDecryptable
public class DownloadDecryptBook : IAudioDecodable
{
private AaxcDownloadConverter aaxcDownloader;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public event EventHandler<Action<byte[]>> RequestCoverArt;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> DecryptBegin;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageFilepathDiscovered;
public event EventHandler<int> UpdateProgress;
public event EventHandler<TimeSpan> UpdateRemainingTime;
public event EventHandler<string> DecryptCompleted;
public event EventHandler<LibraryBook> Completed;
public event EventHandler<byte[]> CoverImageDiscovered;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<LibraryBook> Completed;
public DownloadDecryptBook()
{
RequestCoverArt += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
TitleDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = e });
AuthorsDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = e });
NarratorsDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = e });
CoverImageDiscovered += (o, e) => Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = e?.Length });
StreamingBegin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = e });
StreamingCompleted += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = e });
Begin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = e.LogFriendly() });
Completed += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = e.LogFriendly() });
}
private AaxcDownloadConverter aaxcDownloader;
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
Begin?.Invoke(this, libraryBook);
try
{
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
if (libraryBook.Book.Audio_Exists)
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
var outputAudioFilename = await aaxToM4bConverterDecryptAsync(AudibleFileStorage.DownloadsInProgress, AudibleFileStorage.DecryptInProgress, libraryBook);
@@ -45,12 +61,13 @@ namespace FileLiberator
return new StatusHandler { "Decrypt failed" };
// moves files and returns dest dir
_ = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
var moveResults = MoveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
if (!finalAudioExists)
if (!moveResults.movedAudioFile)
return new StatusHandler { "Cannot find final audio file after decryption" };
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
return new StatusHandler();
}
finally
@@ -61,22 +78,21 @@ namespace FileLiberator
private async Task<string> aaxToM4bConverterDecryptAsync(string cacheDir, string destinationDir, LibraryBook libraryBook)
{
DecryptBegin?.Invoke(this, $"Begin decrypting {libraryBook}");
StreamingBegin?.Invoke(this, $"Begin decrypting {libraryBook}");
try
{
validate(libraryBook);
var api = await InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var aaxcDecryptDlLic = new DownloadLicense
(
contentLic.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic.Voucher?.Key,
contentLic.Voucher?.Iv,
Resources.UserAgent
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic?.Voucher?.Key,
contentLic?.Voucher?.Iv,
Resources.USER_AGENT
);
if (Configuration.Instance.AllowLibationFixup)
@@ -97,12 +113,12 @@ namespace FileLiberator
_ => throw new NotImplementedException(),
};
var proposedOutputFile = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
var outFileName = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
aaxcDownloader = new AaxcDownloadConverter(proposedOutputFile, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" };
aaxcDownloader.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => UpdateRemainingTime?.Invoke(this, remaining);
aaxcDownloader = new AaxcDownloadConverter(outFileName, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" };
aaxcDownloader.DecryptProgressUpdate += (s, progress) => StreamingProgressChanged?.Invoke(this, progress);
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => StreamingTimeRemaining?.Invoke(this, remaining);
aaxcDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
aaxcDownloader.RetrievedTags += aaxcDownloader_RetrievedTags;
@@ -113,14 +129,15 @@ namespace FileLiberator
if (!success)
return null;
return aaxcDownloader.OutputFileName;
return outFileName;
}
finally
{
DecryptCompleted?.Invoke(this, $"Completed downloading and decrypting {libraryBook.Book.Title}");
StreamingCompleted?.Invoke(this, $"Completed downloading and decrypting {libraryBook.Book.Title}");
}
}
private void AaxcDownloader_RetrievedCoverArt(object sender, byte[] e)
{
if (e is null && Configuration.Instance.AllowLibationFixup)
@@ -130,7 +147,7 @@ namespace FileLiberator
if (e is not null)
{
CoverImageFilepathDiscovered?.Invoke(this, e);
CoverImageDiscovered?.Invoke(this, e);
}
}
@@ -138,10 +155,10 @@ namespace FileLiberator
{
TitleDiscovered?.Invoke(this, e.TitleSansUnabridged);
AuthorsDiscovered?.Invoke(this, e.FirstAuthor ?? "[unknown]");
NarratorsDiscovered?.Invoke(this, e.Narrator ?? "[unknown]");
NarratorsDiscovered?.Invoke(this, e.Narrator ?? "[unknown]");
}
private static string moveFilesToBooksDir(Book product, string outputAudioFilename)
private static (string destinationDir, bool movedAudioFile) MoveFilesToBooksDir(Book product, string outputAudioFilename)
{
// create final directory. move each file into it. MOVE AUDIO FILE LAST
// new dir: safetitle_limit50char + " [" + productId + "]"
@@ -156,6 +173,7 @@ namespace FileLiberator
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
var audioFileName = FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId);
bool movedAudioFile = false;
foreach (var f in sortedFiles)
{
var dest
@@ -168,9 +186,13 @@ namespace FileLiberator
Cue.UpdateFileName(f, audioFileName);
File.Move(f.FullName, dest);
movedAudioFile |= AudibleFileStorage.Audio.IsFileTypeMatch(f);
}
return destinationDir;
AudibleFileStorage.Audio.Refresh();
return (destinationDir, movedAudioFile);
}
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
@@ -193,29 +215,28 @@ namespace FileLiberator
}
private static void validate(LibraryBook libraryBook)
{
string errorString(string field)
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
{
string errorString(string field)
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
string errorTitle()
{
var title
= (libraryBook.Book.Title.Length > 53)
? $"{libraryBook.Book.Title.Truncate(50)}..."
: libraryBook.Book.Title;
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
return errorBookTitle;
};
string errorTitle()
{
var title
= (libraryBook.Book.Title.Length > 53)
? $"{libraryBook.Book.Title.Truncate(50)}..."
: libraryBook.Book.Title;
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
return errorBookTitle;
};
if (string.IsNullOrWhiteSpace(libraryBook.Account))
throw new Exception(errorString("Account"));
if (string.IsNullOrWhiteSpace(libraryBook.Account))
throw new Exception(errorString("Account"));
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
throw new Exception(errorString("Locale"));
}
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
throw new Exception(errorString("Locale"));
}
public bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
public bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
public void Cancel()
{

View File

@@ -5,22 +5,28 @@ using Dinah.Core.Net.Http;
namespace FileLiberator
{
// frustratingly copy pasta from DownloadableBase and DownloadPdf
// currently only used to download the .zip flies for upgrade
public class DownloadFile : IDownloadable
public class DownloadFile : IStreamable
{
public event EventHandler<string> DownloadBegin;
public event EventHandler<DownloadProgress> DownloadProgressChanged;
public event EventHandler<string> DownloadCompleted;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public DownloadFile()
{
StreamingBegin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = e });
StreamingCompleted += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = e });
}
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
{
var client = new HttpClient();
var progress = new Progress<DownloadProgress>();
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
progress.ProgressChanged += (_, e) => StreamingProgressChanged?.Invoke(this, e);
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
StreamingBegin?.Invoke(this, proposedDownloadFilePath);
try
{
@@ -29,7 +35,7 @@ namespace FileLiberator
}
finally
{
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
StreamingCompleted?.Invoke(this, proposedDownloadFilePath);
}
}
}

View File

@@ -1,4 +1,6 @@
using System.IO;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
@@ -13,19 +15,27 @@ namespace FileLiberator
{
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId);
&& !libraryBook.Book.PDF_Exists;
public DownloadPdf()
{
StreamingBegin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingBegin), Message = e });
StreamingCompleted += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(StreamingCompleted), Message = e });
Begin += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = e.LogFriendly() });
Completed += (o, e) => Serilog.Log.Logger.Information("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = e.LogFriendly() });
}
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
return verifyDownload(libraryBook);
}
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId)
? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler();
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
return result;
}
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
{
@@ -37,25 +47,32 @@ namespace FileLiberator
return Path.Combine(existingPath, Path.GetFileName(file));
var full = FileUtility.GetValidFilename(
AudibleFileStorage.PDF.StorageDirectory,
AudibleFileStorage.PdfStorageDirectory,
libraryBook.Book.Title,
Path.GetExtension(file),
libraryBook.Book.AudibleProductId);
return full;
}
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
{
var api = await GetApiAsync(libraryBook);
var api = await libraryBook.GetApiAsync();
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var client = new HttpClient();
var actualDownloadedFilePath = await PerformDownloadAsync(
proposedDownloadFilePath,
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
return actualDownloadedFilePath;
}
private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
private static StatusHandler verifyDownload(string actualDownloadedFilePath)
=> !File.Exists(actualDownloadedFilePath)
? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler();
}
}

View File

@@ -6,16 +6,18 @@ using Dinah.Core.Net.Http;
namespace FileLiberator
{
public abstract class DownloadableBase : IDownloadableProcessable
public abstract class DownloadableBase : IProcessable
{
public event EventHandler<LibraryBook> Begin;
public event EventHandler<LibraryBook> Completed;
public event EventHandler<string> DownloadBegin;
public event EventHandler<DownloadProgress> DownloadProgressChanged;
public event EventHandler<string> DownloadCompleted;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<string> StatusUpdate;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
public abstract bool Validate(LibraryBook libraryBook);
@@ -38,15 +40,12 @@ namespace FileLiberator
}
}
protected static Task<AudibleApi.Api> GetApiAsync(LibraryBook libraryBook)
=> InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
{
var progress = new Progress<DownloadProgress>();
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
progress.ProgressChanged += (_, e) => StreamingProgressChanged?.Invoke(this, e);
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
StreamingBegin?.Invoke(this, proposedDownloadFilePath);
try
{
@@ -57,7 +56,7 @@ namespace FileLiberator
}
finally
{
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
StreamingCompleted?.Invoke(this, proposedDownloadFilePath);
}
}
}

View File

@@ -5,9 +5,7 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />

View File

@@ -0,0 +1,14 @@
using System;
namespace FileLiberator
{
public interface IAudioDecodable : IProcessable
{
event EventHandler<Action<byte[]>> RequestCoverArt;
event EventHandler<string> TitleDiscovered;
event EventHandler<string> AuthorsDiscovered;
event EventHandler<string> NarratorsDiscovered;
event EventHandler<byte[]> CoverImageDiscovered;
void Cancel();
}
}

View File

@@ -1,20 +0,0 @@
using System;
namespace FileLiberator
{
public interface IDecryptable : IProcessable
{
event EventHandler<string> DecryptBegin;
event EventHandler<Action<byte[]>> RequestCoverArt;
event EventHandler<string> TitleDiscovered;
event EventHandler<string> AuthorsDiscovered;
event EventHandler<string> NarratorsDiscovered;
event EventHandler<byte[]> CoverImageFilepathDiscovered;
event EventHandler<int> UpdateProgress;
event EventHandler<TimeSpan> UpdateRemainingTime;
event EventHandler<string> DecryptCompleted;
void Cancel();
}
}

View File

@@ -1,12 +0,0 @@
using System;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public interface IDownloadable
{
event EventHandler<string> DownloadBegin;
event EventHandler<DownloadProgress> DownloadProgressChanged;
event EventHandler<string> DownloadCompleted;
}
}

View File

@@ -1,4 +0,0 @@
namespace FileLiberator
{
public interface IDownloadableProcessable : IDownloadable, IProcessable { }
}

View File

@@ -5,7 +5,7 @@ using Dinah.Core.ErrorHandling;
namespace FileLiberator
{
public interface IProcessable
public interface IProcessable : IStreamable
{
event EventHandler<LibraryBook> Begin;

View File

@@ -1,7 +1,7 @@
using System.Collections.Generic;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
@@ -17,32 +17,15 @@ namespace FileLiberator
// when used in foreach: stateful. deferred execution
public static IEnumerable<LibraryBook> GetValidLibraryBooks(this IProcessable processable)
=> DbContexts.GetContext()
.GetLibrary_Flat_NoTracking()
.Where(libraryBook => processable.Validate(libraryBook));
public static IEnumerable<LibraryBook> GetValidLibraryBooks(this IProcessable processable, IEnumerable<LibraryBook> library)
=> library.Where(libraryBook => processable.Validate(libraryBook));
public static LibraryBook GetSingleLibraryBook(string productId)
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook, bool validate)
{
using var context = DbContexts.GetContext();
var libraryBook = context
.Library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
return libraryBook;
}
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook)
{
if (!processable.Validate(libraryBook))
if (validate && !processable.Validate(libraryBook))
return new StatusHandler { "Validation failed" };
return await processable.ProcessBookAsync_NoValidation(libraryBook);
}
public static async Task<StatusHandler> ProcessBookAsync_NoValidation(this IProcessable processable, LibraryBook libraryBook)
{
Serilog.Log.Logger.Information("Begin " + nameof(ProcessBookAsync_NoValidation) + " {@DebugInfo}", new
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
{
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,

View File

@@ -0,0 +1,13 @@
using System;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public interface IStreamable
{
event EventHandler<string> StreamingBegin;
event EventHandler<DownloadProgress> StreamingProgressChanged;
event EventHandler<TimeSpan> StreamingTimeRemaining;
event EventHandler<string> StreamingCompleted;
}
}

View File

@@ -0,0 +1,26 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
namespace FileLiberator
{
public static class UtilityExtensions
{
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
=> (
id: libraryBook.Book.AudibleProductId,
title: libraryBook.Book.Title,
locale: libraryBook.Book.Locale,
account: libraryBook.Account.ToMask()
);
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
{
var apiExtended = await InternalUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
return apiExtended.Api;
}
}
}

View File

@@ -8,29 +8,23 @@ using Dinah.Core.Collections.Generic;
namespace FileManager
{
// could add images here, but for now images are stored in a well-known location
public enum FileType { Unknown, Audio, AAXC, PDF }
/// <summary>
/// Files are large. File contents are never read by app.
/// Paths are varied.
/// Files are written during download/decrypt/backup/liberate.
/// Paths are read at app launch and during download/decrypt/backup/liberate.
/// Many files are often looked up at once
/// </summary>
public abstract class AudibleFileStorage : Enumeration<AudibleFileStorage>
{
public abstract string[] Extensions { get; }
protected abstract string[] Extensions { get; }
public abstract string StorageDirectory { get; }
public static string DownloadsInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
public static string DecryptInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
public static string PdfStorageDirectory => BooksDirectory;
private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage();
public static bool AaxcExists(string productId) => AAXC.Exists(productId);
#region static
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static AudibleFileStorage AAXC { get; } = new AaxcFileStorage();
public static AudibleFileStorage PDF { get; } = new PdfFileStorage();
public static string DownloadsInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
public static string DecryptInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
public static string BooksDirectory
{
@@ -41,38 +35,55 @@ namespace FileManager
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
}
}
internal static BackgroundFileSystem BookDirectoryFiles { get; set; }
#endregion
#region instance
public FileType FileType => (FileType)Value;
private IEnumerable<string> extensions_noDots { get; }
protected IEnumerable<string> extensions_noDots { get; }
private string extAggr { get; }
protected AudibleFileStorage(FileType fileType) : base((int)fileType, fileType.ToString())
{
extensions_noDots = Extensions.Select(ext => ext.Trim('.')).ToList();
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
}
BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
}
/// <summary>
/// Example for full books:
/// Search recursively in _books directory. Full book exists if either are true
/// - a directory name has the product id and an audio file is immediately inside
/// - any audio filename contains the product id
/// </summary>
public bool Exists(string productId) => GetPath(productId) != null;
public string GetPath(string productId)
protected string GetFilePath(string productId)
{
var cachedFile = FilePathCache.GetPath(productId, FileType);
if (cachedFile != null)
return cachedFile;
var firstOrNull =
Directory
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, $@"{productId}.*?\.({extAggr})$", RegexOptions.IgnoreCase));
string regexPattern = $@"{productId}.*?\.({extAggr})$";
string firstOrNull;
if (StorageDirectory == BooksDirectory)
{
//If user changed the BooksDirectory, reinitialize.
if (StorageDirectory != BookDirectoryFiles.RootDirectory)
{
lock (BookDirectoryFiles)
{
if (StorageDirectory != BookDirectoryFiles.RootDirectory)
{
BookDirectoryFiles = new BackgroundFileSystem(StorageDirectory, "*.*", SearchOption.AllDirectories);
}
}
}
firstOrNull = BookDirectoryFiles.FindFile(regexPattern, RegexOptions.IgnoreCase);
}
else
{
firstOrNull =
Directory
.EnumerateFiles(StorageDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, RegexOptions.IgnoreCase));
}
if (firstOrNull is null)
return null;
@@ -80,6 +91,21 @@ namespace FileManager
FilePathCache.Upsert(productId, FileType, firstOrNull);
return firstOrNull;
}
#endregion
}
public class AudioFileStorage : AudibleFileStorage
{
protected override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public AudioFileStorage() : base(FileType.Audio) { }
public void Refresh() => BookDirectoryFiles.RefreshFiles();
public string GetDestDir(string title, string asin)
{
@@ -95,37 +121,13 @@ namespace FileManager
public bool IsFileTypeMatch(FileInfo fileInfo)
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
#endregion
}
public class AudioFileStorage : AudibleFileStorage
{
public const string SKIP_FILE_EXT = "libhack";
public override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public AudioFileStorage() : base(FileType.Audio) { }
public string CreateSkipFile(string title, string asin, string contents = null)
{
var destinationDir = GetDestDir(title, asin);
Directory.CreateDirectory(destinationDir);
var path = FileUtility.GetValidFilename(destinationDir, title, SKIP_FILE_EXT, asin);
File.WriteAllText(path, contents ?? string.Empty);
return path;
}
public string GetPath(string productId) => GetFilePath(productId);
}
public class AaxcFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "aaxc" };
protected override string[] Extensions { get; } = new[] { "aaxc" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
@@ -133,17 +135,13 @@ namespace FileManager
public override string StorageDirectory => DownloadsInProgress;
public AaxcFileStorage() : base(FileType.AAXC) { }
}
public class PdfFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "pdf", "zip" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public PdfFileStorage() : base(FileType.PDF) { }
/// <summary>
/// Example for full books:
/// Search recursively in _books directory. Full book exists if either are true
/// - a directory name has the product id and an audio file is immediately inside
/// - any audio filename contains the product id
/// </summary>
public bool Exists(string productId) => GetFilePath(productId) != null;
}
}

View File

@@ -0,0 +1,163 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace FileManager
{
/// <summary>
/// Tracks actual locations of files. This is especially useful for clicking button to navigate to the book's files.
///
/// Note: this is no longer how Libation manages "Liberated" state. That is not statefully managed in the database.
/// This paradigm is what allows users to manually choose to not download books. Also allows them to manually toggle
/// this state and download again.
/// </summary>
internal class BackgroundFileSystem
{
public string RootDirectory { get; private set; }
public string SearchPattern { get; private set; }
public SearchOption SearchOption { get; private set; }
private FileSystemWatcher fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
private Task backgroundScanner { get; set; }
private List<string> fsCache { get; } = new();
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
{
RootDirectory = rootDirectory;
SearchPattern = searchPattern;
SearchOption = searchOptions;
Init();
}
public string FindFile(string regexPattern, RegexOptions options)
{
lock (fsCache)
{
return fsCache.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, options));
}
}
public void RefreshFiles()
{
lock (fsCache)
{
fsCache.Clear();
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
}
}
private void Init()
{
Stop();
lock (fsCache)
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory);
fileSystemWatcher.Created += FileSystemWatcher_Changed;
fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
fileSystemWatcher.Error += FileSystemWatcher_Error;
fileSystemWatcher.IncludeSubdirectories = true;
fileSystemWatcher.EnableRaisingEvents = true;
backgroundScanner = new Task(BackgroundScanner);
backgroundScanner.Start();
}
private void Stop()
{
//Stop raising events
fileSystemWatcher?.Dispose();
//Calling CompleteAdding() will cause background scanner to terminate.
directoryChangesEvents?.CompleteAdding();
//Wait for background scanner to terminate before reinitializing.
backgroundScanner?.Wait();
//Dispose of directoryChangesEvents after backgroundScanner exists.
directoryChangesEvents?.Dispose();
lock (fsCache)
fsCache.Clear();
}
private void FileSystemWatcher_Error(object sender, ErrorEventArgs e)
{
Stop();
Init();
}
private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e)
{
directoryChangesEvents.Add(e);
}
#region Background Thread
private void BackgroundScanner()
{
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
{
lock (fsCache)
UpdateLocalCache(change);
}
}
private void UpdateLocalCache(FileSystemEventArgs change)
{
if (change.ChangeType == WatcherChangeTypes.Deleted)
{
RemovePath(change.FullPath);
}
else if (change.ChangeType == WatcherChangeTypes.Created)
{
AddPath(change.FullPath);
}
else if (change.ChangeType == WatcherChangeTypes.Renamed && change is RenamedEventArgs renameChange)
{
RemovePath(renameChange.OldFullPath);
AddPath(renameChange.FullPath);
}
}
private void RemovePath(string path)
{
var pathsToRemove = fsCache.Where(p => p.StartsWith(path)).ToArray();
foreach (var p in pathsToRemove)
fsCache.Remove(p);
}
private void AddPath(string path)
{
if (File.GetAttributes(path).HasFlag(FileAttributes.Directory))
AddUniqueFiles(Directory.EnumerateFiles(path, SearchPattern, SearchOption));
else
AddUniqueFile(path);
}
private void AddUniqueFiles(IEnumerable<string> newFiles)
{
foreach (var file in newFiles)
{
AddUniqueFile(file);
}
}
private void AddUniqueFile(string newFile)
{
if (!fsCache.Contains(newFile))
fsCache.Add(newFile);
}
#endregion
~BackgroundFileSystem() => Stop();
}
}

View File

@@ -46,9 +46,17 @@ namespace FileManager
private PersistentDictionary persistentDictionary;
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false) => persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
@@ -63,7 +71,7 @@ namespace FileManager
return attribute?.Description;
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books
@@ -94,6 +102,29 @@ namespace FileManager
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
}
public enum BadBookAction
{
[Description("Ask each time what action to take.")]
Ask = 0,
[Description("Stop processing books.")]
Abort = 1,
[Description("Retry book later. Skip for now. Continue processing books.")]
Retry = 2,
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
Ignore = 3
}
[Description("When liberating books and there is an error, Libation should:")]
public BadBookAction BadBook
{
get
{
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
}
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
}
#endregion
#region known directories
@@ -154,13 +185,9 @@ namespace FileManager
#region logging
private IConfigurationRoot configuration;
public void ConfigureLogging()
{
//// with code. also persists to Settings.json
//SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
//// hack which achieves the same, in memory only
//configuration["Serilog:WriteTo:1:Args:path"] = logPath;
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
@@ -174,20 +201,17 @@ namespace FileManager
{
get
{
try
{
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
var logLevelEnum = Enum<LogEventLevel>.Parse(logLevelStr);
return logLevelEnum;
}
catch
{
return LogEventLevel.Information;
}
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
}
set
{
persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Information("LogLevel.set attempt. No change");
return;
}
configuration.Reload();
@@ -207,11 +231,10 @@ namespace FileManager
#region singleton stuff
public static Configuration Instance { get; } = new Configuration();
private Configuration() { }
#endregion
#endregion
#region LibationFiles
private const string APPSETTINGS_JSON = "appsettings.json";
#region LibationFiles
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
private const string LIBATION_FILES_KEY = "LibationFiles";
[Description("Location for storage of program-created files")]
@@ -228,11 +251,16 @@ namespace FileManager
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
// Config init in Program.ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
var logPath = Path.Combine(LibationFiles, "Log.log");
SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
configuration?.Reload();
// BAD: Serilog.WriteTo[1].Args
// "[1]" assumes ordinal position
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
SetWithJsonPath(jsonpath, "path", logPath, true);
return libationFilesPathCache;
}
@@ -275,19 +303,8 @@ namespace FileManager
return valueFinal;
}
public bool TrySetLibationFiles(string directory)
public void SetLibationFiles(string directory)
{
// this is WRONG. need to MOVE settings; not DELETE them
//// if moving from default, delete old settings file and dir (if empty)
//if (LibationFiles.EqualsInsensitive(AppDir))
//{
// File.Delete(SettingsFilePath);
// System.Threading.Thread.Sleep(100);
// if (!Directory.EnumerateDirectories(AppDir).Any() && !Directory.EnumerateFiles(AppDir).Any())
// Directory.Delete(AppDir);
//}
libationFilesPathCache = null;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
@@ -297,7 +314,7 @@ namespace FileManager
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return true;
return;
// now it's set in the file again but no settings have moved yet
File.WriteAllText(APPSETTINGS_JSON, endingContents);
@@ -307,13 +324,6 @@ namespace FileManager
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
}
catch { }
//// attempting this will try to change the settings file which has not yet been moved
//// after this is fixed, can remove it from Program.configureLogging()
// var logPath = Path.Combine(LibationFiles, "Log.log");
// SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
return true;
}
#endregion
}

View File

@@ -5,13 +5,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="1.1.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" />
<PackageReference Include="Octokit" Version="0.50.0" />
<PackageReference Include="Polly" Version="7.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
</ItemGroup>
</Project>

View File

@@ -9,6 +9,7 @@ namespace FileManager
{
public static class FilePathCache
{
private const string FILENAME = "FileLocations.json";
internal class CacheEntry
{
public string Id { get; set; }
@@ -16,16 +17,16 @@ namespace FileManager
public string Path { get; set; }
}
static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
public static string JsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
static FilePathCache()
{
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (File.Exists(JsonFile))
if (File.Exists(jsonFile))
{
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(JsonFile));
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
cache = new Cache<CacheEntry>(list);
}
}
@@ -74,7 +75,7 @@ namespace FileManager
private static void save()
{
// create json if not exists
static void resave() => File.WriteAllText(JsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
lock (locker)
{
@@ -84,7 +85,7 @@ namespace FileManager
try { resave(); }
catch (IOException ex)
{
Serilog.Log.Logger.Error(ex, "Error saving FilePaths.json");
Serilog.Log.Logger.Error(ex, $"Error saving {FILENAME}");
throw;
}
}

View File

@@ -51,7 +51,8 @@ namespace FileManager
{
var obj = GetObject(propertyName);
if (obj is null) return default;
if (obj is JToken jToken) return jToken.Value<T>();
if (obj is JValue jValue) return jValue.Value<T>();
if (obj is JObject jObject) return jObject.ToObject<T>();
return (T)obj;
}
@@ -142,33 +143,44 @@ namespace FileManager
catch { }
}
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
/// <returns>Value was changed</returns>
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
{
if (IsReadOnly)
return;
return false;
var path = $"{jsonPath}.{propertyName}";
{
// only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(path) && stringCache[path] == newValue)
return;
return false;
// set cache
stringCache[path] = newValue;
}
lock (locker)
try
{
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
var oldValue = token.Value<string>(propertyName);
lock (locker)
{
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
if (token is null || token[propertyName] is null)
return false;
if (oldValue == newValue)
return;
var oldValue = token.Value<string>(propertyName);
if (oldValue == newValue)
return false;
token[propertyName] = newValue;
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
token[propertyName] = newValue;
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
}
}
catch (Exception exDebug)
{
return false;
}
if (!suppressLogging)
@@ -180,6 +192,8 @@ namespace FileManager
}
catch { }
}
return true;
}
private static string formatValueForLog(string value)

View File

@@ -1,12 +1,19 @@
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
namespace FileManager
{
public enum PictureSize { _80x80, _300x300, _500x500 }
public enum PictureSize { _80x80 = 80, _300x300 = 300, _500x500 = 500 }
public class PictureCachedEventArgs : EventArgs
{
public PictureDefinition Definition { get; internal set; }
public byte[] Picture { get; internal set; }
}
public struct PictureDefinition
{
public string PictureId { get; }
@@ -27,34 +34,60 @@ namespace FileManager
private static string getPath(PictureDefinition def)
=> Path.Combine(ImagesDirectory, $"{def.PictureId}{def.Size}.jpg");
private static System.Timers.Timer timer { get; }
static PictureStorage()
{
timer = new System.Timers.Timer(700)
{
AutoReset = true,
Enabled = true
};
timer.Elapsed += (_, __) => timerDownload();
new Task(BackgroundDownloader, TaskCreationOptions.LongRunning)
.Start();
}
public static event EventHandler<string> PictureCached;
public static event EventHandler<PictureCachedEventArgs> PictureCached;
private static BlockingCollection<PictureDefinition> DownloadQueue { get; } = new BlockingCollection<PictureDefinition>();
private static Dictionary<PictureDefinition, byte[]> cache { get; } = new Dictionary<PictureDefinition, byte[]>();
private static Dictionary<PictureSize, byte[]> defaultImages { get; } = new Dictionary<PictureSize, byte[]>();
public static (bool isDefault, byte[] bytes) GetPicture(PictureDefinition def)
{
if (!cache.ContainsKey(def))
lock (cache)
{
if (cache.ContainsKey(def))
return (false, cache[def]);
var path = getPath(def);
cache[def]
= File.Exists(path)
? File.ReadAllBytes(path)
: null;
if (File.Exists(path))
{
cache[def] = File.ReadAllBytes(path);
return (false, cache[def]);
}
DownloadQueue.Add(def);
return (true, getDefaultImage(def.Size));
}
}
public static byte[] GetPictureSynchronously(PictureDefinition def)
{
lock (cache)
{
if (!cache.ContainsKey(def) || cache[def] == null)
{
var path = getPath(def);
byte[] bytes;
if (File.Exists(path))
bytes = File.ReadAllBytes(path);
else
{
bytes = downloadBytes(def);
saveFile(def, bytes);
}
cache[def] = bytes;
}
return cache[def];
}
return (cache[def] == null, cache[def] ?? getDefaultImage(def.Size));
}
private static Dictionary<PictureSize, byte[]> defaultImages { get; } = new Dictionary<PictureSize, byte[]>();
public static void SetDefaultImage(PictureSize pictureSize, byte[] bytes)
=> defaultImages[pictureSize] = bytes;
private static byte[] getDefaultImage(PictureSize size)
@@ -62,45 +95,26 @@ namespace FileManager
? defaultImages[size]
: new byte[0];
// necessary to avoid IO errors. ReadAllBytes and WriteAllBytes can conflict in some cases, esp when debugging
private static bool isProcessing;
private static void timerDownload()
static void BackgroundDownloader()
{
// must live outside try-catch, else 'finally' can reset another thread's lock
if (isProcessing)
return;
try
while (!DownloadQueue.IsCompleted)
{
isProcessing = true;
var def = cache
.Where(kvp => kvp.Value is null)
.Select(kvp => kvp.Key)
// 80x80 should be 1st since it's enum value == 0
.OrderBy(d => d.PictureId)
.FirstOrDefault();
// no more null entries. all requsted images are cached
if (string.IsNullOrWhiteSpace(def.PictureId))
return;
if (!DownloadQueue.TryTake(out var def, System.Threading.Timeout.InfiniteTimeSpan))
continue;
var bytes = downloadBytes(def);
saveFile(def, bytes);
cache[def] = bytes;
lock (cache)
cache[def] = bytes;
PictureCached?.Invoke(nameof(PictureStorage), def.PictureId);
}
finally
{
isProcessing = false;
PictureCached?.Invoke(nameof(PictureStorage), new PictureCachedEventArgs { Definition = def, Picture = bytes });
}
}
private static HttpClient imageDownloadClient { get; } = new HttpClient();
private static byte[] downloadBytes(PictureDefinition def)
{
var sz = def.Size.ToString().Split('x')[1];
var sz = (int)def.Size;
return imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}._SL{sz}_.jpg").Result;
}
@@ -110,4 +124,4 @@ namespace FileManager
File.WriteAllBytes(path, bytes);
}
}
}
}

View File

@@ -31,6 +31,9 @@ namespace FileManager
{
ensureCache();
if (!tagsCollection.Any())
return;
// on initial reload, there's a huge benefit to adding to cache individually then updating the file only once
foreach ((string productId, string tags) in tagsCollection)
cache[productId] = tags;

7
Hoopla/Hoopla.csproj Normal file
View File

@@ -0,0 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
</Project>

View File

@@ -0,0 +1,847 @@
<#
.SYNOPSIS
Downloads, decrypts and repackages content from Hoopla
.DESCRIPTION
Uses a HooplaDigital.com account to download DRM-free copies of ebooks, comics,
and/or audiobooks available on the platform. Content that is not already borrowed
on the account will be borrowed if slots are available. Content that is not borrowed
cannot be downloaded.
* E-Books are downloaded to epub files (most) or cbz (rare, picture books).
* Comic books are downloaded to cbz files.
* Audiobooks are downloaded to m4a files. (single file, and very little metadata available
from Hoopla, such as chapters)
.PARAMETER Credential
Credential to use for logging into Hoopla site.
(Cannot be used with Username and Password parameters)
.PARAMETER Username
Username to use for logging into Hoopla site.
(Cannot be used with Credential parameter)
.PARAMETER Password
Password to use for logging into Hoopla site.
(Cannot be used with Credential parameter)
.PARAMETER TitleId
Specifies one or more title IDs of content to download.
.PARAMETER OutputFolder
Sets the output folder for downloaded content. Defaults to current directory.
.PARAMETER PatronId
Override default patron id for Hoopla. (This is rarely required as most user accounts are only tied
to a single patron).
.PARAMETER EpubZipBin
Specifies path to epubzip binary. Else look for one beside script, or in system path.
.PARAMETER FfmpegBin
Specifies path to ffmpeg binary. Else look for one beside script, or in system path.
.PARAMETER KeepDecryptedData
If set, don't delete the intermediary data after decryption, before final output file.
For ebooks, this is xml, images, and the manifest. For comics, it is images. For audiobooks,
it is mp4 ts files. This is typically only useful for development or troubleshooting.
.PARAMETER KeepEncryptedData
If set, don't delete the encrypted data as downloaded from Hoopla's servers. This is typically
only useful for development or troubleshooting.
.PARAMETER AllBorrowed
This parameter is deprecated. If TitleId is not set, it is implied that all borrowed titles will
be downloaded.
.PARAMETER AudioBookForceSingleFile
If set, leave audiobook as single file, as if chapter data is not present.
.EXAMPLE
.\Invoke-HooplaDownload.ps1 123456
Downloads Hoopla content with title id 123456
.NOTES
Author: kabutops728 - My Anonamouse
Version: 2.9
#>
[CmdletBinding(DefaultParameterSetName='CredentialSingleTitle')]
param(
[int64[]]
$TitleId,
[Parameter(Mandatory,ParameterSetName='CredentialSingleTitle')]
[Management.Automation.PSCredential]
$Credential,
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
[string]
$Username,
[Parameter(Mandatory,ParameterSetName='UserPassSingleTitle')]
[string]
$Password,
[ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Container})]
[string]$OutputFolder = $PSScriptRoot,
[int64]$PatronId,
[string]$EpubZipBin,
[string]$FfmpegBin,
[switch]$KeepDecryptedData,
[switch]$KeepEncryptedData,
[switch]$AudioBookForceSingleFile,
# Deprecated
[switch]$AllBorrowed
)
$USER_AGENT = 'Hoopla Android/4.27'
$HEADERS = @{
'app' = 'ANDROID'
'app-version' = '4.27.1'
'device-module' = 'KFKAWI'
'device-version' = ''
'hoopla-verson' = '4.27.1'
'kids-mode' = 'false'
'os' = 'ANDROID'
'os-version' = '6.0.1'
'ws-api' = '2.1'
'Host' = 'hoopla-ws.hoopladigital.com'
}
$URL_HOOPLA_WS_BASE = 'https://hoopla-ws.hoopladigital.com'
$URL_HOOPLA_LIC_BASE = 'https://hoopla-license2.hoopladigital.com'
$COMIC_IMAGE_EXTS = @('.jpg','.png','.jpeg','.gif','.bmp','.tif','.tiff')
enum HooplaKind
{
EBOOK = 5
MUSIC = 6
MOVIE = 7
AUDIOBOOK = 8
TELEVISION = 9
COMIC = 10
}
$SUPPORTED_KINDS = @([HooplaKind]::EBOOK, [HooplaKind]::COMIC, [HooplaKind]::AUDIOBOOK)
Function Connect-Hoopla
{
param(
[Parameter(Mandatory)][Management.Automation.PSCredential]$Credential
)
$username = $Credential.UserName
$password = $Credential.GetNetworkCredential().Password
$res = Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/tokens" -Method Post -Headers $HEADERS -UserAgent $USER_AGENT -Body @{username = $username; password = $password}
if ($res.tokenStatus -ne 'SUCCESS')
{
throw $res.message
}
$res.token
}
Function Get-HooplaUsers
{
param(
[Parameter(Mandatory)][string]$Token
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-HooplaTitleInfo
{
param(
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$TitleId
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/v2/titles/$TitleId" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-HooplaBorrowsRemaining
{
param(
[Parameter(Mandatory)][string]$UserId,
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrows-remaining" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-HooplaBorrowedTitles
{
param(
[Parameter(Mandatory)][string]$UserId,
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/borrowed-titles" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Invoke-HooplaBorrow
{
param(
[Parameter(Mandatory)][string]$UserId,
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$TitleId
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_WS_BASE/users/$UserId/patrons/$PatronId/borrowed-titles/$TitleId" -Method Post -Headers $h -UserAgent $USER_AGENT
}
Function Invoke-HooplaZipDownload
{
param(
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$CircId,
[Parameter(Mandatory)][ValidateScript({Test-Path -LiteralPath $_ -IsValid -PathType Leaf})][string]$OutFile
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
$res = Invoke-WebRequest -Uri "$URL_HOOPLA_WS_BASE/patrons/downloads/$CircId/url" -Method Get -Headers $h -UserAgent $USER_AGENT -UseBasicParsing
if ($PSVersionTable.PSVersion.Major -ge 6)
{
Invoke-WebRequest -Uri $res.Headers['Location'][0] -Method Get -UseBasicParsing -OutFile $OutFile
}
else
{
Invoke-WebRequest -Uri $res.Headers['Location'] -Method Get -UseBasicParsing -OutFile $OutFile
}
}
Function Get-HooplaKey
{
param(
[Parameter(Mandatory)][int64]$PatronId,
[Parameter(Mandatory)][string]$Token,
[Parameter(Mandatory)][int64]$CircId
)
$h = $HEADERS.Clone()
$h['Authorization'] = "Bearer $Token"
$h['patron-id'] = $PatronId
Invoke-RestMethod -Uri "$URL_HOOPLA_LIC_BASE/downloads/$CircId/key" -Method Get -Headers $h -UserAgent $USER_AGENT
}
Function Get-FileKeyKey
{
param(
[Parameter(Mandatory)][int64]$CircId,
[Parameter(Mandatory)][DateTime]$Due,
[Parameter(Mandatory)][int64]$PatronId
)
$combined = '{0:yyyyMMddHHmmss}:{1}:{2}' -f $Due, $PatronId, $CircId
[Security.Cryptography.HashAlgorithm]::Create('SHA1').ComputeHash([Text.Encoding]::UTF8.GetBytes($combined)) | Select-Object -First 16
}
Function Decrypt-FileKey
{
param(
[Parameter(Mandatory)][byte[]]$FileKeyEnc,
[Parameter(Mandatory)][byte[]]$FileKeyKey
)
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
$aesManaged.Mode = [Security.Cryptography.CipherMode]::ECB
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
$aesManaged.BlockSize = 128
$aesManaged.KeySize = 128
$aesManaged.Key = $FileKeyKey
$decryptor = $aesManaged.CreateDecryptor();
$unencryptedData = $decryptor.TransformFinalBlock($FileKeyEnc, 0, $FileKeyEnc.Length);
$aesManaged.Dispose()
$unencryptedData
}
Function Decrypt-File
{
param(
[Parameter(Mandatory)][byte[]]$FileKey,
[Parameter(Mandatory)][string]$MediaKey,
[Parameter(Mandatory)][string]$InputFileName,
[Parameter(Mandatory)][string]$OutputFileName
)
$aesManaged = New-Object "System.Security.Cryptography.AesManaged"
$aesManaged.Mode = [Security.Cryptography.CipherMode]::CBC
$aesManaged.Padding = [Security.Cryptography.PaddingMode]::PKCS7
$aesManaged.BlockSize = 128
$aesManaged.KeySize = 256
$aesManaged.Key = $FileKey
$aesManaged.IV = [Text.Encoding]::UTF8.GetBytes($MediaKey) | Select-Object -First 16
$fileStreamReader = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $InputFileName, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
$fileStreamWriter = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $OutputFileName, ([IO.FileMode]::Create)
$FileStreamReader.Seek(0, [IO.SeekOrigin]::Begin) | Out-Null
$decryptor = $aesManaged.CreateDecryptor()
$cryptoStream = New-Object -TypeName 'System.Security.Cryptography.CryptoStream' -ArgumentList $fileStreamWriter, $decryptor, ([Security.Cryptography.CryptoStreamMode]::Write)
$fileStreamReader.CopyTo($cryptoStream)
$cryptoStream.FlushFinalBlock()
$cryptoStream.Close()
$fileStreamReader.Close()
$fileStreamWriter.Close()
$aesManaged.Dispose()
}
Function Test-Mp4
{
param(
[Parameter(Mandatory, Position=0)]
[Alias('LiteralPath')]
[string]$Path
)
$fileStream = New-Object -TypeName 'System.IO.FileStream' -ArgumentList $Path, ([IO.FileMode]::Open), ([IO.FileShare]::Read)
$fileReader = New-Object -TypeName 'System.IO.BinaryReader' -ArgumentList $fileStream -ErrorAction Stop
$head = $fileReader.ReadBytes(8)
$fileReader.Dispose()
$fileStream.Dispose()
return [Text.Encoding]::ASCII.GetString(($head | Select-Object -Skip 4)) -eq 'ftyp'
}
Function Remove-InvalidFileNameChars
{
param(
[Parameter(Mandatory,Position=0,
ValueFromPipeline=$true,
ValueFromPipelineByPropertyName=$true)]
[String]$Name
)
$invalidChars = [IO.Path]::GetInvalidFileNameChars() -join ''
$re = "[{0}]" -f [RegEx]::Escape($invalidChars)
$Name -replace $re, '_'
}
Function Convert-HooplaDecryptedToEpub
{
param(
[Parameter(Mandatory)][string]$InputFolder,
[Parameter(Mandatory)][string]$OutFolder
)
$container = [xml](Get-Content -LiteralPath (Join-Path -Path $InputFolder -ChildPath 'META-INF\container.xml') -Raw)
$rootFile = $container.container.rootfiles.rootfile | Select-Object -ExpandProperty Full-Path
$contentFile = (Join-Path -Path $InputFolder -ChildPath $rootFile).Trim()
$contentRoot = Get-Item -LiteralPath $contentFile | Select-Object -ExpandProperty Directory
$content = [xml](Get-Content -LiteralPath $contentFile)
$fileList = $content.package.manifest.item | Select-Object -ExpandProperty href | ForEach-Object -Process { (Join-Path -Path $contentRoot -ChildPath ([Web.HttpUtility]::UrlDecode($_))).Trim() }
$fileList += $contentFile
$fileList = $fileList | Sort-Object -Unique
$title = $content.package.metadata.title | Select-Object -First 1
if ($title.GetType() -ne [String])
{
$title = $content.package.metadata.title | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
}
$author = $content.package.metadata.creator | Select-Object -First 1
if ($author.GetType() -ne [String])
{
$author = $content.package.metadata.creator | Select-Object -First 1 | Select-Object -ExpandProperty '#text'
}
# Usually, content root is a subfolder of the input folder. But sometimes, they are the same. Make sure we declutter the input root if they differ, and always keep the mimetype file.
$mimeTypeFile = Join-Path -Path $InputFolder -ChildPath 'mimetype'
$extra = @(Get-ChildItem -LiteralPath $contentRoot -File -Recurse | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) })
$extra += Get-ChildItem -LiteralPath $InputFolder -File | Where-Object -FilterScript { ($_.FullName -notin $fileList) -and ($_.FullName -ne $mimeTypeFile) }
$extra = $extra | Sort-Object -Property FullName -Unique
$extra | Remove-Item
$containerXmlFolder = Join-Path -Path $contentRoot.FullName -ChildPath 'META-INF'
$containerXmlPath = Join-Path -Path $containerXmlFolder -ChildPath 'container.xml'
if (!(Test-Path -LiteralPath $containerXmlPath -PathType Leaf))
{
New-Item -Path $containerXmlFolder -ItemType Directory -Force | Out-Null
$xml = @"
<?xml version="1.0"?>
<container version="1.0" xmlns="urn:oasis:names:tc:opendocument:xmlns:container">
<rootfiles>
<rootfile full-path="content.opf" media-type="application/oebps-package+xml"/>
</rootfiles>
</container>
"@
$xml | Out-File -LiteralPath $containerXmlPath -Encoding ascii
}
$finalFile = ('{0} - {1}.epub' -f $title, $author) | Remove-InvalidFileNameChars
Push-Location
Set-Location -LiteralPath $InputFolder
$finalFileFullPath = (Join-Path -Path $OutFolder -ChildPath $finalFile)
if ($VerbosePreference -eq 'Continue')
{
& $EpubZipBin $finalFileFullPath
}
else
{
& $EpubZipBin $finalFileFullPath >$null 2>&1
}
Pop-Location
Get-Item -LiteralPath $finalFileFullPath
}
Function Convert-HooplaDecryptedToCbz
{
param(
[Parameter(Mandatory)][string]$InputFolder,
[Parameter(Mandatory)][string]$OutFolder,
[Parameter(Mandatory)][string]$Name
)
$fileName = $Name | Remove-InvalidFileNameChars
$tempOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.zip"
$finalOutFile = Join-Path -Path $OutFolder -ChildPath "$fileName.cbz"
Compress-Archive -Path (
Get-ChildItem -LiteralPath $InputFolder | Where-Object -FilterScript { $_.Extension -in $COMIC_IMAGE_EXTS } | Select-Object -ExpandProperty FullName
) -CompressionLevel Fastest -DestinationPath $tempOutFile
Rename-Item -LiteralPath $tempOutFile -NewName $finalOutFile
Get-Item $finalOutFile
}
Function Convert-HooplaDecryptedToM4a
{
param(
[Parameter(Mandatory)][string]$InputFolder,
[Parameter(Mandatory)][string]$OutFolder,
[Parameter(Mandatory)][string]$Name,
[Parameter(Mandatory)][string]$Title,
[Parameter(Mandatory)][string]$Author,
[Parameter(Mandatory)][int]$Year,
[string]$Subtitle,
[object]$ChapterData
)
if ($Author)
{
$baseFileName = ('{0} - {1}' -f $Name, $Author) | Remove-InvalidFileNameChars
}
else
{
$baseFileName = $Name | Remove-InvalidFileNameChars
}
$finalOutFile = Join-Path -Path $OutFolder -ChildPath ('{0}.m4a' -f $baseFileName)
$inFile = Get-ChildItem -LiteralPath $InputFolder -Filter '*.m3u8' | Select-Object -First 1 | Select-Object -ExpandProperty FullName
Push-Location
Set-Location $InputFolder
$ffArgs = @(
'-y',
'-i', $infile,
'-metadata', ('title="{0}"' -f $Title),
'-metadata', ('year="{0}"' -f $Year),
'-metadata', ('author="{0}"' -f $Author),
'-metadata', 'genre="Audiobook"'
)
if ($Subtitle)
{
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
}
$ffArgs += @(
'-c:a', 'copy',
$finalOutFile
)
if ($VerbosePreference -eq 'Continue')
{
& $FfmpegBin @ffArgs
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile
}
else
{
& $FfmpegBin @ffArgs >$null 2>&1
#& $FfmpegBin -y -i $inFile -metadata "title=`"$Title`"" -metadata "year=`"$Year`"" -metadata "author=`"$Author`"" -metadata "genre=`"Audiobook`"" '-c:a' copy $finalOutFile >$null 2>&1
}
if ($ChapterData -and (!$AudioBookForceSingleFile))
{
$outDir = New-Item -Path (Join-Path -Path $OutFolder -ChildPath $baseFileName) -ItemType Directory
$chapterCount = $ChapterData | Select-Object -ExpandProperty chapter | Measure-Object -Maximum | Select-Object -ExpandProperty Maximum
$ChapterData | ForEach-Object -Process {
$ffArgs = @(
'-y',
'-i', $finalOutFile,
'-ss', $_.start,
'-t', $_.duration,
'-metadata', ('title="{0}"' -f $_.title),
'-metadata', ('album="{0}"' -f $Title),
'-metadata', ('year="{0}"' -f $Year),
'-metadata', ('author="{0}"' -f $Author),
'-metadata', 'genre="Audiobook"'
'-metadata', ('track={0}/{1}' -f $_.ordinal, $chapterCount)
)
if ($Subtitle)
{
$ffArgs += '-metadata', ('subtitle="{0}"' -f $Subtitle)
}
$ffArgs += @(
'-c', 'copy',
(Join-Path -Path $outDir.FullName -ChildPath ('{0} - {1} - {2}.m4a' -f $baseFileName, $_.ordinal, ($_.title | Remove-InvalidFileNameChars)))
)
if ($VerbosePreference -eq 'Continue')
{
& $FfmpegBin @ffArgs
}
else
{
& $FfmpegBin @ffArgs >$null 2>&1
}
}
Remove-Item $finalOutFile
$finalOutFile = $outDir
}
Pop-Location
Get-Item $finalOutFile
}
if (!$Credential)
{
$ssPassword = ConvertTo-SecureString $Password -AsPlainText -Force
$Credential = New-Object -TypeName 'System.Management.Automation.PSCredential' -ArgumentList $Username, $ssPassword
}
if ((!$AllBorrowed) -and ($null -eq $TitleId))
{
Write-Warning 'No -TitleId specified. All currently-borrowed titles will be downloaded.'
$AllBorrowed = $true
}
$AppExtension = ''
if (($PSVersionTable.PSVersion -lt '6.0') -or $IsWindows)
{
$AppExtension = '.exe'
}
$cmd = ''
if ($EpubZipBin)
{
$cmd = Get-Command -Name $EpubZipBin -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "Epubzip binary specified was not found ($EpubZipBin). Will try to use alternate version if available."
}
}
if (!$cmd)
{
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "epubzip$AppExtension") -ErrorAction SilentlyContinue
if (!$cmd)
{
$cmd = Get-Command -Name "epubzip$AppExtension" -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "Epubzip binary not found ($EpubZipBin). If you are downloading ebooks (rather than comics or audiobooks), you may wish to download the binary from https://github.com/dino-/epub-tools/releases, specify a different path with -EpubZipBin, or specify -KeepDecryptedData so that you can manually pack afterward."
}
}
$EpubZipBin = $cmd.Source
}
Write-Verbose ('Using epubzip bin: "{0}"' -f $EpubZipBin)
$cmd = ''
if ($FfmpegBin)
{
$cmd = Get-Command -Name $FfmpegBin -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "FFMpeg binary specified was not found ($FfmpegBin). Will try to use alternate version if available."
}
}
if (!$cmd)
{
$cmd = Get-Command -Name (Join-Path -Path $PSScriptRoot -ChildPath "ffmpeg$AppExtension") -ErrorAction SilentlyContinue
if (!$cmd)
{
$cmd = Get-Command -Name "ffmpeg$AppExtension" -ErrorAction SilentlyContinue
if (!$cmd)
{
Write-Warning "FFmpeg binary not found. If you are downloading audiobooks (rather than ebooks or comics), you may wish to download the binary from https://ffmpeg.zeranoe.com/builds/, specify a different path with -FfmpegBin, or specify -KeepDecryptedData so that you can manually convert afterward."
}
}
$FfmpegBin = $cmd.Source
}
Write-Verbose ('Using ffpmeg bin: "{0}"' -f $FfmpegBin)
if (!(Test-Path -LiteralPath $OutputFolder -PathType Container))
{
Write-Warning "Output folder doesn't exist. Creating."
New-Item -Path $OutputFolder -ItemType Directory | Out-Null
}
$OutputFolder = Get-Item -LiteralPath $OutputFolder | Select-Object -ExpandProperty $_.FullName
$token = Connect-Hoopla -Credential $Credential
Write-Verbose "Logged in. Received token $($token -replace '\-.*', '-****-****-****-************')"
$users = Get-HooplaUsers $token
Write-Verbose "Found $($users.patrons.Count) patrons"
$userId = $users.id
if (!$PatronId)
{
if ($users.patrons.Count -eq 0)
{
throw "No patrons found on account. Account may not be correctly set up with library."
}
elseif ($users.patrons.Count -gt 1)
{
Write-Warning (
"Multiple patrons found on account. Using first one, {0} ({1}). You can specify -PatronId to override" -f $users.patrons[0].id, $users.patrons[0].libraryName
)
}
$PatronId = $users.patrons[0].id
Write-Verbose "Using PatronId $PatronId"
}
$borrowedRaw = Get-HooplaBorrowedTitles -Token $token -UserId $userId -PatronId $PatronId
$borrowed = $borrowedRaw | Where-Object -FilterScript { $_.kind.id -in $SUPPORTED_KINDS }
Write-Verbose "Found $($borrowed.Count) ($($borrowedRaw.Count)) titles already borrowed"
$toDownload = @()
if ($AllBorrowed)
{
$toDownload = $borrowed
}
else
{
$toDownload = $borrowed | Where-Object -FilterScript { $_.id -in $TitleId }
$allBorrowedTitles = $borrowed | Select-Object -ExpandProperty id
$toBorrow = $TitleId | Where-Object -FilterScript { $_ -notin $allBorrowedTitles }
if ($toBorrow)
{
$borrowsRemainingData = Get-HooplaBorrowsRemaining -UserId $userId -PatronId $PatronId -Token $token
Write-Host $borrowsRemainingData.borrowsRemainingMessage
$borrowsRemaining = $borrowsRemainingData.borrowsRemaining
$toBorrow | ForEach-Object -Process {
Write-Host "Title $_ is not already borrowed or is not a supported kind. Looking up data about it."
$titleInfo = Get-HooplaTitleInfo -PatronId $PatronId -Token $token -TitleId $_
if ($titleInfo.kind.id -in $SUPPORTED_KINDS)
{
if ((--$borrowsRemaining) -le 0)
{
Write-Warning "Title $_ ($($titleInfo.Title)) not borrowed already, but we're out of remaining borrows allowed. Skipping..."
}
else
{
Write-Host "Borrowing title $_ ($($titleInfo.Title))..."
$res = Invoke-HooplaBorrow -UserId $userId -PatronId $PatronId -Token $token -TitleId $titleInfo.id
Write-Host "Response: $($res.message)"
$newToDownload = $res.titles | Where-Object -FilterScript { $_.id -eq $titleInfo.id }
if ($newToDownload)
{
$toDownload += $newToDownload
}
else
{
Write-Warning "Failed to borrow title $_ ($($titleInfo.Title))..."
}
}
}
else
{
Write-Warning "Title $_ is not a supported kind ($($titleInfo.kind.name)). Skipping..."
}
}
}
}
$tempFolder = [IO.Path]::GetTempPath()
$now = Get-Date
$toDownload | ForEach-Object -Process {
$info = $_
$contentKind = [HooplaKind]$_.kind.id
if ($_.contents.mediaType)
{
$contentKind = [HooplaKind]$_.contents.mediaType
}
$contents = $info.contents
$circId = $contents.circId
$mediaKey = $contents.mediaKey
$dueUnix = [Math]::Truncate($info.contents.due / 1000)
$due = (New-Object DateTime 1970, 1, 1, 0, 0, 0, ([DateTimeKind]::Utc)).AddSeconds($dueUnix)
$circFileName = (Join-Path -Path $tempFolder -ChildPath "$($circId).zip")
Invoke-HooplaZipDownload -PatronId $patronId -Token $token -CircId $circId -OutFile $circFileName
$keyData = Get-HooplaKey -PatronId $patronId -Token $token -CircId $circId
$fileKeyKey = Get-FileKeyKey -CircId $circId -Due $due -PatronId $patronId
$fileKey = Decrypt-FileKey -FileKeyEnc ([Convert]::FromBase64String($keyData."$mediaKey")) -FileKeyKey $fileKeyKey
$encDir = Join-Path -Path $tempFolder -ChildPath ('enc-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
New-Item -Path $encDir -ItemType Directory | Out-Null
Expand-Archive -LiteralPath $circFileName -DestinationPath $encDir
Remove-Item -LiteralPath $circFileName
$decDir = Join-Path -Path $tempFolder -ChildPath ('dec-{0}-{1:yyyyMMddHHmmss}' -f $circId, $now)
New-Item -Path $decDir -ItemType Directory | Out-Null
$activity = 'Decrypting Content ({0})' -f $_.title
Write-Progress -Activity $activity -PercentComplete 0
$zipFiles = Get-ChildItem $encDir -Recurse -File
$decDone = 0
$decTotal = $zipFiles.Count
$zipFiles | ForEach-Object -Process {
$outFile = $_.FullName.Replace($encDir, $decDir)
$outDir = $_.DirectoryName.Replace($encDir, $decDir)
if (!(Test-Path -LiteralPath $outDir))
{
New-Item -Path $outDir -ItemType Directory -Force | Out-Null
}
if (($contentKind -eq [HooplaKind]::AUDIOBOOK) -and ($_.Extension -eq '.m3u8'))
{
$lines = Get-Content -LiteralPath $_.FullName | Where-Object -FilterScript {$_ -notmatch '^#EXT-X-KEY'}
# Out-File doesn't support utf8 w/o BOM
[IO.File]::WriteAllLines($outFile, $lines)
return
}
if ($_.Length)
{
# Hack. Some ebooks contain audio files that download as unencrypted
if (($_.Extension -eq '.m4a') -and (Test-Mp4 -LiteralPath $_.FullName))
{
Write-Verbose -Message ('Coping unencrypted {0}' -f $_.FullName)
Copy-Item -LiteralPath $_.FullName -Destination $outFile
}
else
{
Write-Verbose -Message ('Decrypting {0}' -f $_.FullName)
Decrypt-File -FileKey $fileKey -MediaKey $mediaKey -InputFileName $_.FullName -OutputFileName $outFile
}
}
else
{
Write-Verbose -Message ('Writing empty file {0}' -f $_.FullName)
'' | Out-File -LiteralPath $outFile
}
Write-Progress -Activity $activity -PercentComplete ((++$decDone) / $decTotal * 100)
}
Write-Progress -Activity $activity -Completed
switch ($contentKind)
{
([HooplaKind]::EBOOK) {
Convert-HooplaDecryptedToEpub -InputFolder $decDir -OutFolder $OutputFolder
}
([HooplaKind]::COMIC) {
$title = $contents.title
$subtitle = $contents.subtitle
$name = $title
if ($subtitle) {
$name += ", $subtitle"
}
Convert-HooplaDecryptedToCbz -InputFolder $decDir -OutFolder $OutputFolder -Name $name
}
([HooplaKind]::AUDIOBOOK) {
Convert-HooplaDecryptedToM4a -InputFolder $decDir -OutFolder $OutputFolder -Name $info.title -Title $info.title `
-Year $info.year -Author $info.artist.name -Subtitle $contents.subtitle -ChapterData $contents.chapters
}
}
if (!$KeepDecryptedData)
{
Remove-Item -LiteralPath $decDir -Recurse
}
else
{
Write-Host ('Decrypted data for {0} ({1}) stored in {2}' -f $_.id, $_.title, $decDir)
}
if (!$KeepEncryptedData)
{
Remove-Item -LiteralPath $encDir -Recurse
}
}

13
Hoopla/_README.txt Normal file
View File

@@ -0,0 +1,13 @@
From a Libation user about possibility of integrating Hoopla:
I have a powershell script. I didn't write it, and neither did the person that gave it to me. It works most of the time (98%). Some titles, it doesn't play well with, but does allow to keep the downloaded data, whether the decrypt was successful, or not, and then you can mess with the data, from there.
If you run the script with no parameters, then all the books in your library will download, and decrypt into the same directory as the script, into a folder named Completed.
If you run the script with the command:
'.\HooplaDownloader.newer.ps1 -KeepDecryptedData'
then it will, and will notify you, when complete, where it was stored.
There is a parameter to download a specific titleID#, whether it's in your library, or not, but I've not played with it that far, as the method to accomplish it still reserves it to your library, and then proceeds as normal. I can tell you, if it's a "trial and error concern", the title will not be removed from your library, after you run the script, whether it succeeds or not. So, if it fails, you can retry, or try the -KeepDecryptedData option. I received no documentation for it, which is why I'm telling you as much as I know about using it.
[ see HooplaDownloader.newer.ps1 ]

9
Hoopla/temp.cs Normal file
View File

@@ -0,0 +1,9 @@
using System;
namespace Hoopla
{
public class temp
{
// placeholder
}
}

View File

@@ -0,0 +1,296 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
using Polly;
using Polly.Retry;
namespace InternalUtilities
{
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public class ApiExtended
{
public Api Api { get; private set; }
private ApiExtended(Api api) => Api = api;
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginChoiceEager,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens 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)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
AccountMaskedLogEntry = account.MaskedLogEntry
});
return await CreateAsync(account.AccountId, account.Locale.Name);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
Username = username.ToMask(),
LocaleName = localeName,
});
var api = await EzApiCreator.GetApiAsync(
Localization.Get(localeName),
AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
return new ApiExtended(api);
}
private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>()
// 2 retries == 3 total
.RetryAsync(2);
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS)
{
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or tokens are refreshed
// worse, this 1st dummy call doesn't seem to help:
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
return policy.ExecuteAsync(() => getItemsAsync(responseGroups));
}
private async Task<List<Item>> getItemsAsync(LibraryOptions.ResponseGroupOptions responseGroups)
{
var items = new List<Item>();
#if DEBUG
//// this will not work for multi accounts
//var library_json = "library.json";
//if (System.IO.File.Exists(library_json))
//{
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//}
#endif
if (!items.Any())
items = await Api.GetAllLibraryItemsAsync(responseGroups);
#if DEBUG
//System.IO.File.WriteAllText("library.json", AudibleApi.Common.Converter.ToJson(items));
#endif
await manageEpisodesAsync(items);
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
{
var exceptions = v.Validate(items);
if (exceptions != null && exceptions.Any())
throw new AggregateException(exceptions);
}
return items;
}
#region episodes and podcasts
private async Task manageEpisodesAsync(List<Item> items)
{
// 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
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.
// also must happen before processing children because children abuses this flag
items.RemoveAll(i => i.IsEpisodes);
// 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");
}
}
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
{
var results = new List<Item>();
foreach (var parent in parents)
{
var children = await getEpisodeChildrenAsync(parent);
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
Sequence = parent.Relationships.Single(r => r.Asin == child.Asin).Sort.ToString(),
Title = parent.TitleWithSubtitle
}
};
// overload (read: abuse) IsEpisodes flag
child.Relationships = new Relationship[]
{
new Relationship
{
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode
}
};
}
results.AddRange(children);
}
return results;
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
{
var childrenIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin)
.ToList();
// fetch children in batches
const int batchSize = 20;
var results = new List<Item>();
for (var i = 1; ; i++)
{
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
if (!idBatch.Any())
break;
List<Item> childrenBatch;
try
{
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);
#endif
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = i,
ChildIdBatch = idBatch
});
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
results.AddRange(childrenBatch);
}
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
ChildCount = childrenIds.Count
});
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.");
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,104 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApiDTOs;
using Dinah.Core;
using Polly;
using Polly.Retry;
namespace InternalUtilities
{
public static class AudibleApiActions
{
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public static Task<Api> GetApiAsync(string username, string localeName, ILoginCallback loginCallback = null)
{
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
{
Username = username.ToMask(),
LocaleName = localeName,
});
return EzApiCreator.GetApiAsync(
Localization.Get(localeName),
AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName),
loginCallback);
}
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public static Task<Api> GetApiAsync(ILoginCallback loginCallback, Account account)
{
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
return EzApiCreator.GetApiAsync(
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath(),
loginCallback);
}
private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>()
// 2 retries == 3 total
.RetryAsync(2);
public static Task<List<Item>> GetLibraryValidatedAsync(Api api)
{
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or tokens are refreshed
// worse, this 1st dummy call doesn't seem to help:
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
return policy.ExecuteAsync(() => getItemsAsync(api));
}
private static async Task<List<Item>> getItemsAsync(Api api)
{
var items = await api.GetAllLibraryItemsAsync();
// remove episode parents
items.RemoveAll(i => i.IsEpisodes);
#region // episode handling. doesn't quite work
// // add individual/children episodes
// var childIds = items
// .Where(i => i.Episodes)
// .SelectMany(ep => ep.Relationships)
// .Where(r => r.RelationshipToProduct == AudibleApiDTOs.RelationshipToProduct.Child && r.RelationshipType == AudibleApiDTOs.RelationshipType.Episode)
// .Select(c => c.Asin)
// .ToList();
// foreach (var childId in childIds)
// {
// var bookResult = await api.GetLibraryBookAsync(childId, AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
// var bookItem = AudibleApiDTOs.LibraryDtoV10.FromJson(bookResult.ToString()).Item;
// items.Add(bookItem);
// }
#endregion
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
{
var exceptions = v.Validate(items);
if (exceptions != null && exceptions.Any())
throw new AggregateException(exceptions);
}
return items;
}
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,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using AudibleApi.Common;
namespace InternalUtilities
{
@@ -33,8 +33,10 @@ namespace InternalUtilities
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
if (items.Any(i => string.IsNullOrWhiteSpace(i.Title)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.Title)}", nameof(items)));
// this can happen with podcast episodes
foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title)))
i.Title = "[blank title]";
return exceptions;
}
@@ -76,9 +78,9 @@ namespace InternalUtilities
var distinct = items.GetSeriesDistinct();
if (distinct.Any(s => s.SeriesId is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(AudibleApiDTOs.Series.SeriesId)}", nameof(items)));
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
if (distinct.Any(s => s.SeriesName is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(AudibleApiDTOs.Series.SeriesName)}", nameof(items)));
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
return exceptions;
}

View File

@@ -5,7 +5,10 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
<PackageReference Include="AudibleApi" Version="2.1.1.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>

View File

@@ -5,6 +5,7 @@ VisualStudioVersion = 16.0.28803.156
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
ProjectSection(SolutionItems) = preProject
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
__TODO.txt = __TODO.txt
_DB_NOTES.txt = _DB_NOTES.txt
REFERENCE.txt = REFERENCE.txt
@@ -34,59 +35,26 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Tests", "0 Tests", "{38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Demos and Examples", "0 Demos and Examples", "{F61184E7-2426-4A13-ACEF-5689928E2CE2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Demos", "..\Dinah.Core\_Demos\Dinah.Core.Demos\Dinah.Core.Demos.csproj", "{9F1AA3DE-962F-469B-82B2-46F93491389B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Tests", "..\Dinah.Core\_Tests\Dinah.Core.Tests\Dinah.Core.Tests.csproj", "{E874D000-AD3A-4629-AC65-7219C2C7C1F0}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestCommon", "..\Dinah.Core\_Tests\TestCommon\TestCommon.csproj", "{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitAllRepos", "..\GitAllRepos\GitAllRepos\GitAllRepos.csproj", "{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Tests", "..\audible api\AudibleApi\_Tests\AudibleApi.Tests\AudibleApi.Tests.csproj", "{111420E2-D4F0-4068-B46A-C4B6DCC823DC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsDesigner", "WinFormsDesigner\WinFormsDesigner.csproj", "{0807616A-A77A-4B08-A65A-1582B09E114B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core", "..\Dinah.Core\Dinah.Core\Dinah.Core.csproj", "{9E951521-2587-4FC6-AD26-FAA9179FB6C4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore", "..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj", "{1255D9BA-CE6E-42E4-A253-6376540B9661}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2", "..\LuceneNet303r2\LuceneNet303r2\LuceneNet303r2.csproj", "{35803735-B669-4090-9681-CC7F7FABDC71}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2.Tests", "..\LuceneNet303r2\LuceneNet303r2.Tests\LuceneNet303r2.Tests.csproj", "{5A7681A5-60D9-480B-9AC7-63E0812A2548}"
ProjectSection(ProjectDependencies) = postProject
{428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
EndProjectSection
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiDTOs", "..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj", "{C03C5D65-3B7F-453B-972F-23950B7E0604}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiDTOs.Tests", "..\audible api\AudibleApi\_Tests\AudibleApiDTOs.Tests\AudibleApiDTOs.Tests.csproj", "{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiClientExample", "..\audible api\AudibleApi\_Demos\AudibleApiClientExample\AudibleApiClientExample.csproj", "{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.WindowsDesktop", "..\Dinah.Core\Dinah.Core.WindowsDesktop\Dinah.Core.WindowsDesktop.csproj", "{059CE32C-9AD6-45E9-A166-790DFFB0B730}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "WindowsDesktopUtilities", "WindowsDesktopUtilities\WindowsDesktopUtilities.csproj", "{E7EFD64D-6630-4426-B09C-B6862A92E3FD}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationLauncher", "LibationLauncher\LibationLauncher.csproj", "{F3B04A3A-20C8-4582-A54A-715AF6A5D859}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Libation Tests", "0 Libation Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities.Tests", "_Tests\InternalUtilities.Tests\InternalUtilities.Tests.csproj", "{8447C956-B03E-4F59-9DD4-877793B849D9}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore.Tests", "..\Dinah.Core\_Tests\Dinah.EntityFrameworkCore.Tests\Dinah.EntityFrameworkCore.Tests.csproj", "{6F5131A0-09AE-4707-B82B-5E53CB74688E}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AAXClean", "..\AAXClean\AAXClean.csproj", "{94BEB7CC-511D-45AB-9F09-09BE858EE486}"
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -118,86 +86,18 @@ Global
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = Release|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.Build.0 = Release|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.Build.0 = Release|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.Build.0 = Release|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.Build.0 = Release|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.Build.0 = Release|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.Build.0 = Release|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = Release|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.Build.0 = Release|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.Build.0 = Release|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.Build.0 = Release|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.Build.0 = Release|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.Build.0 = Release|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.Build.0 = Release|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Release|Any CPU.Build.0 = Release|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Release|Any CPU.Build.0 = Release|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Release|Any CPU.Build.0 = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Debug|Any CPU.Build.0 = Debug|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Release|Any CPU.ActiveCfg = Release|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Release|Any CPU.Build.0 = Release|Any CPU
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E7EFD64D-6630-4426-B09C-B6862A92E3FD}.Release|Any CPU.Build.0 = Release|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.Build.0 = Release|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -206,49 +106,37 @@ Global
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.Build.0 = Release|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Release|Any CPU.Build.0 = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.Build.0 = Release|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.Build.0 = Debug|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.ActiveCfg = Release|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
EndGlobalSection
GlobalSection(NestedProjects) = preSolution
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{9F1AA3DE-962F-469B-82B2-46F93491389B} = {F61184E7-2426-4A13-ACEF-5689928E2CE2}
{E874D000-AD3A-4629-AC65-7219C2C7C1F0} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{111420E2-D4F0-4068-B46A-C4B6DCC823DC} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{0807616A-A77A-4B08-A65A-1582B09E114B} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{9E951521-2587-4FC6-AD26-FAA9179FB6C4} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{1255D9BA-CE6E-42E4-A253-6376540B9661} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{35803735-B669-4090-9681-CC7F7FABDC71} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{5A7681A5-60D9-480B-9AC7-63E0812A2548} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{C03C5D65-3B7F-453B-972F-23950B7E0604} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6} = {F61184E7-2426-4A13-ACEF-5689928E2CE2}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{059CE32C-9AD6-45E9-A166-790DFFB0B730} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{E7EFD64D-6630-4426-B09C-B6862A92E3FD} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{F3B04A3A-20C8-4582-A54A-715AF6A5D859} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{8447C956-B03E-4F59-9DD4-877793B849D9} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{6F5131A0-09AE-4707-B82B-5E53CB74688E} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{94BEB7CC-511D-45AB-9F09-09BE858EE486} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net5.0</TargetFramework>
<PublishTrimmed>true</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<!--
When LibationWinForms and LibationCli output to the same dir, LibationCli must build before LibationWinForms
VS > rt-clik solution > Project Build Order...
Dependencies [tab]
Projects: LibationWinForms
manually check LibationCli
-->
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
namespace LibationCli
{
[Verb("convert", HelpText = "Convert mp4 to mp3.")]
public class ConvertOptions : ProcessableOptionsBase
{
protected override Task ProcessAsync() => RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
}
}

View File

@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using CommandLine;
using InternalUtilities;
namespace LibationCli
{
[Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json]")]
public class ExportOptions : OptionsBase
{
[Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")]
public string FilePath { get; set; }
#region explanation of mutually exclusive options
/*
giving these SetName values makes them mutually exclusive. they are in different sets. eg:
class Options
{
[Option("username", SetName = "auth")]
public string Username { get; set; }
[Option("password", SetName = "auth")]
public string Password { get; set; }
[Option("guestaccess", SetName = "guest")]
public bool GuestAccess { get; set; }
}
*/
#endregion
[Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)]
public bool xlsx { get; set; }
[Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)]
public bool csv { get; set; }
[Option(shortName: 'j', longName: "json", SetName = "json", Required = true)]
public bool json { get; set; }
protected override Task ProcessAsync()
{
if (xlsx)
LibraryExporter.ToXlsx(FilePath);
if (csv)
LibraryExporter.ToCsv(FilePath);
if (json)
LibraryExporter.ToJson(FilePath);
Console.WriteLine($"Library exported to: {FilePath}");
return Task.CompletedTask;
}
}
}

View File

@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
using DataLayer;
using FileLiberator;
namespace LibationCli
{
[Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs. "
+ "Optional: use 'pdf' flag to only download pdfs.")]
public class LiberateOptions : ProcessableOptionsBase
{
[Option(shortName: 'p', longName: "pdf", Required = false, Default = false, HelpText = "Flag to only download pdfs")]
public bool PdfOnly { get; set; }
protected override Task ProcessAsync()
=> PdfOnly
? RunAsync(CreateProcessable<DownloadPdf>())
: RunAsync(CreateBackupBook());
private static IProcessable CreateBackupBook()
{
var downloadPdf = CreateProcessable<DownloadPdf>();
//Chain pdf download on DownloadDecryptBook.Completed
async void onDownloadDecryptBookCompleted(object sender, LibraryBook e)
{
await downloadPdf.TryProcessAsync(e);
}
var downloadDecryptBook = CreateProcessable<DownloadDecryptBook>(onDownloadDecryptBookCompleted);
return downloadDecryptBook;
}
}
}

View File

@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using CommandLine;
using InternalUtilities;
namespace LibationCli
{
[Verb("scan", HelpText = "Scan library. Default: scan all accounts. Optional: use 'account' flag to specify a single account.")]
public class ScanOptions : OptionsBase
{
[Value(0, MetaName = "Accounts", HelpText = "Optional: nicknames of accounts to scan.", Required = false)]
public IEnumerable<string> AccountNicknames { get; set; }
protected override async Task ProcessAsync()
{
var accounts = getAccounts();
if (!accounts.Any())
{
Console.WriteLine("No accounts. Exiting.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
var _accounts = accounts.ToArray();
var intro
= (_accounts.Length == 1)
? "Scanning Audible library. This may take a few minutes."
: $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account.";
Console.WriteLine(intro);
var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((a) => ApiExtended.CreateAsync(a), _accounts);
Console.WriteLine("Scan complete.");
Console.WriteLine($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}");
}
private Account[] getAccounts()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll().ToArray();
if (!AccountNicknames.Any())
return accounts;
var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray();
var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray();
// no accounts found. do not continue
if (!found.Any())
{
Console.WriteLine("Accounts not found:");
foreach (var nf in notFound)
Console.WriteLine($"- {nf}");
return found;
}
// some accounts not found. continue after message
if (notFound.Any())
{
Console.WriteLine("Accounts found:");
foreach (var f in found)
Console.WriteLine($"- {f}");
Console.WriteLine("Accounts not found:");
foreach (var nf in notFound)
Console.WriteLine($"- {nf}");
}
// else: all accounts area found. silently continue
return found;
}
}
}

View File

@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
namespace LibationCli
{
public abstract class OptionsBase
{
public async Task Run()
{
try
{
await ProcessAsync();
}
catch (Exception ex)
{
Environment.ExitCode = (int)ExitCode.RunTimeError;
Console.WriteLine("ERROR");
Console.WriteLine("=====");
Console.WriteLine(ex.Message);
Console.WriteLine();
Console.WriteLine(ex.StackTrace);
}
}
protected abstract Task ProcessAsync();
}
}

View File

@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using CommandLine;
using DataLayer;
using FileLiberator;
namespace LibationCli
{
// streamlined, non-Forms copy of ProcessorAutomationController
public abstract class ProcessableOptionsBase : OptionsBase
{
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
where TProcessable : IProcessable, new()
{
var strProc = new TProcessable();
strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
strProc.Completed += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
strProc.Completed += completedAction;
return strProc;
}
protected static async Task RunAsync(IProcessable Processable)
{
foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking()))
await ProcessOneAsync(Processable, libraryBook, false);
var done = "Done. All books have been processed";
Console.WriteLine(done);
Serilog.Log.Logger.Information(done);
}
private static async Task ProcessOneAsync(IProcessable Processable, LibraryBook libraryBook, bool validate)
{
try
{
var statusHandler = await Processable.ProcessSingleAsync(libraryBook, validate);
if (statusHandler.IsSuccess)
return;
foreach (var errorMessage in statusHandler.Errors)
Serilog.Log.Logger.Error(errorMessage);
}
catch (Exception ex)
{
var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app.";
Console.WriteLine(msg + ". See log for more details.");
Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() });
}
}
}
}

84
LibationCli/Program.cs Normal file
View File

@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
using CommandLine.Text;
using Dinah.Core;
using Dinah.Core.Collections;
using Dinah.Core.Collections.Generic;
namespace LibationCli
{
public enum ExitCode
{
ProcessCompletedSuccessfully = 0,
NonRunNonError = 1,
ParseError = 2,
RunTimeError = 3
}
class Program
{
static async Task<int> Main(string[] args)
{
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
Setup.Initialize();
Setup.SubscribeToDatabaseEvents();
var types = Setup.LoadVerbs();
#if DEBUG
string input = null;
//input = " export --help";
//input = " scan rmcrackan";
//input = " liberate ";
// note: this hack will fail for quoted file paths with spaces because it will break on those spaces
if (!string.IsNullOrWhiteSpace(input))
args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var setBreakPointHere = args;
#endif
var result = Parser.Default.ParseArguments(args, types);
// if successfully parsed
// async: run parsed options
await result.WithParsedAsync<OptionsBase>(opt => opt.Run());
// if not successfully parsed
// sync: handle parse errors
result.WithNotParsed(errors => HandleErrors(result, errors));
return Environment.ExitCode;
}
private static void HandleErrors(ParserResult<object> result, IEnumerable<Error> errors)
{
var errorsList = errors.ToList();
if (errorsList.Any(e => e.Tag.In(ErrorType.HelpRequestedError, ErrorType.VersionRequestedError, ErrorType.HelpVerbRequestedError)))
{
Environment.ExitCode = (int)ExitCode.NonRunNonError;
return;
}
Environment.ExitCode = (int)ExitCode.ParseError;
if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError)))
{
Console.WriteLine("No verb selected");
return;
}
var helpText = HelpText.AutoBuild(result,
h => HelpText.DefaultParsingErrorsHandler(result, h),
e => e);
Console.WriteLine(helpText);
}
}
}

63
LibationCli/Setup.cs Normal file
View File

@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AppScaffolding;
using CommandLine;
using CommandLine.Text;
using Dinah.Core;
using Dinah.Core.Collections;
using Dinah.Core.Collections.Generic;
namespace LibationCli
{
public static class Setup
{
public static void Initialize()
{
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations();
LibationScaffolding.RunPostMigrationScaffolding();
#if !DEBUG
checkForUpdate();
#endif
}
private static void checkForUpdate()
{
var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease();
if (!hasUpgrade)
return;
var origColor = Console.ForegroundColor;
try
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}");
}
finally
{
Console.ForegroundColor = origColor;
}
}
public static void SubscribeToDatabaseEvents()
{
DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book);
}
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)
.ToArray();
}
}

View File

@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk.WindowsDesktop">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net5.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>libation.ico</ApplicationIcon>
<AssemblyName>Libation</AssemblyName>
<PublishTrimmed>true</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>
<!-- <PublishSingleFile>true</PublishSingleFile> -->
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<Version>5.3.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.50.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationWinForms\LibationWinForms.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,428 +0,0 @@
using System;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using AudibleApi;
using AudibleApi.Authorization;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using FileManager;
using InternalUtilities;
using LibationWinForms;
using LibationWinForms.Dialogs;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
namespace LibationLauncher
{
static class Program
{
[System.Runtime.InteropServices.DllImport("kernel32.dll", SetLastError = true)]
[return: System.Runtime.InteropServices.MarshalAs(System.Runtime.InteropServices.UnmanagedType.Bool)]
static extern bool AllocConsole();
[STAThread]
static void Main()
{
//// uncomment to see Console. MUST be called before anything writes to Console. Might only work from VS
//AllocConsole();
Application.SetHighDpiMode(HighDpiMode.SystemAware);
Application.EnableVisualStyles();
Application.SetCompatibleTextRenderingDefault(false);
// must occur before access to Configuration instance
migrate_to_v5_2_0__pre_config();
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
var config = Configuration.Instance;
createSettings(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists();
migrate_to_v5_0_0(config);
migrate_to_v5_2_0__post_config(config);
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
checkForUpdate(config);
Application.Run(new Form1());
}
private static void createSettings(Configuration config)
{
// all returns should be preceded by either:
// - if config.LibationSettingsAreValid
// - error message, Exit()
static void CancelInstallation()
{
MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Application.Exit();
Environment.Exit(0);
}
if (config.LibationSettingsAreValid)
return;
var defaultLibationFilesDir = Configuration.UserProfile;
// check for existing settigns in default location
var defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json");
if (Configuration.SettingsFileIsValid(defaultSettingsFile))
config.TrySetLibationFiles(defaultLibationFilesDir);
if (config.LibationSettingsAreValid)
return;
var setupDialog = new SetupDialog();
if (setupDialog.ShowDialog() != DialogResult.OK)
{
CancelInstallation();
return;
}
if (setupDialog.IsNewUser)
config.TrySetLibationFiles(defaultLibationFilesDir);
else if (setupDialog.IsReturningUser)
{
var libationFilesDialog = new LibationFilesDialog();
if (libationFilesDialog.ShowDialog() != DialogResult.OK)
{
CancelInstallation();
return;
}
config.TrySetLibationFiles(libationFilesDialog.SelectedDirectory);
if (config.LibationSettingsAreValid)
return;
// path did not result in valid settings
MessageBox.Show(
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
"New install?",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question);
if (libationFilesDialog.ShowDialog() != DialogResult.Yes)
{
CancelInstallation();
return;
}
}
// if 'new user' was clicked, or if 'returning user' chose new install: show basic settings dialog
config.Books ??= Path.Combine(defaultLibationFilesDir, "Books");
config.InProgress ??= Configuration.WinTemp;
config.AllowLibationFixup = true;
config.DecryptToLossy = false;
if (new SettingsDialog().ShowDialog() != DialogResult.OK)
{
CancelInstallation();
return;
}
if (config.LibationSettingsAreValid)
return;
CancelInstallation();
}
#region migrate_to_v5_0_0 re-register device if device info not in settings
private static void migrate_to_v5_0_0(Configuration config)
{
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!File.Exists(AudibleApiStorage.AccountsSettingsFile))
return;
var accountsPersister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = accountsPersister?.AccountsSettings?.Accounts;
if (accounts is null)
return;
foreach (var account in accounts)
{
var identity = account?.IdentityTokens;
if (identity is null)
continue;
if (!string.IsNullOrWhiteSpace(identity.DeviceType) &&
!string.IsNullOrWhiteSpace(identity.DeviceSerialNumber) &&
!string.IsNullOrWhiteSpace(identity.AmazonAccountId))
continue;
var authorize = new Authorize(identity.Locale);
try
{
authorize.DeregisterAsync(identity.ExistingAccessToken, identity.Cookies.ToKeyValuePair()).GetAwaiter().GetResult();
identity.Invalidate();
var api = AudibleApiActions.GetApiAsync(new LibationWinForms.Login.WinformResponder(account), account).GetAwaiter().GetResult();
}
catch
{
// Don't care if it fails
}
}
}
#endregion
#region migrate to v5.2.0
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
private 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
};
private static void migrate_to_v5_2_0__post_config(Configuration config)
{
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
}
#endregion
private static void ensureSerilogConfig(Configuration config)
{
if (config.GetObject("Serilog") != null)
return;
// "Serilog": {
// "MinimumLevel": "Information"
// "WriteTo": [
// {
// "Name": "Console"
// },
// {
// "Name": "File",
// "Args": {
// "rollingInterval": "Day",
// "outputTemplate": ...
// }
// }
// ],
// "Using": [ "Dinah.Core" ],
// "Enrich": [ "WithCaller" ]
// }
var serilogObj = new JObject
{
{ "MinimumLevel", "Information" },
{ "WriteTo", new JArray
{
new JObject { {"Name", "Console" } },
new JObject
{
{ "Name", "File" },
{ "Args",
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}" }
}
}
}
}
},
{ "Using", new JArray{ "Dinah.Core" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller" } },
};
config.SetObject("Serilog", serilogObj);
}
// to restore original: Console.SetOut(origOut);
private static TextWriter origOut { get; } = Console.Out;
private static void configureLogging(Configuration config)
{
config.ConfigureLogging();
// Fwd Console to serilog.
// Serilog also write to Console (should probably change this) so it might be asking for trouble.
// SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
// Empirical testing so far has shown no issues.
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
// .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)
{
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
Log.Logger.Information("Begin Libation. {@DebugInfo}", new
{
Version = BuildVersion.ToString(),
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
config.InProgress,
DownloadsInProgressDir = AudibleFileStorage.DownloadsInProgress,
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgress).Count(),
DecryptInProgressDir = AudibleFileStorage.DecryptInProgress,
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgress).Count(),
});
// when turning on debug (and especially Verbose) to share logs, some privacy settings may not be obscured
if (Log.Logger.IsVerboseEnabled())
MessageBox.Show(@"
Warning: verbose logging is enabled.
This should be used for debugging only. It creates many
more logs and debug files, neither of which are as
strictly anonomous.
When you are finished debugging, it's highly recommended
to set your debug MinimumLevel to Information and restart
Libation.
".Trim(), "Verbose logging enabled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
}
private static void checkForUpdate(Configuration config)
{
string zipUrl;
string selectedPath;
try
{
// timed out
var latest = getLatestRelease(TimeSpan.FromSeconds(30));
if (latest is null)
return;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return;
// we're up to date
if (latestRelease <= BuildVersion)
return;
// we have an update
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new {
latestRelease = latestRelease.ToString(),
latest.HtmlUrl,
zipUrl
});
if (zipUrl is null)
{
MessageBox.Show(latest.HtmlUrl, "New version available");
return;
}
var result = MessageBox.Show($"New version available @ {latest.HtmlUrl}\r\nDownload the zip file?", "New version available", MessageBoxButtons.YesNo, MessageBoxIcon.Question);
if (result != DialogResult.Yes)
return;
using var fileSelector = new SaveFileDialog { FileName = zip.Name, Filter = "Zip Files (*.zip)|*.zip|All files (*.*)|*.*" };
if (fileSelector.ShowDialog() != DialogResult.OK)
return;
selectedPath = fileSelector.FileName;
}
catch (Exception ex)
{
MessageBoxAlertAdmin.Show("Error checking for update", "Error checking for update", ex);
return;
}
try
{
LibationWinForms.BookLiberation.ProcessorAutomationController.DownloadFile(zipUrl, selectedPath, true);
}
catch (Exception ex)
{
MessageBoxAlertAdmin.Show("Error downloading update", "Error downloading update", ex);
}
}
private static Octokit.Release getLatestRelease(TimeSpan timeout)
{
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
if (task.Wait(timeout))
return task.Result;
Log.Logger.Information("Timed out");
return null;
}
private static Octokit.Release getLatestRelease()
{
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("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;
}
private static Version BuildVersion => System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
}
}

View File

Binary file not shown.

Before

Width:  |  Height:  |  Size: 100 KiB

View File

@@ -9,7 +9,10 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\LuceneNet303r2\LuceneNet303r2\LuceneNet303r2.csproj" />
<PackageReference Include="LuceneNet303r2" Version="3.0.3.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>

View File

@@ -9,24 +9,24 @@ using Lucene.Net.Search;
namespace LibationSearchEngine
{
// field names are case specific and, due to StandardAnalyzer, content is case INspecific
public static class LuceneExtensions
internal static class LuceneExtensions
{
public static void AddRaw(this Document document, string name, string value)
internal static void AddRaw(this Document document, string name, string value)
=> document.Add(new Field(name, value, Field.Store.YES, Field.Index.NOT_ANALYZED));
public static void AddAnalyzed(this Document document, string name, string value)
internal static void AddAnalyzed(this Document document, string name, string value)
{
if (value != null)
document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.ANALYZED));
}
public static void AddNotAnalyzed(this Document document, string name, string value)
internal static void AddNotAnalyzed(this Document document, string name, string value)
=> document.Add(new Field(name.ToLowerInvariant(), value, Field.Store.YES, Field.Index.NOT_ANALYZED));
public static void AddBool(this Document document, string name, bool value)
internal static void AddBool(this Document document, string name, bool value)
=> document.Add(new Field(name.ToLowerInvariant(), value.ToString(), Field.Store.YES, Field.Index.ANALYZED_NO_NORMS));
public static Query GetQuery(this Analyzer analyzer, string defaultField, string searchString)
internal static Query GetQuery(this Analyzer analyzer, string defaultField, string searchString)
=> new QueryParser(SearchEngine.Version, defaultField.ToLowerInvariant(), analyzer).Parse(searchString);
// put all numbers, including dates, into this format:

View File

@@ -18,8 +18,6 @@ namespace LibationSearchEngine
{
public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30;
private LibationContext context { get; }
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
@@ -32,8 +30,6 @@ namespace LibationSearchEngine
// the workaround which allows displaying all books when query is empty
public const string ALL_QUERY = "*:*";
public SearchEngine(LibationContext context) => this.context = context;
#region index rules
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
@@ -111,14 +107,14 @@ namespace LibationSearchEngine
= new ReadOnlyDictionary<string, Func<LibraryBook, bool>>(
new Dictionary<string, Func<LibraryBook, bool>>
{
["HasDownloads"] = lb => lb.Book.Supplements.Any(),
["HasDownload"] = lb => lb.Book.Supplements.Any(),
["Downloads"] = lb => lb.Book.Supplements.Any(),
["Download"] = lb => lb.Book.Supplements.Any(),
["HasPDFs"] = lb => lb.Book.Supplements.Any(),
["HasPDF"] = lb => lb.Book.Supplements.Any(),
["PDFs"] = lb => lb.Book.Supplements.Any(),
["PDF"] = lb => lb.Book.Supplements.Any(),
["HasDownloads"] = lb => lb.Book.HasPdf,
["HasDownload"] = lb => lb.Book.HasPdf,
["Downloads"] = lb => lb.Book.HasPdf,
["Download"] = lb => lb.Book.HasPdf,
["HasPDFs"] = lb => lb.Book.HasPdf,
["HasPDF"] = lb => lb.Book.HasPdf,
["PDFs"] = lb => lb.Book.HasPdf,
["PDF"] = lb => lb.Book.HasPdf,
["IsRated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f,
["Rated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f,
@@ -130,8 +126,14 @@ namespace LibationSearchEngine
["Abridged"] = lb => lb.Book.IsAbridged,
// this will only be evaluated at time of re-index. ie: state of files moved later will be out of sync until next re-index
["IsLiberated"] = lb => isLiberated(lb.Book.AudibleProductId),
["Liberated"] = lb => isLiberated(lb.Book.AudibleProductId),
["IsLiberated"] = lb => isLiberated(lb.Book),
["Liberated"] = lb => isLiberated(lb.Book),
["LiberatedError"] = lb => liberatedError(lb.Book),
["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["Episode"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsEpisode"] = lb => lb.Book.ContentType == ContentType.Episode,
}
);
@@ -142,7 +144,8 @@ namespace LibationSearchEngine
return authors.Intersect(narrators).Any();
}
private static bool isLiberated(string id) => AudibleFileStorage.Audio.Exists(id);
private static bool isLiberated(Book book) => book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated;
private static bool liberatedError(Book book) => book.UserDefinedItem.BookStatus == LiberatedStatus.Error;
// use these common fields in the "all" default search field
private static IEnumerable<Func<LibraryBook, string>> allFieldIndexRules { get; }
@@ -194,26 +197,9 @@ namespace LibationSearchEngine
#endregion
#region create and update index
/// <summary>
/// create new. ie: full re-index
/// </summary>
/// <param name="overwrite"></param>
public void CreateNewIndex(bool overwrite = true)
/// <summary>create new. ie: full re-index</summary>
public void CreateNewIndex(IEnumerable<LibraryBook> library, bool overwrite = true)
{
// 300 products
// 1st run after app is started: 400ms
// subsequent runs: 200ms
var sw = System.Diagnostics.Stopwatch.StartNew();
var stamps = new List<long>();
void log() => stamps.Add(sw.ElapsedMilliseconds);
log();
var library = context.GetLibrary_Flat_NoTracking();
log();
// location of index/create the index
using var index = getIndex();
var exists = IndexReader.IndexExists(index);
@@ -227,12 +213,10 @@ namespace LibationSearchEngine
var doc = createBookIndexDocument(libraryBook);
ixWriter.AddDocument(doc);
}
log();
}
/// <summary>Long running. Use await Task.Run(() => UpdateBook(productId))</summary>
public void UpdateBook(string productId)
public void UpdateBook(LibationContext context, string productId)
{
var libraryBook = context.GetLibraryBook_Flat_NoTracking(productId);
var term = new Term(_ID_, productId);
@@ -301,18 +285,22 @@ namespace LibationSearchEngine
});
// update single document entry
public void UpdateIsLiberated(string productId)
public void UpdateLiberatedStatus(Book book)
=> updateDocument(
productId,
book.AudibleProductId,
d =>
{
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
// ie: must remove old before adding new else will create unwanted duplicates.
var v = isLiberated(productId);
var v1 = isLiberated(book);
d.RemoveField("IsLiberated");
d.AddBool("IsLiberated", v);
d.AddBool("IsLiberated", v1);
d.RemoveField("Liberated");
d.AddBool("Liberated", v);
d.AddBool("Liberated", v1);
var v2 = liberatedError(book);
d.RemoveField("LiberatedError");
d.AddBool("LiberatedError", v2);
});
private static void updateDocument(string productId, Action<Document> action)

View File

@@ -0,0 +1,14 @@
using Dinah.Core.Threading;
using System.ComponentModel;
using System.Runtime.CompilerServices;
namespace LibationWinForms
{
public abstract class AsyncNotifyPropertyChanged : SynchronizeInvoker, INotifyPropertyChanged
{
public event PropertyChangedEventHandler PropertyChanged;
public void NotifyPropertyChanged([CallerMemberName] string propertyName = "")
=> this.UIThreadAsync(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
}
}

View File

@@ -0,0 +1,31 @@
using DataLayer;
using FileManager;
using System;
namespace LibationWinForms.BookLiberation
{
class AudioConvertForm : AudioDecodeForm
{
public AudioConvertForm()
{
this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
}
#region AudioDecodeForm overrides
public override string DecodeActionName => "Converting";
#endregion
#region IProcessable event handler overrides
public override void OnBegin(object sender, LibraryBook libraryBook)
{
LogMe.Info($"Convert Step, Begin: {libraryBook.Book}");
base.OnBegin(sender, libraryBook);
}
public override void OnCompleted(object sender, LibraryBook libraryBook)
=> LogMe.Info($"Convert Step, Completed: {libraryBook.Book}{Environment.NewLine}");
#endregion
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -1,6 +1,6 @@
namespace LibationWinForms.BookLiberation
{
partial class DecryptForm
partial class AudioDecodeForm
{
/// <summary>
/// Required designer variable.

View File

@@ -0,0 +1,92 @@
using System;
using DataLayer;
using Dinah.Core.Net.Http;
using Dinah.Core.Threading;
using LibationWinForms.BookLiberation.BaseForms;
namespace LibationWinForms.BookLiberation
{
public partial class AudioDecodeForm : LiberationBaseForm
{
public virtual string DecodeActionName { get; } = "Decoding";
public AudioDecodeForm() => InitializeComponent();
private Func<byte[]> GetCoverArtDelegate;
// book info
private string title;
private string authorNames;
private string narratorNames;
#region IProcessable event handler overrides
public override void OnBegin(object sender, LibraryBook libraryBook)
{
GetCoverArtDelegate = () => FileManager.PictureStorage.GetPictureSynchronously(
new FileManager.PictureDefinition(
libraryBook.Book.PictureId,
FileManager.PictureSize._500x500));
//Set default values from library
OnTitleDiscovered(sender, libraryBook.Book.Title);
OnAuthorsDiscovered(sender, string.Join(", ", libraryBook.Book.Authors));
OnNarratorsDiscovered(sender, string.Join(", ", libraryBook.Book.NarratorNames));
OnCoverImageDiscovered(sender,
FileManager.PictureStorage.GetPicture(
new FileManager.PictureDefinition(
libraryBook.Book.PictureId,
FileManager.PictureSize._80x80)).bytes);
}
#endregion
#region IStreamable event handler overrides
public override void OnStreamingProgressChanged(object sender, DownloadProgress downloadProgress)
{
if (!downloadProgress.ProgressPercentage.HasValue)
return;
if (downloadProgress.ProgressPercentage == 0)
updateRemainingTime(0);
else
progressBar1.UIThreadAsync(() => progressBar1.Value = (int)downloadProgress.ProgressPercentage);
}
public override void OnStreamingTimeRemaining(object sender, TimeSpan timeRemaining)
=> updateRemainingTime((int)timeRemaining.TotalSeconds);
#endregion
#region IAudioDecodable event handlers
public override void OnRequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate)
=> setCoverArtDelegate(GetCoverArtDelegate?.Invoke());
public override void OnTitleDiscovered(object sender, string title)
{
this.UIThreadAsync(() => this.Text = DecodeActionName + " " + title);
this.title = title;
updateBookInfo();
}
public override void OnAuthorsDiscovered(object sender, string authors)
{
authorNames = authors;
updateBookInfo();
}
public override void OnNarratorsDiscovered(object sender, string narrators)
{
narratorNames = narrators;
updateBookInfo();
}
public override void OnCoverImageDiscovered(object sender, byte[] coverArt)
=> pictureBox1.UIThreadAsync(() => pictureBox1.Image = Dinah.Core.Drawing.ImageReader.ToImage(coverArt));
#endregion
// thread-safe UI updates
private void updateBookInfo()
=> bookInfoLbl.UIThreadAsync(() => bookInfoLbl.Text = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}");
private void updateRemainingTime(int remaining)
=> remainingTimeLbl.UIThreadAsync(() => remainingTimeLbl.Text = $"ETA:\r\n{remaining} sec");
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -0,0 +1,31 @@
using DataLayer;
using FileManager;
using System;
namespace LibationWinForms.BookLiberation
{
class AudioDecryptForm : AudioDecodeForm
{
public AudioDecryptForm()
{
this.Load += (_, _) => this.RestoreSizeAndLocation(Configuration.Instance);
this.FormClosing += (_, _) => this.SaveSizeAndLocation(Configuration.Instance);
}
#region AudioDecodeForm overrides
public override string DecodeActionName => "Decrypting";
#endregion
#region IProcessable event handler overrides
public override void OnBegin(object sender, LibraryBook libraryBook)
{
LogMe.Info($"Download & Decrypt Step, Begin: {libraryBook.Book}");
base.OnBegin(sender, libraryBook);
}
public override void OnCompleted(object sender, LibraryBook libraryBook)
=> LogMe.Info($"Download & Decrypt Step, Completed: {libraryBook.Book}{Environment.NewLine}");
#endregion
}
}

View File

@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
<xsd:complexType>
<xsd:choice maxOccurs="unbounded">
<xsd:element name="metadata">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" />
</xsd:sequence>
<xsd:attribute name="name" use="required" type="xsd:string" />
<xsd:attribute name="type" type="xsd:string" />
<xsd:attribute name="mimetype" type="xsd:string" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="assembly">
<xsd:complexType>
<xsd:attribute name="alias" type="xsd:string" />
<xsd:attribute name="name" type="xsd:string" />
</xsd:complexType>
</xsd:element>
<xsd:element name="data">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
<xsd:element name="comment" type="xsd:string" minOccurs="0" msdata:Ordinal="2" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" msdata:Ordinal="1" />
<xsd:attribute name="type" type="xsd:string" msdata:Ordinal="3" />
<xsd:attribute name="mimetype" type="xsd:string" msdata:Ordinal="4" />
<xsd:attribute ref="xml:space" />
</xsd:complexType>
</xsd:element>
<xsd:element name="resheader">
<xsd:complexType>
<xsd:sequence>
<xsd:element name="value" type="xsd:string" minOccurs="0" msdata:Ordinal="1" />
</xsd:sequence>
<xsd:attribute name="name" type="xsd:string" use="required" />
</xsd:complexType>
</xsd:element>
</xsd:choice>
</xsd:complexType>
</xsd:element>
</xsd:schema>
<resheader name="resmimetype">
<value>text/microsoft-resx</value>
</resheader>
<resheader name="version">
<value>2.0</value>
</resheader>
<resheader name="reader">
<value>System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
</root>

View File

@@ -1,34 +1,34 @@
using System;
using System.Windows.Forms;
using Dinah.Core.Windows.Forms;
using Dinah.Core.Threading;
namespace LibationWinForms.BookLiberation
{
public partial class AutomatedBackupsForm : Form
{
public bool KeepGoingChecked => keepGoingCb.Checked;
public partial class AutomatedBackupsForm : Form
{
public bool KeepGoingChecked => keepGoingCb.Checked;
public bool KeepGoing => keepGoingCb.Enabled && keepGoingCb.Checked;
public bool KeepGoing => keepGoingCb.Enabled && keepGoingCb.Checked;
public AutomatedBackupsForm()
{
InitializeComponent();
}
public AutomatedBackupsForm()
{
InitializeComponent();
}
public void WriteLine(string text)
{
if (!IsDisposed)
logTb.UIThread(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}"));
}
public void WriteLine(string text)
{
if (!IsDisposed)
logTb.UIThreadAsync(() => logTb.AppendText($"{DateTime.Now} {text}{Environment.NewLine}"));
}
public void FinalizeUI()
{
keepGoingCb.Enabled = false;
{
keepGoingCb.Enabled = false;
if (!IsDisposed)
logTb.AppendText("");
}
if (!IsDisposed)
logTb.AppendText("");
}
private void AutomatedBackupsForm_FormClosing(object sender, FormClosingEventArgs e) => keepGoingCb.Checked = false;
}
private void AutomatedBackupsForm_FormClosing(object sender, FormClosingEventArgs e) => keepGoingCb.Checked = false;
}
}

View File

@@ -1,64 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">

View File

@@ -0,0 +1,160 @@
using System;
using System.Windows.Forms;
using DataLayer;
using Dinah.Core.Net.Http;
using Dinah.Core.Threading;
using FileLiberator;
namespace LibationWinForms.BookLiberation.BaseForms
{
public class LiberationBaseForm : Form
{
protected IStreamable Streamable { get; private set; }
protected LogMe LogMe { get; private set; }
private SynchronizeInvoker Invoker { get; init; }
public LiberationBaseForm()
{
//SynchronizationContext.Current will be null until the process contains a Form.
//If this is the first form created, it will not exist until after execution
//reaches inside the constructor (after base class has been initialized).
Invoker = new SynchronizeInvoker();
}
public void RegisterFileLiberator(IStreamable streamable, LogMe logMe = null)
{
if (streamable is null) return;
Streamable = streamable;
LogMe = logMe;
Subscribe(streamable);
if (Streamable is IProcessable processable)
Subscribe(processable);
if (Streamable is IAudioDecodable audioDecodable)
Subscribe(audioDecodable);
}
#region Event Subscribers and Unsubscribers
private void Subscribe(IStreamable streamable)
{
UnsubscribeStreamable(this, EventArgs.Empty);
streamable.StreamingBegin += OnStreamingBeginShow;
streamable.StreamingBegin += OnStreamingBegin;
streamable.StreamingProgressChanged += OnStreamingProgressChanged;
streamable.StreamingTimeRemaining += OnStreamingTimeRemaining;
streamable.StreamingCompleted += OnStreamingCompleted;
streamable.StreamingCompleted += OnStreamingCompletedClose;
Disposed += UnsubscribeStreamable;
}
private void Subscribe(IProcessable processable)
{
UnsubscribeProcessable(this, null);
processable.Begin += OnBegin;
processable.StatusUpdate += OnStatusUpdate;
processable.Completed += OnCompleted;
//The form is created on IProcessable.Begin and we
//dispose of it on IProcessable.Completed
processable.Completed += OnCompletedDispose;
//Don't unsubscribe from Dispose because it fires when
//IStreamable.StreamingCompleted closes the form, and
//the IProcessable events need to live past that event.
processable.Completed += UnsubscribeProcessable;
}
private void Subscribe(IAudioDecodable audioDecodable)
{
UnsubscribeAudioDecodable(this, EventArgs.Empty);
audioDecodable.RequestCoverArt += OnRequestCoverArt;
audioDecodable.TitleDiscovered += OnTitleDiscovered;
audioDecodable.AuthorsDiscovered += OnAuthorsDiscovered;
audioDecodable.NarratorsDiscovered += OnNarratorsDiscovered;
audioDecodable.CoverImageDiscovered += OnCoverImageDiscovered;
Disposed += UnsubscribeAudioDecodable;
}
private void UnsubscribeStreamable(object sender, EventArgs e)
{
Disposed -= UnsubscribeStreamable;
Streamable.StreamingBegin -= OnStreamingBeginShow;
Streamable.StreamingBegin -= OnStreamingBegin;
Streamable.StreamingProgressChanged -= OnStreamingProgressChanged;
Streamable.StreamingTimeRemaining -= OnStreamingTimeRemaining;
Streamable.StreamingCompleted -= OnStreamingCompleted;
Streamable.StreamingCompleted -= OnStreamingCompletedClose;
}
private void UnsubscribeProcessable(object sender, LibraryBook e)
{
if (Streamable is not IProcessable processable)
return;
processable.Completed -= UnsubscribeProcessable;
processable.Completed -= OnCompletedDispose;
processable.Completed -= OnCompleted;
processable.StatusUpdate -= OnStatusUpdate;
processable.Begin -= OnBegin;
}
private void UnsubscribeAudioDecodable(object sender, EventArgs e)
{
if (Streamable is not IAudioDecodable audioDecodable)
return;
Disposed -= UnsubscribeAudioDecodable;
audioDecodable.RequestCoverArt -= OnRequestCoverArt;
audioDecodable.TitleDiscovered -= OnTitleDiscovered;
audioDecodable.AuthorsDiscovered -= OnAuthorsDiscovered;
audioDecodable.NarratorsDiscovered -= OnNarratorsDiscovered;
audioDecodable.CoverImageDiscovered -= OnCoverImageDiscovered;
audioDecodable.Cancel();
}
#endregion
#region Form creation and disposal handling
/// <summary>
/// If the form was shown using Show (not ShowDialog), Form.Close calls Form.Dispose
/// </summary>
private void OnStreamingCompletedClose(object sender, string completedString) => this.UIThreadAsync(Close);
private void OnCompletedDispose(object sender, LibraryBook e) => this.UIThreadAsync(Dispose);
/// <summary>
/// If StreamingBegin is fired from a worker thread, the window will be created on that
/// worker thread. We need to make certain that we show the window on the UI thread (same
/// thread that created form), otherwise the renderer will be on a worker thread which
/// could cause it to freeze. Form.BeginInvoke won't work until the form is created
/// (ie. shown) because Control doesn't get a window handle until it is Shown.
/// </summary>
private void OnStreamingBeginShow(object sender, string beginString) => Invoker.UIThreadAsync(Show);
#endregion
#region IStreamable event handlers
public virtual void OnStreamingBegin(object sender, string beginString) { }
public virtual void OnStreamingProgressChanged(object sender, DownloadProgress downloadProgress) { }
public virtual void OnStreamingTimeRemaining(object sender, TimeSpan timeRemaining) { }
public virtual void OnStreamingCompleted(object sender, string completedString) { }
#endregion
#region IProcessable event handlers
public virtual void OnBegin(object sender, LibraryBook libraryBook) { }
public virtual void OnStatusUpdate(object sender, string statusUpdate) { }
public virtual void OnCompleted(object sender, LibraryBook libraryBook) { }
#endregion
#region IAudioDecodable event handlers
public virtual void OnRequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate) { }
public virtual void OnTitleDiscovered(object sender, string title) { }
public virtual void OnAuthorsDiscovered(object sender, string authors) { }
public virtual void OnNarratorsDiscovered(object sender, string narrators) { }
public virtual void OnCoverImageDiscovered(object sender, byte[] coverArt) { }
#endregion
}
}

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