Compare commits

...

341 Commits

Author SHA1 Message Date
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
rmcrackan
391f1f387b Update README.md 2021-07-21 14:18:45 -04:00
Robert McRackan
206890b8f3 settings folders need read-only textbox 2021-07-21 14:12:35 -04:00
Robert McRackan
9aa31338d6 New locale: Spain 2021-07-21 13:48:52 -04:00
Robert McRackan
35fe3ae786 New setting: dynamically change log level without app restart 2021-07-21 13:38:22 -04:00
Robert McRackan
b6fe3ae009 version 2021-07-21 07:10:26 -04:00
Robert McRackan
6ba8c0ca91 Decrypt form: remove debug window 2021-07-21 07:10:01 -04:00
Robert McRackan
01de928b7a Better validation when writing to decrypt log UI 2021-07-20 15:19:01 -04:00
Robert McRackan
0a54f8d881 v5.2.1 2021-07-20 14:47:15 -04:00
Robert McRackan
d31121e307 Bug fix to address #59 2021-07-20 14:43:08 -04:00
Robert McRackan
b86bd76726 New button in settings to open file logs folder 2021-07-20 14:37:32 -04:00
Robert McRackan
c49edbc77b Better error logging. MessageBoxAlertAdmin to make it easier for users to report errors 2021-07-20 14:27:27 -04:00
Robert McRackan
1ba54a74af rename basic/advanced settings in menu
*  basic => Settings
* advanced => Move settings files
2021-07-19 23:00:25 -04:00
Robert McRackan
9de08a332f v5.2.0 2021-07-19 14:53:10 -04:00
Robert McRackan
c9b434daed Overhaul of installation workflow per issue #36 2021-07-19 14:52:34 -04:00
rmcrackan
730484c28c Merge pull request #56 from Mbucari/master
Added MP3 support to settings.
2021-07-18 21:03:56 -04:00
Michael Bucari-Tovo
1a48dbe560 Fixed log. 2021-07-18 17:28:37 -06:00
Michael Bucari-Tovo
7df8c7427c Added MP3 conversion option. 2021-07-18 17:18:35 -06:00
Robert McRackan
8997f52505 replace dir selection controls in both settings dialogs with new ctrl.s 2021-07-18 17:48:15 -04:00
Robert McRackan
e61418c677 upgrade download bugfix 2021-07-18 16:29:31 -04:00
Robert McRackan
491aa67a81 add thread safety 2021-07-18 10:44:54 -04:00
Robert McRackan
7b3c857042 unsafe migration helper: Get => TryGet 2021-07-17 08:13:23 -04:00
Robert McRackan
71617b9620 Added UNSAFE_MigrationHelper to help with upgrades 2021-07-16 23:06:59 -04:00
Robert McRackan
f94f66da94 Revamped advanced settings dialog to use new control 2021-07-16 17:02:21 -04:00
Robert McRackan
2243e2a124 Get rid of meta directories. Centralize directory logic. New UI component to revamp settings and installation 2021-07-16 16:21:02 -04:00
Robert McRackan
5d6e3ea3f3 cancel buttons should set dialog result to Cancel 2021-07-15 17:14:45 -04:00
Robert McRackan
3c1c718bc7 don't attempt to overwrite advanced settings file if contents are unchanged 2021-07-15 17:14:20 -04:00
Robert McRackan
20a9e4b651 Simple winforms work in modern .net core designer. Only keep edge cases which work best in .net framework designer 2021-07-15 14:43:10 -04:00
Robert McRackan
f0daa12bb7 Remove unused interfaces and param 2021-07-15 11:48:52 -04:00
rmcrackan
c6e1278e42 Merge pull request #54 from Mbucari/master
Added Mp3 support to AAXClean.
2021-07-15 08:15:43 -04:00
Michael Bucari-Tovo
f5e8e4cd7f Removed indeterminate progress bar because decryption stattup time is now insignificant. 2021-07-15 00:17:59 -06:00
Michael Bucari-Tovo
f986462809 Changed default output type to Mp4a. 2021-07-14 23:58:52 -06:00
Michael Bucari-Tovo
49f2112c42 Updated for new AAXClean pattern. 2021-07-14 23:52:46 -06:00
Robert McRackan
0ce4faaf29 - Improved debugging for login
- Add warning when in Verbose logging mode
- Settings: don't try to re-save settings which haven't changed
- Remove unused logging config
2021-07-14 15:51:29 -04:00
Robert McRackan
bfd494cf93 remove unused setting 2021-07-13 14:59:55 -04:00
Robert McRackan
dc7ec3b328 auto-gened files 2021-07-13 14:59:41 -04:00
Robert McRackan
8f2827108b update gitignore 2021-07-13 07:14:32 -04:00
rmcrackan
fdcaf5e534 Merge pull request #53 from Mbucari/master
Removed Ffmpeg and taglib. Updated AAXClean.
2021-07-13 06:56:29 -04:00
Michael Bucari-Tovo
732695c019 Handle possible corrupt partial download. 2021-07-12 23:27:58 -06:00
Michael Bucari-Tovo
2a2faf6f7b Use new AAXClean.AaxFile type and remove unused dependences. 2021-07-12 23:13:18 -06:00
rmcrackan
c653e17c3d Merge pull request #52 from Mbucari/master
Minor fix.
2021-07-11 22:47:01 -04:00
Michael Bucari-Tovo
833bc3a8f2 Merge branch 'master' of https://github.com/Mbucari/Libation 2021-07-11 15:18:53 -06:00
Michael Bucari-Tovo
11e63ae5a2 Minor fix. 2021-07-11 15:18:07 -06:00
Robert McRackan
827eaefd29 Decryption moved to external project: AAXClean 2021-07-11 12:26:04 -04:00
rmcrackan
8240a97f6d Merge pull request #51 from Mbucari/master
Replaced FFMpeg decryptor and taglib with AAXClean
2021-07-11 12:14:15 -04:00
Mbucari
b766e43656 Merge branch 'master' into master 2021-07-11 09:17:07 -06:00
Robert McRackan
7d805728cb Login cvf bug fix 2021-07-11 09:51:39 -04:00
Michael Bucari-Tovo
c3c8a6fa6b Replaced FFMpeg decryptor and taglib with AAXClean 2021-07-10 20:21:28 -06:00
Robert McRackan
f40df002a2 Begin session logging with form feed 2021-07-09 11:34:20 -04:00
Robert McRackan
3180ea993c Startup logging: which logging levels are enabled 2021-07-09 11:06:57 -04:00
Robert McRackan
9f2fd54018 It helps if you wire-up the event. D'oh! 2021-07-09 10:20:46 -04:00
Robert McRackan
07532f7e65 "check for update" should not include pre-releases 2021-07-08 21:00:36 -04:00
Robert McRackan
4bae07d36c debugging added 2021-07-08 20:43:14 -04:00
Robert McRackan
bf23503d67 Added MFA-choice log in. Seems to come up more with German users 2021-07-08 17:04:11 -04:00
Robert McRackan
aeeba0d567 update ver. api bug fix 2021-07-08 10:54:38 -04:00
Robert McRackan
e2f919d625 "approval needed" ui improved wording 2021-07-07 21:34:05 -04:00
Robert McRackan
e821eea333 Bug: fix "approval" login step 2021-07-07 15:53:47 -04:00
Robert McRackan
8f487894f5 Fixed logging bug in single-book liberation 2021-07-04 16:10:11 -04:00
Robert McRackan
cd3e0dba68 Remove validation against 0-length chapters. It is evidently allowed 2021-07-04 16:08:30 -04:00
rmcrackan
6f31d97763 Merge pull request #47 from Mbucari/master
Addressed two issues and some minor fixed.
2021-07-04 09:27:58 -04:00
Mbucari
fa5637a340 Merge branch 'rmcrackan:master' into master 2021-07-03 22:07:34 -06:00
Michael Bucari-Tovo
7ab209171b Merge branch 'master' of https://github.com/Mbucari/Libation 2021-07-03 22:07:08 -06:00
Michael Bucari-Tovo
6d856f73e7 Reused yellow stoplight to indicate and interrupted and resumable download. 2021-07-03 22:06:56 -06:00
Michael Bucari-Tovo
05426eb618 Added uri refresh to step 2. 2021-07-03 21:54:28 -06:00
Michael Bucari-Tovo
d73701c939 Stop automatic processing if form is closed instead of crashing. 2021-07-03 20:34:50 -06:00
Michael Bucari-Tovo
f284f53edd Clicking on red stoplight now only decrypts that book with no conformation. 2021-07-03 20:21:11 -06:00
rmcrackan
17f3187748 Merge pull request #46 from Mbucari/master
Added resumable download support to FFMpegAaxcProcessor.
2021-07-03 21:06:25 -04:00
Mbucari
f55a41ac0a Merge branch 'rmcrackan:master' into master 2021-07-03 19:00:56 -06:00
Michael Bucari-Tovo
0be2a17537 Made FFMpegAaxcProcesser use NetworkFileStream. 2021-07-03 18:59:18 -06:00
rmcrackan
b417c5695e Merge pull request #45 from Mbucari/master
Fixed critical bug with Read stream not blocking.
2021-07-03 20:55:33 -04:00
Michael Bucari-Tovo
6efe064ca7 Added support for changing Uri to the same file in case iold one expires. 2021-07-03 17:15:35 -06:00
Michael Bucari-Tovo
da7af895fb Fixed possible hang issue. 2021-07-03 14:37:24 -06:00
Mbucari
1b39f30fd0 Merge branch 'rmcrackan:master' into master 2021-07-03 14:31:23 -06:00
Michael Bucari-Tovo
9cde6bddbd Fixed Read not blocking 2021-07-03 14:31:02 -06:00
rmcrackan
b21f257baa Merge pull request #43 from Mbucari/master
Modified NetworkFileStream to make it resumable.
2021-07-03 11:14:07 -04:00
Michael Bucari-Tovo
da68ddc9b8 Renamed property. 2021-07-03 06:13:38 -06:00
Michael Bucari-Tovo
9e15fde2e3 Modified NetworkFileStream to make is resumable. 2021-07-03 06:10:51 -06:00
rmcrackan
ef5b14a929 Merge pull request #40 from Mbucari/master
Addressed Issue #37 and minor corrections
2021-07-02 23:48:44 -04:00
Michael Bucari-Tovo
5df7d80aac Revert earlier. 2021-07-02 21:21:17 -06:00
Michael Bucari-Tovo
4b2c8ee513 Add any subtitle to the title. 2021-07-02 17:00:04 -06:00
Michael Bucari-Tovo
097bda2d25 Added null check. 2021-07-02 15:58:37 -06:00
Michael Bucari-Tovo
81195e382e Revert "Remove items from library."
This reverts commit 00f7e4b779.
2021-07-02 15:24:05 -06:00
Michael Bucari-Tovo
35fc3581b3 Revert "Added count of items removed from library."
This reverts commit 771d992da7.
2021-07-02 15:23:26 -06:00
Michael Bucari-Tovo
771d992da7 Added count of items removed from library. 2021-07-02 15:01:55 -06:00
Michael Bucari-Tovo
00f7e4b779 Remove items from library. 2021-07-02 14:07:42 -06:00
Michael Bucari-Tovo
5d4bcb2db0 Removed unnecessary conversion to List. 2021-07-02 13:51:32 -06:00
Mbucari
fbf92bf151 Merge branch 'rmcrackan:master' into master 2021-07-02 08:42:14 -06:00
Michael Bucari-Tovo
b9770220db Fixed stupid mistake. I need to go to ber. 2021-07-01 21:56:38 -06:00
258 changed files with 11680 additions and 12160 deletions

72
.gitignore vendored
View File

@@ -4,6 +4,7 @@
## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore
# User-specific files
*.rsuser
*.suo
*.user
*.userosscache
@@ -12,6 +13,9 @@
# User-specific files (MonoDevelop/Xamarin Studio)
*.userprefs
# Mono auto generated files
mono_crash.*
# Build results
[Dd]ebug/
[Dd]ebugPublic/
@@ -19,10 +23,15 @@
[Rr]eleases/
x64/
x86/
[Ww][Ii][Nn]32/
[Aa][Rr][Mm]/
[Aa][Rr][Mm]64/
bld/
[Bb]in/
[Oo]bj/
[Oo]ut/
[Ll]og/
[Ll]ogs/
# Visual Studio 2015/2017 cache/options directory
.vs/
@@ -36,9 +45,10 @@ Generated\ Files/
[Tt]est[Rr]esult*/
[Bb]uild[Ll]og.*
# NUNIT
# NUnit
*.VisualState.xml
TestResult.xml
nunit-*.xml
# Build Results of an ATL Project
[Dd]ebugPS/
@@ -52,7 +62,9 @@ BenchmarkDotNet.Artifacts/
project.lock.json
project.fragment.lock.json
artifacts/
**/Properties/launchSettings.json
# ASP.NET Scaffolding
ScaffoldingReadMe.txt
# StyleCop
StyleCopReport.xml
@@ -60,7 +72,7 @@ StyleCopReport.xml
# Files built by Visual Studio
*_i.c
*_p.c
*_i.h
*_h.h
*.ilk
*.meta
*.obj
@@ -77,6 +89,7 @@ StyleCopReport.xml
*.tlh
*.tmp
*.tmp_proj
*_wpftmp.csproj
*.log
*.vspscc
*.vssscc
@@ -119,9 +132,6 @@ _ReSharper*/
*.[Rr]e[Ss]harper
*.DotSettings.user
# JustCode is a .NET coding add-in
.JustCode
# TeamCity is a build add-in
_TeamCity*
@@ -132,6 +142,11 @@ _TeamCity*
.axoCover/*
!.axoCover/settings.json
# Coverlet is a free, cross platform Code Coverage Tool
coverage*.json
coverage*.xml
coverage*.info
# Visual Studio code coverage results
*.coverage
*.coveragexml
@@ -179,6 +194,8 @@ PublishScripts/
# NuGet Packages
*.nupkg
# NuGet Symbol Packages
*.snupkg
# The packages folder can be ignored because of Package Restore
**/[Pp]ackages/*
# except build/, which is used as an MSBuild target.
@@ -203,12 +220,14 @@ BundleArtifacts/
Package.StoreAssociation.xml
_pkginfo.txt
*.appx
*.appxbundle
*.appxupload
# Visual Studio cache files
# files ending in .cache can be ignored
*.[Cc]ache
# but keep track of directories ending in .cache
!*.[Cc]ache/
!?*.[Cc]ache/
# Others
ClientBin/
@@ -221,7 +240,7 @@ ClientBin/
*.publishsettings
orleans.codegen.cs
# Including strong name files can present a security risk
# Including strong name files can present a security risk
# (https://github.com/github/gitignore/pull/2483#issue-259490424)
#*.snk
@@ -252,6 +271,9 @@ ServiceFabricBackup/
*.bim.layout
*.bim_*.settings
*.rptproj.rsuser
*- [Bb]ackup.rdl
*- [Bb]ackup ([0-9]).rdl
*- [Bb]ackup ([0-9][0-9]).rdl
# Microsoft Fakes
FakesAssemblies/
@@ -287,12 +309,8 @@ paket-files/
# FAKE - F# Make
.fake/
# JetBrains Rider
.idea/
*.sln.iml
# CodeRush
.cr/
# CodeRush personal settings
.cr/personal
# Python Tools for Visual Studio (PTVS)
__pycache__/
@@ -317,7 +335,7 @@ __pycache__/
# OpenCover UI analysis results
OpenCover/
# Azure Stream Analytics local run output
# Azure Stream Analytics local run output
ASALocalRun/
# MSBuild Binary and Structured Log
@@ -326,11 +344,29 @@ ASALocalRun/
# NVidia Nsight GPU debugger configuration file
*.nvuser
# MFractors (Xamarin productivity tool) working folder
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# Local History for Visual Studio
.localhistory/
# BeatPulse healthcheck temp database
healthchecksdb
# Backup folder for Package Reference Convert tool in Visual Studio 2017
MigrationBackup/
# Ionide (cross platform F# VS Code tools) working folder
.ionide/
# Fody - auto-generated XML schema
FodyWeavers.xsd
### manually ignored files
# Windows shortcuts
*.lnk
# manually ignored files
/__TODO.txt
/DataLayer/LibationContext.db
*.lnk

View File

@@ -5,41 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="TagLibSharp" Version="2.2.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="DecryptLib\avcodec-58.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\avdevice-58.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\avfilter-7.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\avformat-58.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\avutil-56.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\ffmpeg.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\ffprobe.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\swresample-3.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<None Update="DecryptLib\swscale-5.dll">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
<PackageReference Include="AAXClean" Version="0.1.8" />
<PackageReference Include="Dinah.Core" Version="1.1.0.1" />
</ItemGroup>
</Project>

View File

@@ -1,103 +1,74 @@
using Dinah.Core;
using Dinah.Core.Diagnostics;
using AAXClean;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using System;
using System.IO;
namespace AaxDecrypter
{
public interface ISimpleAaxcToM4bConverter
public enum OutputFormat { Mp4a, Mp3 }
public class AaxcDownloadConverter
{
event EventHandler<AaxcTagLibFile> RetrievedTags;
event EventHandler<byte[]> RetrievedCoverArt;
event EventHandler<TimeSpan> DecryptTimeRemaining;
event EventHandler<int> DecryptProgressUpdate;
bool Run();
string AppName { get; set; }
string outDir { get; }
string outputFileName { get; }
DownloadLicense downloadLicense { get; }
AaxcTagLibFile aaxcTagLib { get; }
byte[] coverArt { get; }
void SetCoverArt(byte[] coverArt);
void SetOutputFilename(string outFileName);
}
public interface IAdvancedAaxcToM4bConverter : ISimpleAaxcToM4bConverter
{
void Cancel();
bool Step1_CreateDir();
bool Step2_GetMetadata();
bool Step3_DownloadAndCombine();
bool Step4_RestoreMetadata();
bool Step5_CreateCue();
bool Step6_CreateNfo();
}
public class AaxcDownloadConverter : IAdvancedAaxcToM4bConverter
{
public event EventHandler<AaxcTagLibFile> RetrievedTags;
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 outDir { get; private set; }
public string outputFileName { get; private set; }
public DownloadLicense downloadLicense { get; private set; }
public AaxcTagLibFile aaxcTagLib { get; private set; }
public byte[] coverArt { get; private set; }
private string outputFileName { get; }
private string cacheDir { get; }
private DownloadLicense downloadLicense { get; }
private AaxFile aaxFile;
private OutputFormat OutputFormat;
private StepSequence steps { get; }
private FFMpegAaxcProcesser aaxcProcesser;
private NetworkFileStreamPersister nfsPersister;
private bool isCanceled { get; set; }
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(outputFileName) + ".json");
private string tempFile => PathLib.ReplaceExtension(jsonDownloadState, ".aaxc");
public static AaxcDownloadConverter Create(string outDirectory, DownloadLicense dlLic)
public AaxcDownloadConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
{
var converter = new AaxcDownloadConverter(outDirectory, dlLic);
converter.SetOutputFilename(Path.GetTempFileName());
return converter;
}
ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
outputFileName = outFileName;
private AaxcDownloadConverter(string outDirectory, DownloadLicense dlLic)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
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 (!Directory.Exists(outDirectory))
throw new ArgumentNullException(nameof(outDirectory), "Directory does not exist");
outDir = outDirectory;
if (!Directory.Exists(cacheDirectory))
throw new ArgumentNullException(nameof(cacheDirectory), "Directory does not exist");
cacheDir = cacheDirectory;
downloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
OutputFormat = outputFormat;
steps = new StepSequence
{
Name = "Convert Aax To M4b",
Name = "Download and Convert Aaxc To " + (outputFormat == OutputFormat.Mp4a ? "M4b" : "Mp3"),
["Step 1: Create Dir"] = Step1_CreateDir,
["Step 2: Get Aaxc Metadata"] = Step2_GetMetadata,
["Step 3: Download Decrypted Audiobook"] = Step3_DownloadAndCombine,
["Step 4: Restore Aaxc Metadata"] = Step4_RestoreMetadata,
["Step 5: Create Cue"] = Step5_CreateCue,
["Step 6: Create Nfo"] = Step6_CreateNfo,
["Step 1: Get Aaxc Metadata"] = Step1_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step2_DownloadAndCombine,
["Step 3: Create Cue"] = Step3_CreateCue,
["Step 4: Create Nfo"] = Step4_CreateNfo,
["Step 5: Cleanup"] = Step5_Cleanup,
};
aaxcProcesser = new FFMpegAaxcProcesser(dlLic);
aaxcProcesser.ProgressUpdate += AaxcProcesser_ProgressUpdate;
downloadLicense = dlLic;
}
public void SetOutputFilename(string outFileName)
{
outputFileName = PathLib.ReplaceExtension(outFileName, ".m4b");
outDir = Path.GetDirectoryName(outputFileName);
if (File.Exists(outputFileName))
File.Delete(outputFileName);
}
/// <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);
}
@@ -111,134 +82,149 @@ namespace AaxDecrypter
return false;
}
var speedup = (int)(aaxcTagLib.Properties.Duration.TotalSeconds / (long)Elapsed.TotalSeconds);
Console.WriteLine("Speedup is " + speedup + "x realtime.");
Console.WriteLine("Done");
var speedup = (int)(aaxFile.Duration.TotalSeconds / (long)Elapsed.TotalSeconds);
Serilog.Log.Logger.Information($"Speedup is {speedup}x realtime.");
return true;
}
public bool Step1_CreateDir()
{
ProcessRunner.WorkingDir = outDir;
Directory.CreateDirectory(outDir);
return !isCanceled;
}
public bool Step2_GetMetadata()
public bool Step1_GetMetadata()
{
//Get metadata from the file over http
var client = new System.Net.Http.HttpClient();
client.DefaultRequestHeaders.Add("User-Agent", downloadLicense.UserAgent);
var networkFile = NetworkFileAbstraction.CreateAsync(client, new Uri(downloadLicense.DownloadUrl)).GetAwaiter().GetResult();
aaxcTagLib = new AaxcTagLibFile(networkFile);
if (coverArt is null && aaxcTagLib.AppleTags.Pictures.Length > 0)
if (File.Exists(jsonDownloadState))
{
coverArt = aaxcTagLib.AppleTags.Pictures[0].Data.Data;
try
{
nfsPersister = new NetworkFileStreamPersister(jsonDownloadState);
//If More than ~1 hour has elapsed since getting the download url, it will expire.
//The new url will be to the same file.
nfsPersister.NetworkFileStream.SetUriForSameFile(new Uri(downloadLicense.DownloadUrl));
}
catch
{
FileExt.SafeDelete(jsonDownloadState);
FileExt.SafeDelete(tempFile);
nfsPersister = NewNetworkFilePersister();
}
}
else
{
nfsPersister = NewNetworkFilePersister();
}
RetrievedTags?.Invoke(this, aaxcTagLib);
RetrievedCoverArt?.Invoke(this, coverArt);
aaxFile = new AaxFile(nfsPersister.NetworkFileStream);
RetrievedTags?.Invoke(this, aaxFile.AppleTags);
RetrievedCoverArt?.Invoke(this, aaxFile.AppleTags.Cover);
return !isCanceled;
}
public bool Step3_DownloadAndCombine()
private NetworkFileStreamPersister NewNetworkFilePersister()
{
DecryptProgressUpdate?.Invoke(this, int.MaxValue);
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", downloadLicense.UserAgent }
};
bool userSuppliedChapters = downloadLicense.ChapterInfo != null;
string metadataPath = null;
if (userSuppliedChapters)
{
//Only write chaopters to the metadata file. All other aaxc metadata will be
//wiped out but is restored in Step 3.
metadataPath = Path.Combine(outDir, Path.GetFileName(outputFileName) + ".ffmeta");
File.WriteAllText(metadataPath, downloadLicense.ChapterInfo.ToFFMeta(true));
}
aaxcProcesser.ProcessBook(
outputFileName,
metadataPath)
.GetAwaiter()
.GetResult();
if (!userSuppliedChapters && aaxcProcesser.Succeeded)
downloadLicense.ChapterInfo = new ChapterInfo(outputFileName);
if (userSuppliedChapters)
FileExt.SafeDelete(metadataPath);
DecryptProgressUpdate?.Invoke(this, 0);
return aaxcProcesser.Succeeded && !isCanceled;
var networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
private void AaxcProcesser_ProgressUpdate(object sender, AaxcProcessUpdate e)
public bool Step2_DownloadAndCombine()
{
double remainingSecsToProcess = (aaxcTagLib.Properties.Duration - e.ProcessPosition).TotalSeconds;
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
};
DecryptProgressUpdate?.Invoke(this, zeroProgress);
if (File.Exists(outputFileName))
FileExt.SafeDelete(outputFileName);
FileStream outFile = File.OpenWrite(outputFileName);
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;
aaxFile.Close();
downloadLicense.ChapterInfo = aaxFile.Chapters;
nfsPersister.Dispose();
DecryptProgressUpdate?.Invoke(this, zeroProgress);
return decryptionResult == ConversionResult.NoErrorsDetected && !isCanceled;
}
private void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = aaxFile.Duration;
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
DecryptTimeRemaining?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / aaxcTagLib.Properties.Duration.TotalSeconds;
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
});
}
/// <summary>
/// Copy all aacx metadata to m4b file, including cover art.
/// </summary>
public bool Step4_RestoreMetadata()
{
var outFile = new AaxcTagLibFile(outputFileName);
outFile.CopyTagsFrom(aaxcTagLib);
if (outFile.AppleTags.Pictures.Length == 0 && coverArt is not null)
{
outFile.AddPicture(coverArt);
}
outFile.Save();
return !isCanceled;
}
public bool Step5_CreateCue()
public bool Step3_CreateCue()
{
// 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));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step5_CreateCue)}. FAILED");
Serilog.Log.Logger.Error(ex, $"{nameof(Step3_CreateCue)}. FAILED");
}
return !isCanceled;
}
public bool Step6_CreateNfo()
public bool Step4_CreateNfo()
{
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, aaxcTagLib, downloadLicense.ChapterInfo));
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step5_CreateCue)}. FAILED");
Serilog.Log.Logger.Error(ex, $"{nameof(Step4_CreateNfo)}. FAILED");
}
return !isCanceled;
}
public bool Step5_Cleanup()
{
FileExt.SafeDelete(jsonDownloadState);
FileExt.SafeDelete(tempFile);
return !isCanceled;
}
public void Cancel()
{
isCanceled = true;
aaxcProcesser.Cancel();
aaxFile?.Cancel();
aaxFile?.Dispose();
nfsPersister?.NetworkFileStream?.Close();
nfsPersister?.Dispose();
}
}
}

View File

@@ -1,75 +0,0 @@
using System;
using System.Linq;
using Dinah.Core;
using TagLib;
using TagLib.Mpeg4;
namespace AaxDecrypter
{
public class AaxcTagLibFile : TagLib.Mpeg4.File
{
// ©
private const byte COPYRIGHT = 0xa9;
private static ReadOnlyByteVector narratorType { get; } = new ReadOnlyByteVector(COPYRIGHT, (byte)'n', (byte)'r', (byte)'t');
private static ReadOnlyByteVector descriptionType { get; } = new ReadOnlyByteVector(COPYRIGHT, (byte)'d', (byte)'e', (byte)'s');
private static ReadOnlyByteVector publisherType { get; } = new ReadOnlyByteVector(COPYRIGHT, (byte)'p', (byte)'u', (byte)'b');
public string AsciiTitleSansUnabridged => TitleSansUnabridged?.UnicodeToAscii();
public string AsciiFirstAuthor => FirstAuthor?.UnicodeToAscii();
public string AsciiNarrator => Narrator?.UnicodeToAscii();
public string AsciiComment => Comment?.UnicodeToAscii();
public string AsciiLongDescription => LongDescription?.UnicodeToAscii();
public AppleTag AppleTags => GetTag(TagTypes.Apple) as AppleTag;
public string Comment => AppleTags.Comment;
public string[] Authors => AppleTags.Performers;
public string FirstAuthor => Authors?.Length > 0 ? Authors[0] : default;
public string TitleSansUnabridged => AppleTags.Title?.Replace(" (Unabridged)", "");
public string BookCopyright => _copyright is not null && _copyright.Length > 0 ? _copyright[0] : default;
public string RecordingCopyright => _copyright is not null && _copyright.Length > 1 ? _copyright[1] : default;
private string[] _copyright => AppleTags.Copyright?.Replace("&#169;", string.Empty)?.Replace("(P)", string.Empty)?.Split(';');
public string Narrator => getAppleTagsText(narratorType);
public string LongDescription => getAppleTagsText(descriptionType);
public string ReleaseDate => getAppleTagsText("rldt");
public string Publisher => getAppleTagsText(publisherType);
private string getAppleTagsText(ByteVector byteVector)
{
string[] text = AppleTags.GetText(byteVector);
return text.Length == 0 ? default : text[0];
}
public AaxcTagLibFile(IFileAbstraction abstraction)
: base(abstraction, ReadStyle.Average)
{
}
public AaxcTagLibFile(string path)
: this(new LocalFileAbstraction(path))
{
}
/// <summary>
/// Copy all metadata fields in the source file, even those that TagLib doesn't
/// recognize, to the output file.
/// NOTE: Chapters aren't stored in MPEG-4 metadata. They are encoded as a Timed
/// Text Stream (MPEG-4 Part 17), so taglib doesn't read or write them.
/// </summary>
/// <param name="sourceFile">File from which tags will be coppied.</param>
public void CopyTagsFrom(AaxcTagLibFile sourceFile)
{
AppleTags.Clear();
foreach (var stag in sourceFile.AppleTags)
{
AppleTags.SetData(stag.BoxType, stag.Children.Cast<AppleDataBox>().ToArray());
}
}
public void AddPicture(byte[] coverArt)
{
AppleTags.SetData("covr", coverArt, 0);
}
}
}

View File

@@ -1,85 +0,0 @@
using Dinah.Core;
using Dinah.Core.Diagnostics;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Text;
namespace AaxDecrypter
{
public class ChapterInfo
{
private List<Chapter> _chapterList = new List<Chapter>();
public IEnumerable<Chapter> Chapters => _chapterList.AsEnumerable();
public int Count => _chapterList.Count;
public ChapterInfo() { }
public ChapterInfo(string audiobookFile)
{
var info = new ProcessStartInfo
{
FileName = DecryptSupportLibraries.ffprobePath,
Arguments = "-loglevel panic -show_chapters -print_format json \"" + audiobookFile + "\""
};
var jString = info.RunHidden().Output;
var chapterJObject = JObject.Parse(jString);
var chapters = chapterJObject["chapters"]
.Select(c => new Chapter(
c["tags"]?["title"]?.Value<string>(),
c["start_time"].Value<double>(),
c["end_time"].Value<double>()
));
_chapterList.AddRange(chapters);
}
public void AddChapter(Chapter chapter)
{
ArgumentValidator.EnsureNotNull(chapter, nameof(chapter));
_chapterList.Add(chapter);
}
public string ToFFMeta(bool includeFFMetaHeader)
{
var ffmetaChapters = new StringBuilder();
if (includeFFMetaHeader)
ffmetaChapters.AppendLine(";FFMETADATA1\n");
foreach (var c in Chapters)
{
ffmetaChapters.AppendLine(c.ToFFMeta());
}
return ffmetaChapters.ToString();
}
}
public class Chapter
{
public string Title { get; }
public TimeSpan StartOffset { get; }
public TimeSpan EndOffset { get; }
public Chapter(string title, long startOffsetMs, long lengthMs)
{
ArgumentValidator.EnsureNotNullOrEmpty(title, nameof(title));
ArgumentValidator.EnsureGreaterThan(startOffsetMs, nameof(startOffsetMs), -1);
ArgumentValidator.EnsureGreaterThan(lengthMs, nameof(lengthMs), 0);
Title = title;
StartOffset = TimeSpan.FromMilliseconds(startOffsetMs);
EndOffset = StartOffset + TimeSpan.FromMilliseconds(lengthMs);
}
public Chapter(string title, double startTimeSec, double endTimeSec)
:this(title, (long)(startTimeSec * 1000), (long)((endTimeSec - startTimeSec) * 1000))
{
}
public string ToFFMeta()
{
return "[CHAPTER]\n" +
"TIMEBASE=1/1000\n" +
"START=" + StartOffset.TotalMilliseconds + "\n" +
"END=" + EndOffset.TotalMilliseconds + "\n" +
"title=" + Title;
}
}
}

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Text;
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

Binary file not shown.

View File

@@ -1,16 +0,0 @@
using System.IO;
namespace AaxDecrypter
{
public static class DecryptSupportLibraries
{
// OTHER EXTERNAL DEPENDENCIES
// ffprobe has these pre-req.s as I'm using it:
// avcodec-58.dll, avdevice-58.dll, avfilter-7.dll, avformat-58.dll, avutil-56.dll, swresample-3.dll, swscale-5.dll, taglib-sharp.dll
private static string appPath_ { get; } = Path.GetDirectoryName(Dinah.Core.Exe.FileLocationOnDisk);
private static string decryptLib_ { get; } = Path.Combine(appPath_, "DecryptLib");
public static string ffmpegPath { get; } = Path.Combine(decryptLib_, "ffmpeg.exe");
public static string ffprobePath { get; } = Path.Combine(decryptLib_, "ffprobe.exe");
}
}

View File

@@ -1,4 +1,5 @@
using Dinah.Core;
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
{

View File

@@ -1,244 +0,0 @@
using System;
using System.Diagnostics;
using System.IO;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks;
namespace AaxDecrypter
{
class AaxcProcessUpdate
{
public AaxcProcessUpdate(TimeSpan position, double speed)
{
ProcessPosition = position;
ProcessSpeed = speed;
EventTime = DateTime.Now;
}
public TimeSpan ProcessPosition { get; }
public double ProcessSpeed { get; }
public DateTime EventTime { get; }
}
/// <summary>
/// Download audible aaxc, decrypt, and remux with chapters.
/// </summary>
class FFMpegAaxcProcesser
{
public event EventHandler<AaxcProcessUpdate> ProgressUpdate;
public string FFMpegPath { get; }
public DownloadLicense DownloadLicense { get; }
public bool IsRunning { get; private set; }
public bool Succeeded { get; private set; }
public string FFMpegRemuxerStandardError => remuxerError.ToString();
public string FFMpegDownloaderStandardError => downloaderError.ToString();
private StringBuilder remuxerError { get; } = new StringBuilder();
private StringBuilder downloaderError { get; } = new StringBuilder();
private static Regex processedTimeRegex { get; } = new Regex("time=(\\d{2}):(\\d{2}):(\\d{2}).\\d{2}.*speed=\\s{0,1}([0-9]*[.]?[0-9]+)(?:e\\+([0-9]+)){0,1}", RegexOptions.IgnoreCase | RegexOptions.Compiled);
private Process downloader;
private Process remuxer;
private bool isCanceled = false;
public FFMpegAaxcProcesser( DownloadLicense downloadLicense)
{
FFMpegPath = DecryptSupportLibraries.ffmpegPath;
DownloadLicense = downloadLicense;
}
public async Task ProcessBook(string outputFile, string ffmetaChaptersPath = null)
{
//This process gets the aaxc from the url and streams the decrypted
//aac stream to standard output
downloader = new Process
{
StartInfo = getDownloaderStartInfo()
};
//This process retreves an aac stream from standard input and muxes
// it into an m4b along with the cover art and metadata.
remuxer = new Process
{
StartInfo = getRemuxerStartInfo(outputFile, ffmetaChaptersPath)
};
IsRunning = true;
downloader.ErrorDataReceived += Downloader_ErrorDataReceived;
downloader.Start();
downloader.BeginErrorReadLine();
remuxer.ErrorDataReceived += Remuxer_ErrorDataReceived;
remuxer.Start();
remuxer.BeginErrorReadLine();
//Thic check needs to be placed after remuxer has started
if (isCanceled) return;
var pipedOutput = downloader.StandardOutput.BaseStream;
var pipedInput = remuxer.StandardInput.BaseStream;
//All the work done here. Copy download standard output into
//remuxer standard input
await Task.Run(() =>
{
int lastRead = 0;
byte[] buffer = new byte[32 * 1024];
do
{
lastRead = pipedOutput.Read(buffer, 0, buffer.Length);
pipedInput.Write(buffer, 0, lastRead);
} while (lastRead > 0 && !remuxer.HasExited);
});
//Closing input stream terminates remuxer
pipedInput.Close();
//If the remuxer exited due to failure, downloader will still have
//data in the pipe. Force kill downloader to continue.
if (remuxer.HasExited && !downloader.HasExited)
downloader.Kill();
remuxer.WaitForExit();
downloader.WaitForExit();
IsRunning = false;
Succeeded = downloader.ExitCode == 0 && remuxer.ExitCode == 0;
}
public void Cancel()
{
isCanceled = true;
if (IsRunning && !remuxer.HasExited)
remuxer.Kill();
if (IsRunning && !downloader.HasExited)
downloader.Kill();
}
private void Downloader_ErrorDataReceived(object sender, DataReceivedEventArgs e)
{
if (string.IsNullOrEmpty(e.Data))
return;
downloaderError.AppendLine(e.Data);
}
private void Remuxer_ErrorDataReceived(object sender, DataReceivedEventArgs e)
{
if (string.IsNullOrEmpty(e.Data))
return;
remuxerError.AppendLine(e.Data);
if (processedTimeRegex.IsMatch(e.Data))
{
//get timestamp of of last processed audio stream position
//and processing speed
var match = processedTimeRegex.Match(e.Data);
int hours = int.Parse(match.Groups[1].Value);
int minutes = int.Parse(match.Groups[2].Value);
int seconds = int.Parse(match.Groups[3].Value);
var position = new TimeSpan(hours, minutes, seconds);
double speed = double.Parse(match.Groups[4].Value);
int exp = match.Groups[5].Success ? int.Parse(match.Groups[5].Value) : 0;
speed *= Math.Pow(10, exp);
ProgressUpdate?.Invoke(this, new AaxcProcessUpdate(position, speed));
}
if (e.Data.Contains("aac bitstream error"))
{
//This happens if input is corrupt (should never happen) or if caller
//supplied wrong key/iv
var process = sender as Process;
process.Kill();
}
}
private ProcessStartInfo getDownloaderStartInfo() =>
new ProcessStartInfo
{
FileName = FFMpegPath,
RedirectStandardError = true,
RedirectStandardOutput = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(FFMpegPath),
ArgumentList ={
"-nostdin",
"-audible_key",
DownloadLicense.AudibleKey,
"-audible_iv",
DownloadLicense.AudibleIV,
"-user_agent",
DownloadLicense.UserAgent, //user-agent is requied for CDN to serve the file
"-i",
DownloadLicense.DownloadUrl,
"-c:a", //audio codec
"copy", //copy stream
"-f", //force output format: adts
"adts",
"pipe:" //pipe output to stdout
}
};
private ProcessStartInfo getRemuxerStartInfo(string outputFile, string ffmetaChaptersPath = null)
{
var startInfo = new ProcessStartInfo
{
FileName = FFMpegPath,
RedirectStandardError = true,
RedirectStandardInput = true,
CreateNoWindow = true,
WindowStyle = ProcessWindowStyle.Hidden,
UseShellExecute = false,
WorkingDirectory = Path.GetDirectoryName(FFMpegPath),
};
startInfo.ArgumentList.Add("-thread_queue_size");
startInfo.ArgumentList.Add("1024");
startInfo.ArgumentList.Add("-f"); //force input format: aac
startInfo.ArgumentList.Add("aac");
startInfo.ArgumentList.Add("-i"); //read input from stdin
startInfo.ArgumentList.Add("pipe:");
if (ffmetaChaptersPath is null)
{
//copy metadata from aaxc file.
startInfo.ArgumentList.Add("-user_agent");
startInfo.ArgumentList.Add(DownloadLicense.UserAgent);
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(DownloadLicense.DownloadUrl);
}
else
{
//copy metadata from supplied metadata file
startInfo.ArgumentList.Add("-f");
startInfo.ArgumentList.Add("ffmetadata");
startInfo.ArgumentList.Add("-i");
startInfo.ArgumentList.Add(ffmetaChaptersPath);
}
startInfo.ArgumentList.Add("-map"); //map file 0 (aac audio stream)
startInfo.ArgumentList.Add("0");
startInfo.ArgumentList.Add("-map_chapters"); //copy chapter data from file 1 (either metadata file or aaxc file)
startInfo.ArgumentList.Add("1");
startInfo.ArgumentList.Add("-c"); //copy all mapped streams
startInfo.ArgumentList.Add("copy");
startInfo.ArgumentList.Add("-f"); //force output format: mp4
startInfo.ArgumentList.Add("mp4");
startInfo.ArgumentList.Add("-movflags");
startInfo.ArgumentList.Add("disable_chpl"); //Disable Nero chapters format
startInfo.ArgumentList.Add(outputFile);
startInfo.ArgumentList.Add("-y"); //overwrite existing
return startInfo;
}
}
}

View File

@@ -1,50 +1,52 @@

using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
{
public static class NFO
{
public static string CreateContents(string ripper, AaxcTagLibFile aaxcTagLib, ChapterInfo chapters)
public static string CreateContents(string ripper, Mp4File aaxcTagLib, ChapterInfo chapters)
{
var _hours = (int)aaxcTagLib.Properties.Duration.TotalHours;
var _hours = (int)aaxcTagLib.Duration.TotalHours;
var myDuration
= (_hours > 0 ? _hours + " hours, " : string.Empty)
+ aaxcTagLib.Properties.Duration.Minutes + " minutes, "
+ aaxcTagLib.Properties.Duration.Seconds + " seconds";
+ aaxcTagLib.Duration.Minutes + " minutes, "
+ aaxcTagLib.Duration.Seconds + " seconds";
var nfoString
= "General Information\r\n"
+ "======================\r\n"
+ $" Title: {aaxcTagLib.AsciiTitleSansUnabridged ?? "[unknown]"}\r\n"
+ $" Author: {aaxcTagLib.AsciiFirstAuthor ?? "[unknown]"}\r\n"
+ $" Read By: {aaxcTagLib.AsciiNarrator ?? "[unknown]"}\r\n"
+ $" Release Date: {aaxcTagLib.ReleaseDate ?? "[unknown]"}\r\n"
+ $" Book Copyright: {aaxcTagLib.BookCopyright ?? "[unknown]"}\r\n"
+ $" Recording Copyright: {aaxcTagLib.RecordingCopyright ?? "[unknown]"}\r\n"
+ $" Genre: {aaxcTagLib.AppleTags.FirstGenre ?? "[unknown]"}\r\n"
+ $" Publisher: {aaxcTagLib.Publisher ?? "[unknown]"}\r\n"
+ $" Title: {aaxcTagLib.AppleTags.TitleSansUnabridged?.UnicodeToAscii() ?? "[unknown]"}\r\n"
+ $" Author: {aaxcTagLib.AppleTags.FirstAuthor?.UnicodeToAscii() ?? "[unknown]"}\r\n"
+ $" Read By: {aaxcTagLib.AppleTags.Narrator?.UnicodeToAscii() ?? "[unknown]"}\r\n"
+ $" Release Date: {aaxcTagLib.AppleTags.ReleaseDate ?? "[unknown]"}\r\n"
+ $" Book Copyright: {aaxcTagLib.AppleTags.BookCopyright ?? "[unknown]"}\r\n"
+ $" Recording Copyright: {aaxcTagLib.AppleTags.RecordingCopyright ?? "[unknown]"}\r\n"
+ $" Genre: {aaxcTagLib.AppleTags.Generes ?? "[unknown]"}\r\n"
+ $" Publisher: {aaxcTagLib.AppleTags.Publisher ?? "[unknown]"}\r\n"
+ $" Duration: {myDuration}\r\n"
+ $" Chapters: {chapters.Count}\r\n"
+ "\r\n"
+ "\r\n"
+ "Media Information\r\n"
+ "======================\r\n"
+ " Source Format: Audible AAX\r\n"
+ $" Source Sample Rate: {aaxcTagLib.Properties.AudioSampleRate} Hz\r\n"
+ $" Source Channels: {aaxcTagLib.Properties.AudioChannels}\r\n"
+ $" Source Bitrate: {aaxcTagLib.Properties.AudioBitrate} Kbps\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"
+ "\r\n"
+ " Lossless Encode: Yes\r\n"
+ " Encoded Codec: AAC / M4B\r\n"
+ $" Encoded Sample Rate: {aaxcTagLib.Properties.AudioSampleRate} Hz\r\n"
+ $" Encoded Channels: {aaxcTagLib.Properties.AudioChannels}\r\n"
+ $" Encoded Bitrate: {aaxcTagLib.Properties.AudioBitrate} Kbps\r\n"
+ $" Encoded Sample Rate: {aaxcTagLib.TimeScale} Hz\r\n"
+ $" Encoded Channels: {aaxcTagLib.AudioChannels}\r\n"
+ $" Encoded Bitrate: {aaxcTagLib.AverageBitrate} Kbps\r\n"
+ "\r\n"
+ $" Ripper: {ripper}\r\n"
+ "\r\n"
+ "\r\n"
+ "Book Description\r\n"
+ "================\r\n"
+ (!string.IsNullOrWhiteSpace(aaxcTagLib.LongDescription) ? aaxcTagLib.AsciiLongDescription : aaxcTagLib.AsciiComment);
+ (!string.IsNullOrWhiteSpace(aaxcTagLib.AppleTags.LongDescription) ? aaxcTagLib.AppleTags.LongDescription.UnicodeToAscii() : aaxcTagLib.AppleTags.Comment?.UnicodeToAscii());
return nfoString;
}

View File

@@ -1,135 +0,0 @@
using System;
using System.IO;
using System.Net.Http;
using System.Threading.Tasks;
namespace AaxDecrypter
{
/// <summary>
/// Provides a <see cref="TagLib.File.IFileAbstraction"/> for a file over Http.
/// </summary>
class NetworkFileAbstraction : TagLib.File.IFileAbstraction
{
private NetworkFileStream aaxNetworkStream;
public static async Task<NetworkFileAbstraction> CreateAsync(HttpClient client, Uri webFileUri)
{
var response = await client.GetAsync(webFileUri, HttpCompletionOption.ResponseHeadersRead);
if (response.StatusCode != System.Net.HttpStatusCode.OK)
throw new Exception("Can't read file from client.");
var contentLength = response.Content.Headers.ContentLength ?? 0;
var networkStream = await response.Content.ReadAsStreamAsync();
var networkFile = new NetworkFileAbstraction(Path.GetFileName(webFileUri.LocalPath), networkStream, contentLength);
return networkFile;
}
private NetworkFileAbstraction(string fileName, Stream netStream, long contentLength)
{
Name = fileName;
aaxNetworkStream = new NetworkFileStream(netStream, contentLength);
}
public string Name { get; private set; }
public Stream ReadStream => aaxNetworkStream;
public Stream WriteStream => throw new NotImplementedException();
public void CloseStream(Stream stream)
{
aaxNetworkStream.Close();
}
private class NetworkFileStream : Stream
{
private const int BUFF_SZ = 2 * 1024;
private FileStream _fileBacker;
private Stream _networkStream;
private long networkBytesRead = 0;
private long _contentLength;
public NetworkFileStream(Stream netStream, long contentLength)
{
_networkStream = netStream;
_contentLength = contentLength;
_fileBacker = File.Create(Path.GetTempFileName(), BUFF_SZ, FileOptions.DeleteOnClose);
}
public override bool CanRead => true;
public override bool CanSeek => true;
public override bool CanWrite => false;
public override long Length => _contentLength;
public override long Position { get => _fileBacker.Position; set => Seek(value, 0); }
public override void Flush()
{
throw new NotImplementedException();
}
public override void SetLength(long value)
{
throw new NotImplementedException();
}
public override void Write(byte[] buffer, int offset, int count)
{
throw new NotImplementedException();
}
public override int Read(byte[] buffer, int offset, int count)
{
long requiredLength = Position + count;
if (requiredLength > networkBytesRead)
readWebFileToPosition(requiredLength);
return _fileBacker.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
long newPosition = (long)origin + offset;
if (newPosition > networkBytesRead)
readWebFileToPosition(newPosition);
_fileBacker.Position = newPosition;
return newPosition;
}
public override void Close()
{
_fileBacker.Close();
_networkStream.Close();
}
/// <summary>
/// Read more data from <see cref="_networkStream"/> into <see cref="_fileBacker"/> as needed.
/// </summary>
/// <param name="requiredLength">Length of strem required for the operation.</param>
private void readWebFileToPosition(long requiredLength)
{
byte[] buff = new byte[BUFF_SZ];
long backerPosition = _fileBacker.Position;
_fileBacker.Position = networkBytesRead;
while (networkBytesRead < requiredLength)
{
int bytesRead = _networkStream.Read(buff, 0, BUFF_SZ);
_fileBacker.Write(buff, 0, bytesRead);
networkBytesRead += bytesRead;
}
_fileBacker.Position = backerPosition;
}
}
}
}

View File

@@ -0,0 +1,446 @@
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading;
namespace AaxDecrypter
{
/// <summary>
/// A <see cref="CookieContainer"/> for a single Uri.
/// </summary>
public class SingleUriCookieContainer : CookieContainer
{
private Uri baseAddress;
public Uri Uri
{
get => baseAddress;
set
{
baseAddress = new UriBuilder(value.Scheme, value.Host).Uri;
}
}
public CookieCollection GetCookies()
{
return GetCookies(Uri);
}
}
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
public class NetworkFileStream : Stream, IUpdatable
{
public event EventHandler Updated;
#region Public Properties
/// <summary>
/// Location to save the downloaded data.
/// </summary>
[JsonProperty(Required = Required.Always)]
public string SaveFilePath { get; }
/// <summary>
/// Http(s) address of the file to download.
/// </summary>
[JsonProperty(Required = Required.Always)]
public Uri Uri { get; private set; }
/// <summary>
/// All cookies set by caller or by the remote server.
/// </summary>
[JsonProperty(Required = Required.Always)]
public SingleUriCookieContainer CookieContainer { get; }
/// <summary>
/// Http headers to be sent to the server with the request.
/// </summary>
[JsonProperty(Required = Required.Always)]
public WebHeaderCollection RequestHeaders { get; private set; }
/// <summary>
/// The position in <see cref="SaveFilePath"/> that has been written and flushed to disk.
/// </summary>
[JsonProperty(Required = Required.Always)]
public long WritePosition { get; private set; }
/// <summary>
/// The total length of the <see cref="Uri"/> file to download.
/// </summary>
[JsonProperty(Required = Required.Always)]
public long ContentLength { get; private set; }
#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 EventWaitHandle downloadEnded { get; set; }
private EventWaitHandle downloadedPiece { get; set; }
#endregion
#region Constants
//Download buffer size
private const int DOWNLOAD_BUFF_SZ = 4 * 1024;
//NetworkFileStream will flush all data in _writeFile to disk after every
//DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024;
#endregion
#region Constructor
/// <summary>
/// A resumable, simultaneous file downloader and reader.
/// </summary>
/// <param name="saveFilePath">Path to a location on disk to save the downloaded data from <paramref name="uri"/></param>
/// <param name="uri">Http(s) address of the file to download.</param>
/// <param name="writePosition">The position in <paramref name="uri"/> to begin downloading.</param>
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
/// <param name="cookies">A <see cref="SingleUriCookieContainer"/> with cookies to send with the <see cref="HttpWebRequest"/>. It will also be populated with any cookies set by the server. </param>
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, WebHeaderCollection requestHeaders = null, SingleUriCookieContainer cookies = null)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
SaveFilePath = saveFilePath;
Uri = uri;
WritePosition = writePosition;
RequestHeaders = requestHeaders ?? new WebHeaderCollection();
CookieContainer = cookies ?? new SingleUriCookieContainer { Uri = uri };
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
{
Position = WritePosition
};
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
SetUriForSameFile(uri);
}
#endregion
#region Downloader
/// <summary>
/// Update the <see cref="JsonFilePersister"/>.
/// </summary>
private void Update()
{
RequestHeaders = HttpRequest.Headers;
Updated?.Invoke(this, EventArgs.Empty);
}
/// <summary>
/// Set a different <see cref="System.Uri"/> to the same file targeted by this instance of <see cref="NetworkFileStream"/>
/// </summary>
/// <param name="uriToSameFile">New <see cref="System.Uri"/> host must match existing host.</param>
public void SetUriForSameFile(Uri uriToSameFile)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
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)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
HttpRequest = WebRequest.CreateHttp(Uri);
HttpRequest.CookieContainer = CookieContainer;
HttpRequest.Headers = RequestHeaders;
//If NetworkFileStream is resuming, Header will already contain a range.
HttpRequest.Headers.Remove("Range");
HttpRequest.AddRange(WritePosition);
}
/// <summary>
/// Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread.
/// </summary>
private void BeginDownloading()
{
downloadEnded = new EventWaitHandle(false, EventResetMode.ManualReset);
if (ContentLength != 0 && WritePosition == ContentLength)
{
hasBegunDownloading = true;
downloadEnded.Set();
return;
}
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var response = HttpRequest.GetResponse() as HttpWebResponse;
if (response.StatusCode != HttpStatusCode.PartialContent)
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 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-
if (WritePosition == 0)
ContentLength = response.ContentLength;
_networkStream = response.GetResponseStream();
downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Download the file in the background.
new Thread(() => DownloadFile())
{ IsBackground = true }
.Start();
hasBegunDownloading = true;
return;
}
/// <summary>
/// Downlod <see cref="Uri"/> to <see cref="SaveFilePath"/>.
/// </summary>
private void DownloadFile()
{
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ];
do
{
var bytesRead = _networkStream.Read(buff, 0, DOWNLOAD_BUFF_SZ);
_writeFile.Write(buff, 0, bytesRead);
downloadPosition += bytesRead;
if (downloadPosition > nextFlush)
{
_writeFile.Flush();
WritePosition = downloadPosition;
Update();
nextFlush = downloadPosition + DATA_FLUSH_SZ;
downloadedPiece.Set();
}
} while (downloadPosition < ContentLength && !isCancelled);
_writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition;
Update();
downloadedPiece.Set();
downloadEnded.Set();
if (!isCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
#endregion
#region Json Connverters
public static JsonSerializerSettings GetJsonSerializerSettings()
{
var settings = new JsonSerializerSettings();
settings.Converters.Add(new CookieContainerConverter());
settings.Converters.Add(new WebHeaderCollectionConverter());
return settings;
}
internal class CookieContainerConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(SingleUriCookieContainer);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new SingleUriCookieContainer()
{
Uri = new Uri(jObj["Uri"].Value<string>()),
Capacity = jObj["Capacity"].Value<int>(),
MaxCookieSize = jObj["MaxCookieSize"].Value<int>(),
PerDomainCapacity = jObj["PerDomainCapacity"].Value<int>()
};
var cookieList = jObj["Cookies"].ToList();
foreach (var cookie in cookieList)
{
result.Add(
new Cookie
{
Comment = cookie["Comment"].Value<string>(),
HttpOnly = cookie["HttpOnly"].Value<bool>(),
Discard = cookie["Discard"].Value<bool>(),
Domain = cookie["Domain"].Value<string>(),
Expired = cookie["Expired"].Value<bool>(),
Expires = cookie["Expires"].Value<DateTime>(),
Name = cookie["Name"].Value<string>(),
Path = cookie["Path"].Value<string>(),
Port = cookie["Port"].Value<string>(),
Secure = cookie["Secure"].Value<bool>(),
Value = cookie["Value"].Value<string>(),
Version = cookie["Version"].Value<int>(),
});
}
return result;
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var cookies = value as SingleUriCookieContainer;
var obj = (JObject)JToken.FromObject(value);
var container = cookies.GetCookies();
var propertyNames = container.Select(c => JToken.FromObject(c));
obj.AddFirst(new JProperty("Cookies", new JArray(propertyNames)));
obj.WriteTo(writer);
}
}
internal class WebHeaderCollectionConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(WebHeaderCollection);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var result = new WebHeaderCollection();
foreach (var kvp in jObj)
result.Add(kvp.Key, kvp.Value.Value<string>());
return result;
}
public override bool CanWrite => true;
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
var jObj = new JObject();
var type = value.GetType();
var headers = value as WebHeaderCollection;
var jHeaders = headers.AllKeys.Select(k => new JProperty(k, headers[k]));
jObj.Add(jHeaders);
jObj.WriteTo(writer);
}
}
#endregion
#region Download Stream Reader
[JsonIgnore]
public override bool CanRead => true;
[JsonIgnore]
public override bool CanSeek => true;
[JsonIgnore]
public override bool CanWrite => false;
[JsonIgnore]
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 => false;
[JsonIgnore]
public override int ReadTimeout { get => base.ReadTimeout; set => base.ReadTimeout = value; }
[JsonIgnore]
public override int WriteTimeout { get => base.WriteTimeout; set => base.WriteTimeout = value; }
public override void Flush() => throw new NotImplementedException();
public override void SetLength(long value) => throw new NotImplementedException();
public override void Write(byte[] buffer, int offset, int count) => throw new NotImplementedException();
public override int Read(byte[] buffer, int offset, int count)
{
if (!hasBegunDownloading)
BeginDownloading();
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
return _readFile.Read(buffer, offset, count);
}
public override long Seek(long offset, SeekOrigin origin)
{
var newPosition = origin switch
{
SeekOrigin.Current => Position + offset,
SeekOrigin.End => ContentLength + offset,
_ => offset,
};
WaitToPosition(newPosition);
return _readFile.Position = newPosition;
}
/// <summary>
/// Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns.
/// </summary>
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition)
{
while (requiredPosition > WritePosition && !isCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
}
public override void Close()
{
isCancelled = true;
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
_readFile.Close();
_writeFile.Close();
_networkStream?.Close();
Update();
}
#endregion
~NetworkFileStream()
{
downloadEnded?.Close();
downloadedPiece?.Close();
}
}
}

View File

@@ -0,0 +1,23 @@
using Dinah.Core.IO;
using Newtonsoft.Json;
namespace AaxDecrypter
{
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
{
/// <summary>Alias for Target </summary>
public NetworkFileStream NetworkFileStream => Target;
/// <summary>uses path. create file if doesn't yet exist</summary>
public NetworkFileStreamPersister(NetworkFileStream networkFileStream, string path, string jsonPath = null)
: base(networkFileStream, path, jsonPath) { }
/// <summary>load from existing file</summary>
public NetworkFileStreamPersister(string path, string jsonPath = null)
: base(path, jsonPath) { }
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
}
}

View File

@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
<Version>5.7.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

@@ -0,0 +1,151 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
namespace AppScaffolding
{
/// <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 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);
public static bool APPSETTINGS_TryGet(string key, out string value)
{
bool success = false;
JToken val = null;
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
return success;
}
/// <summary>only insert if not exists</summary>
public static void APPSETTINGS_Insert(string key, string value)
=> process_APPSETTINGS_Json(jObj => jObj.TryAdd(key, value));
/// <summary>only update if exists</summary>
public static void APPSETTINGS_Update(string key, string value)
=> process_APPSETTINGS_Json(jObj => {
if (jObj.ContainsKey(key))
jObj[key] = value;
});
/// <summary>only delete if exists</summary>
public static void APPSETTINGS_Delete(string key)
=> process_APPSETTINGS_Json(jObj => {
if (jObj.ContainsKey(key))
jObj.Remove(key);
});
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!APPSETTINGS_Json_Exists)
return;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
action(jObj);
if (!save)
return;
// only save if different
var endingContents_indented = jObj.ToString(Formatting.Indented);
var endingContents_compact = jObj.ToString(Formatting.None);
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(APPSETTINGS_JSON, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath
{
get
{
var success = APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value);
return !success || value is null ? null : Path.Combine(value, SETTINGS_JSON);
}
}
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static bool Settings_TryGet(string key, out string value)
{
bool success = false;
JToken val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
return success;
}
/// <summary>only insert if not exists</summary>
public static void Settings_Insert(string key, string value)
=> process_SettingsJson(jObj => jObj.TryAdd(key, value));
/// <summary>only update if exists</summary>
public static void Settings_Update(string key, string value)
=> process_SettingsJson(jObj => {
if (jObj.ContainsKey(key))
jObj[key] = value;
});
/// <summary>only delete if exists</summary>
public static void Settings_Delete(string key)
=> process_SettingsJson(jObj => {
if (jObj.ContainsKey(key))
jObj.Remove(key);
});
/// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_SettingsJson(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!SettingsJson_Exists)
return;
var startingContents = File.ReadAllText(SettingsJsonPath);
var jObj = JObject.Parse(startingContents);
action(jObj);
if (!save)
return;
// only save if different
var endingContents_indented = jObj.ToString(Formatting.Indented);
var endingContents_compact = jObj.ToString(Formatting.None);
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(SettingsJsonPath, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
}
}

View File

@@ -5,15 +5,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="27.0.2" />
<PackageReference Include="NPOI" Version="2.5.3" />
<PackageReference Include="CsvHelper" Version="27.1.1" />
<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,45 +8,112 @@ 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, ILoginCallback> loginCallbackFactoryFunc, 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 (0, 0);
return new List<LibraryBook>();
try
{
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var newCount = await importIntoDbAsync(importItems);
Log.Logger.Information($"Import: New count {newCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
await Task.Run(() => SearchEngineCommands.FullReIndex());
Log.Logger.Information("FullReIndex: success");
return (totalCount, newCount);
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.MoveResponseBodyFile(FileManager.Configuration.Instance.LibationFiles);
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 importing library. Login failed. {@DebugInfo}", new {
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePath
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, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, 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);
logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
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 importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
@@ -55,6 +122,11 @@ 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)
@@ -86,38 +158,131 @@ namespace ApplicationServices
Account = account?.MaskedLogEntry ?? "[null]"
});
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
logTime($"pre scanAccountAsync {account.AccountName}");
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api, 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.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.9">
<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.9" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.9">
<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,6 +85,8 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
item.Authors = new[] { new Person { Name = "", Asin = null } };
@@ -102,9 +122,10 @@ namespace DtoImporterService
var book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.Title,
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
authors,
narrators,
category,
@@ -120,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;
}
@@ -134,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,56 +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)
{
var person = DbContext.Contributors.Local.SingleOrDefault(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,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,56 +1,73 @@
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.DecryptInProgress, libraryBook);
var outputAudioFilename = await aaxToM4bConverterDecryptAsync(AudibleFileStorage.DownloadsInProgress, AudibleFileStorage.DecryptInProgress, libraryBook);
// decrypt failed
if (outputAudioFilename is null)
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
@@ -59,9 +76,9 @@ namespace FileLiberator
}
}
private async Task<string> aaxToM4bConverterDecryptAsync(string destinationDir, LibraryBook libraryBook)
private async Task<string> aaxToM4bConverterDecryptAsync(string cacheDir, string destinationDir, LibraryBook libraryBook)
{
DecryptBegin?.Invoke(this, $"Begin decrypting {libraryBook}");
StreamingBegin?.Invoke(this, $"Begin decrypting {libraryBook}");
try
{
@@ -73,34 +90,36 @@ namespace FileLiberator
var aaxcDecryptDlLic = new DownloadLicense
(
contentLic.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic.Voucher?.Key,
contentLic.Voucher?.Iv,
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic?.Voucher?.Key,
contentLic?.Voucher?.Iv,
Resources.UserAgent
);
if (Configuration.Instance.AllowLibationFixup)
{
aaxcDecryptDlLic.ChapterInfo = new ChapterInfo();
aaxcDecryptDlLic.ChapterInfo = new AAXClean.ChapterInfo();
foreach (var chap in contentLic.ContentMetadata?.ChapterInfo?.Chapters)
aaxcDecryptDlLic.ChapterInfo.AddChapter(
new Chapter(
chap.Title,
chap.StartOffsetMs,
chap.LengthMs
));
aaxcDecryptDlLic.ChapterInfo.AddChapter(chap.Title, TimeSpan.FromMilliseconds(chap.LengthMs));
}
aaxcDownloader = AaxcDownloadConverter.Create(destinationDir, aaxcDecryptDlLic);
aaxcDownloader.AppName = "Libation";
var format = Configuration.Instance.DecryptToLossy ? OutputFormat.Mp3 : OutputFormat.Mp4a;
// override default which was set in CreateAsync
var proposedOutputFile = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].m4b");
aaxcDownloader.SetOutputFilename(proposedOutputFile);
aaxcDownloader.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => UpdateRemainingTime?.Invoke(this, remaining);
var extension = format switch
{
OutputFormat.Mp4a => "m4b",
OutputFormat.Mp3 => "mp3",
_ => throw new NotImplementedException(),
};
var outFileName = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}");
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;
@@ -111,14 +130,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)
@@ -128,18 +148,18 @@ namespace FileLiberator
if (e is not null)
{
CoverImageFilepathDiscovered?.Invoke(this, e);
CoverImageDiscovered?.Invoke(this, e);
}
}
private void aaxcDownloader_RetrievedTags(object sender, AaxcTagLibFile e)
private void aaxcDownloader_RetrievedTags(object sender, AAXClean.AppleTags e)
{
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 + "]"
@@ -154,6 +174,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
@@ -166,9 +187,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)
@@ -191,34 +216,32 @@ 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)
&& !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId);
public bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
public void Cancel()
{
aaxcDownloader.Cancel();
aaxcDownloader?.Cancel();
}
}
}

View File

@@ -5,21 +5,28 @@ using Dinah.Core.Net.Http;
namespace FileLiberator
{
// frustratingly copy pasta from DownloadableBase and DownloadPdf
public class DownloadFile : IDownloadable
// currently only used to download the .zip flies for upgrade
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
{
@@ -28,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,14 +47,17 @@ 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 downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
@@ -53,9 +66,13 @@ namespace FileLiberator
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);
@@ -44,9 +46,9 @@ namespace FileLiberator
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 +59,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,23 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Runtime.CompilerServices;
using System.Text;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
namespace FileLiberator
{
public static class LoggerUtilities
{
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()
);
}
}

View File

@@ -8,101 +8,82 @@ 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, AAX, PDF }
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 AAX { get; } = new AaxFileStorage();
public static AudibleFileStorage PDF { get; } = new PdfFileStorage();
public static string DownloadsInProgress
{
get
{
if (!Configuration.Instance.DownloadsInProgressEnum.In("WinTemp", "LibationFiles"))
Configuration.Instance.DownloadsInProgressEnum = "WinTemp";
var AaxRootDir
= Configuration.Instance.DownloadsInProgressEnum == "WinTemp"
? Configuration.WinTemp
: Configuration.Instance.LibationFiles;
return Directory.CreateDirectory(Path.Combine(AaxRootDir, "DownloadsInProgress")).FullName;
}
}
public static string DecryptInProgress
{
get
{
if (!Configuration.Instance.DecryptInProgressEnum.In("WinTemp", "LibationFiles"))
Configuration.Instance.DecryptInProgressEnum = "WinTemp";
var M4bRootDir
= Configuration.Instance.DecryptInProgressEnum == "WinTemp"
? Configuration.WinTemp
: Configuration.Instance.LibationFiles;
return Directory.CreateDirectory(Path.Combine(M4bRootDir, "DecryptInProgress")).FullName;
}
}
// not customizable. don't move to config
public static string DownloadsFinal => new DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("DownloadsFinal").FullName;
public static string BooksDirectory
{
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.Instance.LibationFiles, "Books");
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
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; }
private string extAggr { 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;
@@ -110,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)
{
@@ -125,55 +121,27 @@ namespace FileManager
public bool IsFileTypeMatch(FileInfo fileInfo)
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
#endregion
public string GetPath(string productId) => GetFilePath(productId);
}
public class AudioFileStorage : AudibleFileStorage
public class AaxcFileStorage : AudibleFileStorage
{
public const string SKIP_FILE_EXT = "libhack";
public override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
protected override string[] Extensions { get; } = new[] { "aaxc" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public override string StorageDirectory => DownloadsInProgress;
public AudioFileStorage() : base(FileType.Audio) { }
public AaxcFileStorage() : base(FileType.AAXC) { }
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 class AaxFileStorage : AudibleFileStorage
{
public override string[] Extensions { get; } = new[] { "aax" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => DownloadsFinal;
public AaxFileStorage() : base(FileType.AAX) { }
}
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

@@ -4,146 +4,61 @@ using System.ComponentModel;
using System.IO;
using System.Linq;
using Dinah.Core;
using Dinah.Core.Logging;
using Microsoft.Extensions.Configuration;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
using Serilog.Events;
namespace FileManager
{
public class Configuration
{
// settings will be persisted when all are true
// - property (not field)
// - string
// - public getter
// - public setter
#region // properties to test reflection
/*
// field should NOT be populated
public string TestField;
// int should NOT be populated
public int TestInt { get; set; }
// read-only should NOT be populated
public string TestGet { get; } // get only: should NOT get auto-populated
// set-only should NOT be populated
public string TestSet { private get; set; }
// get and set: SHOULD be auto-populated
public string TestGetSet { get; set; }
*/
#endregion
private PersistentDictionary persistentDictionary;
public bool FilesExist
public bool LibationSettingsAreValid
=> File.Exists(APPSETTINGS_JSON)
&& File.Exists(SettingsFilePath)
&& Directory.Exists(LibationFiles)
&& Directory.Exists(Books);
&& SettingsFileIsValid(SettingsFilePath);
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books
public static bool SettingsFileIsValid(string settingsFile)
{
get => persistentDictionary.GetString(nameof(Books));
set => persistentDictionary.Set(nameof(Books), value);
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
var pDic = new PersistentDictionary(settingsFile, isReadOnly: true);
var booksDir = pDic.GetString(nameof(Books));
if (booksDir is null || !Directory.Exists(booksDir))
return false;
if (string.IsNullOrWhiteSpace(pDic.GetString(nameof(InProgress))))
return false;
return true;
}
private const string APP_DIR = "AppDir";
public static string AppDir { get; } = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES));
public static string MyDocs { get; } = Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), LIBATION_FILES));
public static string WinTemp { get; } = Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
#region persistent configuration settings/values
private Dictionary<string, string> wellKnownPaths { get; } = new Dictionary<string, string>
{
[APP_DIR] = AppDir,
["MyDocs"] = MyDocs,
["WinTemp"] = WinTemp
};
private string libationFilesPathCache;
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
// default setting and directory creation occur in class responsible for files.
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded.\r\nWhen download is complete, the final file will be in [LibationFiles]\\DownloadsFinal")]
public string DownloadsInProgressEnum
{
get => persistentDictionary.GetString(nameof(DownloadsInProgressEnum));
set => persistentDictionary.Set(nameof(DownloadsInProgressEnum), value);
}
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being decrypted.\r\nWhen decryption is complete, the final file will be in Books location")]
public string DecryptInProgressEnum
{
get => persistentDictionary.GetString(nameof(DecryptInProgressEnum));
set => persistentDictionary.Set(nameof(DecryptInProgressEnum), value);
}
[Description("Allow Libation for fix up audiobook metadata?")]
public bool AllowLibationFixup
{
get => persistentDictionary.Get<bool>(nameof(AllowLibationFixup));
set => persistentDictionary.Set(nameof(AllowLibationFixup), value);
}
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
// singleton stuff
public static Configuration Instance { get; } = new Configuration();
private Configuration() { }
private const string APPSETTINGS_JSON = "appsettings.json";
private const string LIBATION_FILES = "LibationFiles";
[Description("Location for storage of program-created files")]
public string LibationFiles => libationFilesPathCache ?? getLibationFiles();
private string getLibationFiles()
{
var value = getLiberationFilesSettingFromJson();
// this looks weird but is correct for translating wellKnownPaths
if (wellKnownPaths.ContainsKey(value))
value = wellKnownPaths[value];
// must write here before SettingsFilePath in next step reads cache
libationFilesPathCache = value;
// load json values into memory. create if not exists
persistentDictionary = new PersistentDictionary(SettingsFilePath);
return libationFilesPathCache;
}
private string getLiberationFilesSettingFromJson()
{
try
{
if (File.Exists(APPSETTINGS_JSON))
{
var appSettingsContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(appSettingsContents);
if (jObj.ContainsKey(LIBATION_FILES))
{
var value = jObj[LIBATION_FILES].Value<string>();
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
if (!string.IsNullOrWhiteSpace(value))
return value;
}
}
}
catch { }
File.WriteAllText(APPSETTINGS_JSON, new JObject { { LIBATION_FILES, APP_DIR } }.ToString(Formatting.Indented));
return APP_DIR;
}
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.Set(propertyName, newValue);
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue) => persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue);
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
/// <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");
public static string GetDescription(string propertyName)
{
@@ -156,34 +71,260 @@ namespace FileManager
return attribute?.Description;
}
public bool TrySetLibationFiles(string directory)
{
if (!Directory.Exists(directory) && !wellKnownPaths.ContainsKey(directory))
return false;
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
// if moving from default, delete old settings file and dir (if empty)
if (LibationFiles.EqualsInsensitive(AppDir))
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books
{
get => persistentDictionary.GetString(nameof(Books));
set => persistentDictionary.SetString(nameof(Books), value);
}
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
public string InProgress
{
get => persistentDictionary.GetString(nameof(InProgress));
set => persistentDictionary.SetString(nameof(InProgress), value);
}
[Description("Allow Libation for fix up audiobook metadata?")]
public bool AllowLibationFixup
{
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
set => persistentDictionary.SetNonString(nameof(AllowLibationFixup), value);
}
[Description("Decrypt to lossy format?")]
public bool DecryptToLossy
{
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
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
{
File.Delete(SettingsFilePath);
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
public static string AppDir_Relative => $@".\{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
public enum KnownDirectories
{
None = 0,
[Description("My Users folder")]
UserProfile = 1,
[Description("The same folder that Libation is running from")]
AppDir = 2,
[Description("Windows temporary folder")]
WinTemp = 3,
[Description("My Documents")]
MyDocs = 4,
[Description("Your settings folder (aka: Libation Files)")]
LibationFiles = 5
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
{
(KnownDirectories.None, () => null),
(KnownDirectories.UserProfile, () => UserProfile),
(KnownDirectories.AppDir, () => AppDir_Relative),
(KnownDirectories.WinTemp, () => WinTemp),
(KnownDirectories.MyDocs, () => MyDocs),
// this is important to not let very early calls try to accidentally load LibationFiles too early.
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
};
public static string GetKnownDirectoryPath(KnownDirectories directory)
{
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
return dirFunc == default ? null : dirFunc.getPathFunc();
}
public static KnownDirectories GetKnownDirectory(string directory)
{
// especially important so a very early call doesn't match null => LibationFiles
if (string.IsNullOrWhiteSpace(directory))
return KnownDirectories.None;
// 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile.
// since it's a list, order matters and non-LibationFiles will be returned first
var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory);
return dirFunc == default ? KnownDirectories.None : dirFunc.directory;
}
#endregion
#region logging
private IConfigurationRoot configuration;
public void ConfigureLogging()
{
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
}
[Description("The importance of a log event")]
public LogEventLevel LogLevel
{
get
{
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
}
set
{
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Information("LogLevel.set attempt. No change");
return;
}
configuration.Reload();
Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new
{
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()
});
}
}
#endregion
#region singleton stuff
public static Configuration Instance { get; } = new Configuration();
private Configuration() { }
#endregion
#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")]
public string LibationFiles
{
get
{
if (libationFilesPathCache is not null)
return libationFilesPathCache;
// FIRST: must write here before SettingsFilePath in next step reads cache
libationFilesPathCache = getLiberationFilesSettingFromJson();
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
// 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");
// 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;
}
}
private static string libationFilesPathCache;
private string getLiberationFilesSettingFromJson()
{
string startingContents = null;
try
{
if (File.Exists(APPSETTINGS_JSON))
{
startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingJObj = JObject.Parse(startingContents);
if (startingJObj.ContainsKey(LIBATION_FILES_KEY))
{
var startingValue = startingJObj[LIBATION_FILES_KEY].Value<string>();
if (!string.IsNullOrWhiteSpace(startingValue))
return startingValue;
}
}
}
catch { }
// not found. write to file. read from file
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented);
if (startingContents != endingContents)
{
File.WriteAllText(APPSETTINGS_JSON, endingContents);
System.Threading.Thread.Sleep(100);
if (!Directory.EnumerateDirectories(AppDir).Any() && !Directory.EnumerateFiles(AppDir).Any())
Directory.Delete(AppDir);
}
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
// verify from live file. no try/catch. want failures to be visible
var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON));
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
return valueFinal;
}
public void SetLibationFiles(string directory)
{
libationFilesPathCache = null;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
var contents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(contents);
jObj[LIBATION_FILES_KEY] = directory;
jObj[LIBATION_FILES] = directory;
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
var output = JsonConvert.SerializeObject(jObj, Formatting.Indented);
File.WriteAllText(APPSETTINGS_JSON, output);
// now it's set in the file again but no settings have moved yet
File.WriteAllText(APPSETTINGS_JSON, endingContents);
return true;
try
{
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
}
catch { }
}
#endregion
}
}

View File

@@ -5,13 +5,9 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="HtmlAgilityPack" Version="1.11.33" />
<PackageReference Include="Octokit" Version="0.50.0" />
<PackageReference Include="Dinah.Core" Version="1.1.0.1" />
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.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

@@ -10,14 +10,16 @@ namespace FileManager
public class PersistentDictionary
{
public string Filepath { get; }
public bool IsReadOnly { get; }
// optimize for strings. expectation is most settings will be strings and a rare exception will be something else
private Dictionary<string, string> stringCache { get; } = new Dictionary<string, string>();
private Dictionary<string, object> objectCache { get; } = new Dictionary<string, object>();
public PersistentDictionary(string filepath)
public PersistentDictionary(string filepath, bool isReadOnly = false)
{
Filepath = filepath;
IsReadOnly = isReadOnly;
if (File.Exists(Filepath))
return;
@@ -25,6 +27,9 @@ namespace FileManager
// will create any missing directories, incl subdirectories. if all already exist: no action
Directory.CreateDirectory(Path.GetDirectoryName(filepath));
if (IsReadOnly)
return;
File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
}
@@ -34,18 +39,21 @@ namespace FileManager
if (!stringCache.ContainsKey(propertyName))
{
var jObject = readFile();
stringCache[propertyName] = jObject.ContainsKey(propertyName) ? jObject[propertyName].Value<string>() : null;
if (!jObject.ContainsKey(propertyName))
return null;
stringCache[propertyName] = jObject[propertyName].Value<string>();
}
return stringCache[propertyName];
}
public T Get<T>(string propertyName)
public T GetNonString<T>(string propertyName)
{
var o = GetObject(propertyName);
if (o is null) return default;
if (o is JToken jt) return jt.Value<T>();
return (T)o;
var obj = GetObject(propertyName);
if (obj is null) return default;
if (obj is JValue jValue) return jValue.Value<T>();
if (obj is JObject jObject) return jObject.ToObject<T>();
return (T)obj;
}
public object GetObject(string propertyName)
@@ -53,17 +61,43 @@ namespace FileManager
if (!objectCache.ContainsKey(propertyName))
{
var jObject = readFile();
objectCache[propertyName] = jObject.ContainsKey(propertyName) ? jObject[propertyName].Value<object>() : null;
if (!jObject.ContainsKey(propertyName))
return null;
objectCache[propertyName] = jObject[propertyName].Value<object>();
}
return objectCache[propertyName];
}
public string GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}");
public string GetStringFromJsonPath(string jsonPath)
{
if (!stringCache.ContainsKey(jsonPath))
{
try
{
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
if (token is null)
return null;
stringCache[jsonPath] = (string)token;
}
catch
{
return null;
}
}
return stringCache[jsonPath];
}
public bool Exists(string propertyName) => readFile().ContainsKey(propertyName);
private object locker { get; } = new object();
public void Set(string propertyName, string newValue)
public void SetString(string propertyName, string newValue)
{
// only do this check in string cache, NOT object cache
if (stringCache[propertyName] == newValue)
if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue)
return;
// set cache
@@ -72,7 +106,7 @@ namespace FileManager
writeFile(propertyName, newValue);
}
public void Set(string propertyName, object newValue)
public void SetNonString(string propertyName, object newValue)
{
// set cache
objectCache[propertyName] = newValue;
@@ -83,42 +117,92 @@ namespace FileManager
private void writeFile(string propertyName, JToken newValue)
{
try
{
var str = newValue?.ToString();
var formattedValue
= str is null ? "[null]"
: string.IsNullOrEmpty(str) ? "[empty]"
: string.IsNullOrWhiteSpace(str) ? $"[whitespace. Length={str.Length}]"
: str.Length > 100 ? $"[Length={str.Length}] {str[0..50]}...{str[^50..^0]}"
: str;
Serilog.Log.Logger.Information($"Config changed. {propertyName}={formattedValue}");
}
catch { }
if (IsReadOnly)
return;
// write new setting to file
lock (locker)
{
var jObject = readFile();
var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
jObject[propertyName] = newValue;
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
}
}
var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented);
// special case: no caching. no logging
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue)
{
lock (locker)
if (startContents == endContents)
return;
File.WriteAllText(Filepath, endContents);
}
try
{
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
var debug_oldValue = (string)token[propertyName];
token[propertyName] = newValue;
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
var str = formatValueForLog(newValue?.ToString());
Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = str });
}
catch { }
}
/// <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 false;
var path = $"{jsonPath}.{propertyName}";
{
// only do this check in string cache, NOT object cache
if (stringCache.ContainsKey(path) && stringCache[path] == newValue)
return false;
// set cache
stringCache[path] = newValue;
}
try
{
lock (locker)
{
var jObject = readFile();
var token = jObject.SelectToken(jsonPath);
if (token is null || token[propertyName] is null)
return false;
var oldValue = token.Value<string>(propertyName);
if (oldValue == newValue)
return false;
token[propertyName] = newValue;
File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented));
}
}
catch (Exception exDebug)
{
return false;
}
if (!suppressLogging)
{
try
{
var str = formatValueForLog(newValue?.ToString());
Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { jsonPath, propertyName, newValue = str });
}
catch { }
}
return true;
}
private static string formatValueForLog(string value)
=> value is null ? "[null]"
: string.IsNullOrEmpty(value) ? "[empty]"
: string.IsNullOrWhiteSpace(value) ? $"[whitespace. Length={value.Length}]"
: value.Length > 100 ? $"[Length={value.Length}] {value[0..50]}...{value[^50..^0]}"
: value;
private JObject readFile()
{
var settingsJsonContents = File.ReadAllText(Filepath);

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

@@ -3,7 +3,7 @@ using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApiDTOs;
using AudibleApi.Common;
using Dinah.Core;
using Polly;
using Polly.Retry;
@@ -47,37 +47,33 @@ namespace InternalUtilities
// 2 retries == 3 total
.RetryAsync(2);
public static Task<List<Item>> GetLibraryValidatedAsync(Api api)
public static Task<List<Item>> GetLibraryValidatedAsync(Api api, 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(api));
return policy.ExecuteAsync(() => getItemsAsync(api, responseGroups));
}
private static async Task<List<Item>> getItemsAsync(Api api)
private static async Task<List<Item>> getItemsAsync(Api api, LibraryOptions.ResponseGroupOptions responseGroups)
{
var items = await api.GetAllLibraryItemsAsync();
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
// 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
await manageEpisodesAsync(api, items);
var validators = new List<IValidator>();
validators.AddRange(getValidators());
@@ -91,6 +87,143 @@ namespace InternalUtilities
return items;
}
#region episodes and podcasts
private static async Task manageEpisodesAsync(Api api, 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(api, 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 static async Task<List<Item>> getEpisodesAsync(Api api, List<Item> parents)
{
var results = new List<Item>();
foreach (var parent in parents)
{
var children = await getEpisodeChildrenAsync(api, 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 static async Task<List<Item>> getEpisodeChildrenAsync(Api api, 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);

View File

@@ -1,7 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using AudibleApi.Common;
namespace InternalUtilities
{
@@ -76,9 +76,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(AudibleApi.Common.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(AudibleApi.Common.Series.SeriesName)}", nameof(items)));
return exceptions;
}

View File

@@ -1,5 +1,5 @@
using System;
using AudibleApiDTOs;
using AudibleApi.Common;
namespace InternalUtilities
{

View File

@@ -5,7 +5,10 @@
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
<PackageReference Include="AudibleApi" Version="1.2.1.2" />
</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,57 +35,23 @@ 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}"
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}") = "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
@@ -116,86 +83,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
@@ -204,44 +103,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
{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}
{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,33 @@
<?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>
<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,79 @@
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(
(account) => null,
_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 cupidneedsglasses";
//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();
}
}

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