Compare commits

...

245 Commits
v3.0 ... v4.3.0

Author SHA1 Message Date
Robert McRackan
270e2531e2 update version 2021-06-17 14:49:05 -04:00
Robert McRackan
959a1aebe9 Enough already. I'm obviously not incentivized/shamed into writing unit tests for things in UNTESTED dir.s. It's just making a mess of the file tree 2021-06-17 14:21:15 -04:00
rmcrackan
2217fe6948 Merge pull request #26 from Mbucari/master
Added support for downloaded chapter titles.
2021-06-17 13:37:15 -04:00
Robert McRackan
96abf56a87 remove unused PublishSingleFile directive 2021-06-17 11:45:44 -04:00
Michael Bucari-Tovo
5731a8f693 Added support for downloaded chapters. 2021-06-16 17:04:42 -06:00
Michael Bucari-Tovo
ff722b6a52 Added support for chapter titles and refactored. 2021-06-16 16:58:01 -06:00
Michael Bucari-Tovo
9271114408 Allow caller to specify alternate chapters source. 2021-06-16 16:27:23 -06:00
Michael Bucari-Tovo
ebfdd44142 Abstracted Chapters class, adding chapter titles and end times. Updated references. 2021-06-16 16:05:06 -06:00
Robert McRackan
6ed4eb34bd update references. move db scratch pad into test 2021-05-06 11:53:40 -04:00
Robert McRackan
9372571370 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-04-12 14:52:43 -04:00
Robert McRackan
215c539920 Bug fix: first line in cue file was incorrectly formatted 2021-04-12 14:52:19 -04:00
rmcrackan
7c7da2024e Update README.md
Add paypal link
2021-04-08 13:56:39 -04:00
Robert McRackan
f55a3ca008 Search engine bug fix and unit tests 2021-04-02 11:27:16 -04:00
Robert McRackan
726b36de4d * bug fix: when user creates a tag which is also a reserved bool word (eg: israted), searching for this tag breaks the search
* add unit tests for search engine
2021-04-01 15:45:19 -04:00
Robert McRackan
abd00ff1df * search engine: refactoring and improved logging
* bug fix: after book is liberated, filter should immediately honor new "is liberated" status
2021-04-01 12:44:16 -04:00
Robert McRackan
7b966f6962 Add IsLiberated option to search engine. Reindex search after download and decrypt 2021-03-31 21:50:32 -04:00
Robert McRackan
c0e955d5ef Better logging and error handling during login 2021-03-15 13:30:33 -04:00
Robert McRackan
bc6f53c8ea bug fix around latest skip-bad-book feature 2020-12-23 16:07:47 -05:00
Robert McRackan
cefab86ce1 bug fixes around new skip-bad-book feature 2020-12-23 14:05:18 -05:00
Robert McRackan
249a2f3b59 bug fix: libhack files: directory not found 2020-12-22 15:54:46 -05:00
Robert McRackan
0e9f2c7681 Truncate too-long error message 2020-12-22 11:38:03 -05:00
Robert McRackan
d25c32ff45 When there's a problem downloading a book, you get the option to skip the file temporarily or permanently. This can be useful with extremely old audible titles where the modern download may no longer be supported 2020-12-21 16:25:42 -05:00
Robert McRackan
642a500f87 "Locale" typo. Make user msg more clear 2020-12-16 12:57:26 -05:00
Robert McRackan
0e2469db64 If no codec during download, retry with all library flags enabled 2020-12-16 11:19:12 -05:00
Robert McRackan
9aa4ef70af increase version 2020-12-14 15:56:30 -05:00
Robert McRackan
1812fc2c7c - Increase account privacy in logs
- Improve book download retry
2020-12-14 15:42:27 -05:00
Robert McRackan
f9849abb7b Better error logging when codecs are not known 2020-12-08 16:16:16 -05:00
Robert McRackan
9cfe8ee6ca bug fix: null ref exception 2020-12-03 10:58:45 -05:00
Robert McRackan
44e2cef18c New in v4.1.0
- upgrade all to .NET5
- bug fix: when codec doesn't appear in prioritized list, just get the 1st available
- add more account privacy in logs
2020-12-02 15:23:08 -05:00
Robert McRackan
4dc29affc3 Incl. audible api bug fix. Also add more account privacy in logs 2020-12-02 15:16:48 -05:00
Robert McRackan
2df38706f7 upgrade to .NET5 2020-11-18 09:32:15 -05:00
Robert McRackan
f30e9dae6f bug fix: include new ApprovalNeeded in enumeration singletons 2020-10-08 22:20:26 -04:00
Robert McRackan
50843e5102 add ApprovalNeeded page in login 2020-10-08 16:56:14 -04:00
Robert McRackan
a13b00d520 - better logging for LoginFailedException
- upgrade nuget pkg.s
2020-10-08 11:48:48 -04:00
Robert McRackan
b5ebe3db23 increm version 2020-10-07 17:48:30 -04:00
Robert McRackan
40f3e4503b Version # 2020-10-02 16:22:42 -04:00
Robert McRackan
0d93243b66 Obscure account names in logs 2020-10-02 16:10:35 -04:00
Robert McRackan
59c3845d21 Standardize logging 2020-10-02 09:35:58 -04:00
Robert McRackan
a3ee3c2881 v4.0.9 2020-10-01 12:35:16 -04:00
Robert McRackan
e971d34948 Bug fix: downloading PDFs without also Liberating books -- post-download verification step was failing 2020-09-22 15:43:13 -04:00
Robert McRackan
2b3f67fb99 Merge branch 'master' of https://github.com/rmcrackan/Libation 2020-09-21 13:10:45 -04:00
Robert McRackan
4509b8c8eb Audible whack-a-mole: they changed how to download pdfs 2020-09-21 13:10:36 -04:00
rmcrackan
2e40bebd7d Update README.md 2020-09-11 22:12:25 -04:00
Robert McRackan
dfc4121ab0 add pics for readme 2020-09-11 22:09:44 -04:00
Robert McRackan
3648607d4d export to xlsx 2020-09-11 21:56:56 -04:00
Robert McRackan
b22c35f841 new feature: json export 2020-09-11 21:07:20 -04:00
Robert McRackan
2795690199 New feature: csv export 2020-09-11 17:04:36 -04:00
Robert McRackan
b1f92343cf increm version 2020-09-10 09:07:04 -04:00
Robert McRackan
9e1d657f60 Config setting to retain aax file after decrypt 2020-09-10 09:06:34 -04:00
Robert McRackan
389761355d New version. Failed attempt to fix publish error 2020-09-07 15:45:08 -04:00
Robert McRackan
69054afaa0 update version # 2020-09-01 09:54:55 -04:00
Robert McRackan
aacdcea1e1 Merge branch 'master' of https://github.com/rmcrackan/Libation 2020-09-01 09:35:38 -04:00
Robert McRackan
0beb3bf437 Add logging 2020-09-01 09:35:13 -04:00
rmcrackan
e925b57f7f Update README.md 2020-08-31 23:03:28 -04:00
rmcrackan
5deaa06d78 Update README.md 2020-08-31 22:57:47 -04:00
Robert McRackan
eda62975ba Screenshots 2020-08-31 22:57:27 -04:00
Robert McRackan
d91e02db29 Increase version. 4.0! 2020-08-31 22:30:50 -04:00
Robert McRackan
cd604d03b1 Fix v3 => v4 migration bug. Improved error handing 2020-08-31 21:27:56 -04:00
Robert McRackan
d5cd569319 clean up comments 2020-08-30 13:01:39 -04:00
Robert McRackan
a58f51a8ce Libation 4.0 prep: do not allow user to change login id in the middle of logging in. If they do then jsonpath will fail 2020-08-28 15:03:55 -04:00
Robert McRackan
d24c10ddf5 Pass account info to login dialogs 2020-08-28 13:55:03 -04:00
Robert McRackan
a12391f0ab Serialize getting API instances so that logins don't conflict 2020-08-28 10:51:42 -04:00
Robert McRackan
60f1d8117d Remove reliance on persistent Account objects across boundaries. If you open an account persister, then dispose of it 2020-08-27 23:05:46 -04:00
Robert McRackan
20b6f28cb5 Add locale and account to search/filter options 2020-08-27 15:33:14 -04:00
Robert McRackan
9a1fa89f6f Grid, misc column: incl locale and acct 2020-08-27 15:08:36 -04:00
Robert McRackan
2a294f4f85 add locale and account to logging 2020-08-27 14:54:11 -04:00
Robert McRackan
0938c84929 Do not import non-library 'audible plus' titles 2020-08-27 13:57:53 -04:00
Robert McRackan
99cc6a6425 Accounts are editable from ScanAccountsDialog via new edit button 2020-08-27 11:36:38 -04:00
Robert McRackan
0025825d5c If no accounts, Import>Scan Library to prompt user to create account 2020-08-27 10:46:47 -04:00
Robert McRackan
81b6833118 Incl note that defaults can be changed in account settings 2020-08-26 12:59:32 -04:00
Robert McRackan
a51e76d44d Libation 4.0 prep: full multiple account support 2020-08-26 12:50:12 -04:00
Robert McRackan
755a7338e9 Account to be included on each import item, not just on the aggr group 2020-08-26 10:25:24 -04:00
Robert McRackan
56732a5365 Add stub form for 'scan mult accounts' dialog 2020-08-25 16:34:06 -04:00
Robert McRackan
dd3b032b21 Fix account persistence edge case 2020-08-25 15:58:56 -04:00
Robert McRackan
dd25792864 Multi-account scan library UI stubs 2020-08-25 15:25:28 -04:00
Robert McRackan
6979ab4450 Libation 4.0 prep: account management complete 2020-08-25 14:21:14 -04:00
Robert McRackan
4b31207f91 Make AccountsSettings and Persister more clear 2020-08-25 10:34:55 -04:00
Robert McRackan
84a847a838 Fix unit test 2020-08-24 23:15:58 -04:00
Robert McRackan
6900a68b9d Rename for clarity: AccountsSettings <=> Accounts 2020-08-24 22:57:08 -04:00
Robert McRackan
743644c4e9 account management UI 2020-08-24 16:22:55 -04:00
Robert McRackan
e9e380dbe6 Account management UI, add 'original' column 2020-08-24 14:16:20 -04:00
Robert McRackan
515dfceb73 begin account management UI 2020-08-24 14:08:07 -04:00
Robert McRackan
3941906d72 Book download validation: librarybook.accountname and/or book.locale is null/blank 2020-08-22 07:32:39 -04:00
Robert McRackan
6407d15fe0 Libation 4.0 prep: use new Accounts decrypt key 2020-08-21 22:15:25 -04:00
Robert McRackan
be84fb317e DecryptKey: static => account instance 2020-08-21 22:01:23 -04:00
Robert McRackan
3af010c1f5 Update ProcessRunner 2020-08-21 17:03:39 -04:00
Robert McRackan
714bb2ba50 Downloading to use new instance locale and account 2020-08-21 13:36:01 -04:00
Robert McRackan
2e5360f0ba Libation 4.0 prep: purge global static config Configuration.LocaleCountryCode 2020-08-21 11:50:11 -04:00
Robert McRackan
258775ff3f Purge static current locale from API: complete 2020-08-21 11:18:10 -04:00
Robert McRackan
82318ffab7 Progress toward purging static current locale 2020-08-21 09:29:33 -04:00
Robert McRackan
901572e7bb intermediate steps toward purging static current locale 2020-08-20 22:53:44 -04:00
Robert McRackan
cfa938360a Library to store book's account in db 2020-08-20 17:03:55 -04:00
Robert McRackan
80017ce9fd populate book locale on library update 2020-08-20 16:49:50 -04:00
Robert McRackan
c67972a327 Importers need access to Account 2020-08-20 16:09:07 -04:00
Robert McRackan
57ee150d3c api to use hardcoded jsonpath for now. Libation is fully functional again 2020-08-20 14:47:26 -04:00
Robert McRackan
57302e1b5c Create methods for test and demo use. Can also use temporarily in Libation until migration is complete 2020-08-19 15:42:54 -04:00
Robert McRackan
09dbc67914 refactor file migration 2020-08-18 07:14:58 -04:00
Robert McRackan
b768362eae migrate old files/settings 2020-08-18 06:51:12 -04:00
Robert McRackan
04a32533cb Libation 4.0 prep: migrate old settings => new files. remove old values. remove old file 2020-08-18 06:32:45 -04:00
Robert McRackan
1ad2135a3f Complete: Accounts, persistence, unit tests 2020-08-16 23:04:23 -04:00
Robert McRackan
643ae09b2b Libation 4.0 prep: in process of migrating to new settings files 2020-08-16 15:44:47 -04:00
Robert McRackan
8391e43b03 begin token file migration. INCOMPLETE 2020-08-13 17:01:06 -04:00
Robert McRackan
8a54eda4a0 Add temp jsonpath options during v3 => v4 migration prep 2020-08-13 15:06:22 -04:00
Robert McRackan
e0406378cb Libation 4.0 prep: Add legacy settings-file support. Move AudibleApiStorage 2020-08-13 14:37:16 -04:00
Robert McRackan
e1299331cc "ApiConnectionSettings" => "AccountsSettings" 2020-08-13 14:26:48 -04:00
Robert McRackan
248b336867 minor csproj changes 2020-08-13 14:21:59 -04:00
Robert McRackan
b7d96ae447 tiny refactor 2020-08-13 09:53:29 -04:00
Robert McRackan
8ab2af1c5d Rename file path var for clarity 2020-08-13 09:14:16 -04:00
Robert McRackan
2d459bb2cf Libation 4.0 prep: add new fields Book.Locale, LibraryBook.Account. Migrate db 2020-08-12 11:31:48 -04:00
Robert McRackan
aeb0d2a82b update nuget pkg.s 2020-08-12 10:15:04 -04:00
Robert McRackan
f50dab94a4 Libation 4.0 prep: incrementally incorporate jsonpath (1) all AccountsSettings.json access must use centralized jsonpath. For now == null 2020-08-11 14:19:35 -04:00
Robert McRackan
efa5cefa23 Libation 4.0 prep. EzApiCreator: remove locale option, add JsonPath support, libation wrapper to pass in jsonpath 2020-08-10 10:31:41 -04:00
Robert McRackan
2e4a97fde7 4.0 prep:
move IdentityTokens data to new AccountsSettings.json file
2020-08-06 14:01:12 -04:00
Robert McRackan
2f241806fa version update 2020-07-31 14:32:51 -04:00
Robert McRackan
e417f60a36 When changing locale, clear previous locale specific settings 2020-07-31 14:21:59 -04:00
Robert McRackan
b00f2bd908 Merge branch 'master' of https://github.com/rmcrackan/Libation 2020-07-31 14:05:44 -04:00
Robert McRackan
220cda42e7 Downloader internationalization bug 2020-07-31 14:03:49 -04:00
rmcrackan
f992a7ec64 Update README.md
Add download link to 'getting started'
2020-07-03 18:15:03 -04:00
Robert McRackan
c54c45df33 Bugfix. Audible changed how they handle categories, causing a new bug. Temp fix to get everything working again -- part 2 2020-06-01 21:51:47 -04:00
Robert McRackan
a8b9e187e6 Merge branch 'master' of https://github.com/rmcrackan/Libation 2020-05-26 14:38:34 -04:00
Robert McRackan
53f252e56f Bugfix. Audible changed how they handle categories, causing a new bug. Temp fix to get everything working again 2020-05-26 14:38:29 -04:00
rmcrackan
2827bc8904 Update README.md 2020-05-25 07:22:06 -04:00
rmcrackan
98a775fc5a Update README.md 2020-05-25 07:21:35 -04:00
Robert McRackan
f28a729d36 forgot to increment build number 2020-04-18 22:27:59 -04:00
Robert McRackan
00a6a4bf50 Merge branch 'master' of https://github.com/rmcrackan/Libation 2020-04-18 22:23:04 -04:00
Robert McRackan
fdefa7c3bf Dependency updated. Increase version 2020-04-18 22:22:59 -04:00
rmcrackan
244862299f Update README.md
Add Australia
2020-03-04 09:47:20 -05:00
Robert McRackan
4decf9d3b7 Experimental: add Australia to locale options 2020-03-03 15:31:41 -05:00
Robert McRackan
83f538d304 Improved logging 2020-02-18 12:20:13 -05:00
Robert McRackan
9e0e06e436 Bugfix: some series indexes/sequences formats cause library not to import 2020-02-17 16:41:12 -05:00
Robert McRackan
f27ac279b2 Bugfix: some series indexes/sequences could cause library not to import 2020-02-17 15:17:59 -05:00
Robert McRackan
ed03fd2451 increment csproj version 2020-02-14 14:08:51 -05:00
Robert McRackan
ccb60ae367 Bugfix: IsAuthorNarrated was returning no books 2020-02-14 14:01:36 -05:00
Robert McRackan
6ad541c199 fix weirdness with build number 2020-01-02 09:01:04 -05:00
Robert McRackan
9606acda26 update release notes 2019-12-31 13:00:36 -05:00
Robert McRackan
9abb9e376d null checks 2019-12-31 10:53:15 -05:00
Robert McRackan
f93498bfe3 v3.1.1 release 2019-12-31 10:06:57 -05:00
Robert McRackan
a13e1f27bb clicking on stoplight prompts with option to liberate full library 2019-12-31 10:04:55 -05:00
Robert McRackan
c7c1b4505b improved logging: at startup, config changes, library counts
better starting instructions to liberate library
2019-12-31 09:49:32 -05:00
Robert McRackan
d9e0f1aedf New feature: check if upgrade available on github 2019-12-27 22:08:43 -05:00
Robert McRackan
d8a0124b68 Better error when download service is unavailable 2019-12-26 13:04:56 -05:00
Robert McRackan
79e0a8fba7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-12-23 11:24:54 -05:00
Robert McRackan
8497987967 update release notes 2019-12-23 11:24:49 -05:00
rmcrackan
717fefd2c0 Update README.md 2019-12-23 11:17:00 -05:00
rmcrackan
066cae8e33 update readme 2019-12-23 11:15:32 -05:00
rmcrackan
9083574a77 Update README.md 2019-12-23 10:52:50 -05:00
rmcrackan
6b1ab9c777 Update README.md 2019-12-23 10:51:52 -05:00
rmcrackan
3be7c87c8e Update README.md 2019-12-23 10:46:52 -05:00
rmcrackan
8694d3206b Update README.md 2019-12-23 10:45:42 -05:00
Robert McRackan
f67f3805c6 add screenshot 2019-12-23 10:45:27 -05:00
Robert McRackan
612dd41b4b Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-12-23 10:42:00 -05:00
Robert McRackan
13378a482d delete unused screenshots 2019-12-23 10:41:55 -05:00
rmcrackan
352b498c23 Update README.md 2019-12-23 10:41:32 -05:00
rmcrackan
3a652cfb70 Update README.md 2019-12-23 10:38:32 -05:00
Robert McRackan
93e9ce31ba update readme images 2019-12-23 10:37:54 -05:00
Robert McRackan
69ed7767b2 update readme screenshots 2019-12-23 10:29:40 -05:00
Robert McRackan
6fcaa8d551 Improved pdf icons 2019-12-23 10:09:28 -05:00
Robert McRackan
15ece43463 Update icon. Add glow so it can be seen on black background. Not as attractive, but no longer invisible 2019-12-23 09:27:11 -05:00
Robert McRackan
25f5f0ed14 Change liberate text buttons to images 2019-12-20 16:37:50 -05:00
Robert McRackan
de66e5b405 Clean up for 3.1 beta 11 2019-12-18 14:27:27 -05:00
Robert McRackan
73c671b7c0 Removed temp fix of hard-coded logging level. Now is correctly set via config file 2019-12-18 10:50:46 -05:00
Robert McRackan
4994684690 Improved configuration management 2019-12-17 10:09:33 -05:00
Robert McRackan
6c757773f7 Minor string change 2019-12-16 13:40:54 -05:00
Robert McRackan
2d0af587d5 Add new settings options to main form 2019-12-16 11:18:50 -05:00
Robert McRackan
c7891dc448 2 bug fixes which prevented saving to db 2019-12-16 10:32:38 -05:00
Robert McRackan
95ae8335a1 Improved settings 2019-12-13 16:11:55 -05:00
Robert McRackan
2fa5170f28 Pluralize LibationWinForms 2019-12-10 10:45:14 -05:00
Robert McRackan
123a32ff9b Move application logic from view to Launcher 2019-12-10 10:33:51 -05:00
Robert McRackan
41620352e8 Add appsettings.json to main application. This will overwrite the one previously inherited from DataLayer 2019-12-09 15:24:25 -05:00
Robert McRackan
54eea8ddae DataLayer appsettings.json is only used for Migrations. It is not directly used by application. Make notes 2019-12-09 14:51:33 -05:00
Robert McRackan
bcc237c693 sqlite db file should live in LibationFiles dir, not in app dir 2019-12-09 14:44:46 -05:00
Robert McRackan
65dc273e12 update release notes 2019-12-06 10:23:05 -05:00
Robert McRackan
7bb4853903 Clicking Liberate button on a liberated item navigates to that audio file 2019-12-06 09:53:07 -05:00
Robert McRackan
f9917d4064 edit comments 2019-12-05 16:14:39 -05:00
Robert McRackan
0f9f0d9eae New feature: liberate individual book 2019-12-05 15:55:46 -05:00
Robert McRackan
498aeaac3a Change "Download Status" column to "Liberate" button column. Displays text status. No functionality added yet 2019-12-05 12:39:38 -05:00
Robert McRackan
9534969c2d Series should sort irrespective of initial the/a/an (like Title already does) 2019-12-04 13:32:25 -05:00
Robert McRackan
b120bb8a66 Replace custom FileLogger with Serilog 2019-12-04 09:58:31 -05:00
Robert McRackan
f8a51f0882 Upgrade to Core 3.1 2019-12-03 16:47:53 -05:00
Robert McRackan
7529fdf878 Add logging 2019-12-02 15:14:19 -05:00
Robert McRackan
f1aacd92ad Bugfix: decrypt file conflict 2019-12-02 14:39:46 -05:00
Robert McRackan
b1b426427c Bugfix: initial bottom counts can throw error when a book was moved since Libation was last run 2019-11-27 16:57:35 -05:00
Robert McRackan
0683e5f55b Optimize tag persistence 2019-11-27 15:36:34 -05:00
Robert McRackan
5c81441f83 Bugfix: decrypt book with no author 2019-11-27 09:27:43 -05:00
Robert McRackan
57bc74cd23 Improved logging for file decrypt 2019-11-26 13:13:16 -05:00
Robert McRackan
1cecd4ba2e Improved logging. Updated nuget packages 2019-11-26 10:42:38 -05:00
Robert McRackan
7a4bd639fb Add notes for v3.1-beta.5 2019-11-25 14:12:07 -05:00
Robert McRackan
87e6a46808 remove db file 2019-11-25 14:09:53 -05:00
Robert McRackan
a2e30df51f Improved importing 2019-11-25 13:45:29 -05:00
Robert McRackan
c8e759c067 update notes 2019-11-24 21:47:03 -05:00
Robert McRackan
6c9074169a Added beta-specific logging 2019-11-24 21:45:35 -05:00
Robert McRackan
1375da2065 Improved performance calculating "liberated" status 2019-11-21 23:07:06 -05:00
Robert McRackan
d5d72a13f6 Login dialogs can get lost. Show on task bar 2019-11-20 13:00:13 -05:00
Robert McRackan
a1ba324166 Has PDFs => Has PDF 2019-11-19 13:34:38 -05:00
Robert McRackan
b0139c47be live update newly downloaded and cached images 2019-11-19 11:22:41 -05:00
Robert McRackan
80b0ef600d Better ToString for DataLayer objects 2019-11-19 09:54:42 -05:00
Robert McRackan
f3128b562d Fix performance issues, esp regarding saving tags 2019-11-18 14:37:17 -05:00
Robert McRackan
6734dec55c remove TODO from git 2019-11-16 21:30:52 -05:00
Robert McRackan
b9314ac678 Added validation and error handling
BETA READY
2019-11-15 22:43:04 -05:00
Robert McRackan
e319326c30 Switch to SQLite 2019-11-15 16:34:16 -05:00
Robert McRackan
5474446f62 Minor stablizing changes before the switch to sqlite 2019-11-15 15:58:21 -05:00
Robert McRackan
d53a617bc8 Download logic in DownloadPdf should look more like DownloadBook. Extract common d/l pattern to base class 2019-11-15 12:50:00 -05:00
Robert McRackan
9076fae6f6 - add retry logic to library get
- UI bug fix when no library yet
- publishing related xml added to data and UI projects
- 'how to publish' notes
2019-11-14 14:17:20 -05:00
Robert McRackan
5d4a97cdc4 Download PDF included with backup book. Update README 2019-11-13 11:24:38 -05:00
Robert McRackan
bbe745f487 'download book' now includes pdf 2019-11-13 11:20:37 -05:00
Robert McRackan
47360c036d Pre-beta: picture storage should be more responsive if on disk 2019-11-13 11:11:00 -05:00
Robert McRackan
e69df2abbc Pre-beta: BackupBook now includes downloading pdf. This replaces the need for throttling pdf downloads 2019-11-13 09:49:23 -05:00
Robert McRackan
88d49acdad Pre-beta: image download throttling 2019-11-13 08:37:57 -05:00
Robert McRackan
01a914c390 streamline indexing ui workflow 2019-11-12 12:54:54 -05:00
Robert McRackan
0b42b8ee49 Re-index if search engine files get deleted 2019-11-11 16:16:17 -05:00
Robert McRackan
c598576683 - Change name LibationWinForm.exe => Libation.exe
- lots of pre-beta bug fixes
2019-11-11 11:03:38 -05:00
Robert McRackan
b126eed028 update readme search sample images 2019-11-07 14:41:25 -05:00
rmcrackan
3020a116cf Update README.md
add Filters to readme
2019-11-07 13:43:17 -05:00
Robert McRackan
88b9ea2f2d Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-11-07 13:43:02 -05:00
Robert McRackan
159c04c4b1 add readme image 2019-11-07 13:42:53 -05:00
rmcrackan
fad0f021ed Update README.md
format readme searches
2019-11-07 13:27:22 -05:00
rmcrackan
52f21dcab1 Update README.md 2019-11-07 13:26:01 -05:00
Robert McRackan
a6b89ca4c5 Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-11-07 13:25:20 -05:00
Robert McRackan
650c00cf66 add readme images 2019-11-07 13:24:48 -05:00
rmcrackan
089edf934e Update README.md 2019-11-07 11:25:06 -05:00
rmcrackan
efe2b19e24 Update README.md
add Search to readme
2019-11-07 11:22:50 -05:00
Robert McRackan
c41dc9a6db add readme img.s 2019-11-07 11:15:42 -05:00
Robert McRackan
707cb78dbc Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-11-07 11:06:48 -05:00
Robert McRackan
fc0d97d8e7 add readme img 2019-11-07 11:06:44 -05:00
rmcrackan
1494a15a6e Update README.md
readme formatting
2019-11-07 09:12:39 -05:00
rmcrackan
ac0de2a05e Update README.md
manually add table of contents
2019-11-07 09:10:50 -05:00
rmcrackan
3cc80b6a24 Update README.md
add tags to readme
2019-11-06 22:43:50 -05:00
Robert McRackan
38b04be6ba Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-11-06 22:38:27 -05:00
Robert McRackan
0c52d443b2 more readme images 2019-11-06 22:37:54 -05:00
rmcrackan
aa0ebac50e Update README.md
Added book and pdf download instructions
2019-11-06 22:03:40 -05:00
Robert McRackan
debebf6ee0 update readme images 2019-11-06 22:02:54 -05:00
Robert McRackan
9034288e7c updated screenshots 2019-11-06 16:58:35 -05:00
Robert McRackan
19ee02ced4 Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-11-06 16:53:43 -05:00
Robert McRackan
33723d7412 new screenshots 2019-11-06 16:53:39 -05:00
rmcrackan
a01a67e34a Update README.md
Add import instructions to readme
2019-11-06 15:16:34 -05:00
Robert McRackan
ecdb510513 add files for github readme 2019-11-06 15:10:45 -05:00
Robert McRackan
0b08bb3c4a Display settings wizard on first run 2019-11-06 13:30:23 -05:00
Robert McRackan
22e5dbf83d blank grid if no products 2019-11-06 09:01:57 -05:00
Robert McRackan
3b33648267 Merge branch 'master' of https://github.com/rmcrackan/Libation 2019-11-06 08:40:37 -05:00
Robert McRackan
8709518cd7 hide lucene debug search string 2019-11-06 08:40:32 -05:00
rmcrackan
3da1dff4d8 Update README 2019-11-05 22:15:20 -05:00
Robert McRackan
6aa544b322 Minor changes 2019-11-05 21:48:02 -05:00
Robert McRackan
bd993b4e4d Removed "legacy inAudible wire-up code" 2019-11-05 13:47:56 -05:00
Robert McRackan
4f7b66d64e Legacy inAudible wire-up code is still present but is commented out. All future check-ins are not guaranteed to have inAudible wire-up code 2019-11-05 13:45:19 -05:00
Robert McRackan
df90fc5361 All scraping code removed 2019-11-05 13:42:11 -05:00
366 changed files with 13969 additions and 13533 deletions

5
.gitignore vendored
View File

@@ -328,3 +328,8 @@ ASALocalRun/
# MFractors (Xamarin productivity tool) working folder
.mfractor/
# manually ignored files
/__TODO.txt
/DataLayer/LibationContext.db

View File

@@ -0,0 +1,40 @@
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using Dinah.Core.Diagnostics;
namespace AaxDecrypter
{
public class AAXChapters : Chapters
{
public AAXChapters(string file)
{
var info = new ProcessStartInfo
{
FileName = DecryptSupportLibraries.ffprobePath,
Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\""
};
var xml = info.RunHidden().Output;
var xmlDocument = new System.Xml.XmlDocument();
xmlDocument.LoadXml(xml);
var chaptersXml = xmlDocument.SelectNodes("/ffprobe/chapters/chapter")
.Cast<System.Xml.XmlNode>()
.Where(n => n.Name == "chapter");
foreach (var cnode in chaptersXml)
{
double startTime = double.Parse(cnode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture);
double endTime = double.Parse(cnode.Attributes["end_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture);
string chapterTitle = cnode.ChildNodes
.Cast<System.Xml.XmlNode>()
.Where(childnode => childnode.Attributes["key"].Value == "title")
.Select(childnode => childnode.Attributes["value"].Value)
.FirstOrDefault();
AddChapter(new Chapter(startTime, endTime, chapterTitle));
}
}
}
}

View File

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

View File

@@ -62,9 +62,10 @@ namespace AaxDecrypter
public Tags tags { get; private set; }
public EncodingInfo encodingInfo { get; private set; }
public static async Task<AaxToM4bConverter> CreateAsync(string inputFile, string decryptKey)
public static async Task<AaxToM4bConverter> CreateAsync(string inputFile, string decryptKey, Chapters chapters = null)
{
var converter = new AaxToM4bConverter(inputFile, decryptKey);
converter.chapters = chapters ?? new AAXChapters(inputFile);
await converter.prelimProcessing();
converter.printPrelim();
@@ -72,8 +73,9 @@ namespace AaxDecrypter
}
private AaxToM4bConverter(string inputFile, string decryptKey)
{
if (string.IsNullOrWhiteSpace(inputFile)) throw new ArgumentNullException(nameof(inputFile), "Input file may not be null or whitespace");
if (!File.Exists(inputFile)) throw new ArgumentNullException(nameof(inputFile), "File does not exist");
ArgumentValidator.EnsureNotNullOrWhiteSpace(inputFile, nameof(inputFile));
if (!File.Exists(inputFile))
throw new ArgumentNullException(nameof(inputFile), "File does not exist");
steps = new StepSequence
{
@@ -89,53 +91,43 @@ namespace AaxDecrypter
["End: Create Nfo"] = End_CreateNfo
};
this.inputFileName = inputFile;
inputFileName = inputFile;
this.decryptKey = decryptKey;
}
private async Task prelimProcessing()
{
this.tags = new Tags(this.inputFileName);
this.encodingInfo = new EncodingInfo(this.inputFileName);
this.chapters = new Chapters(this.inputFileName, this.tags.duration.TotalSeconds);
tags = new Tags(inputFileName);
encodingInfo = new EncodingInfo(inputFileName);
var defaultFilename = Path.Combine(
Path.GetDirectoryName(this.inputFileName),
getASCIITag(this.tags.author),
getASCIITag(this.tags.title) + ".m4b"
Path.GetDirectoryName(inputFileName),
PathLib.ToPathSafeString(tags.author),
PathLib.ToPathSafeString(tags.title) + ".m4b"
);
SetOutputFilename(defaultFilename);
// set default name
SetOutputFilename(defaultFilename);
await Task.Run(() => saveCover(inputFileName));
}
private string getASCIITag(string property)
{
foreach (char ch in new string(Path.GetInvalidFileNameChars()) + new string(Path.GetInvalidPathChars()))
property = property.Replace(ch.ToString(), "");
return property;
}
private void saveCover(string aaxFile)
{
using var file = TagLib.File.Create(aaxFile, "audio/mp4", TagLib.ReadStyle.Average);
this.coverBytes = file.Tag.Pictures[0].Data.Data;
coverBytes = file.Tag.Pictures[0].Data.Data;
}
private void printPrelim()
{
Console.WriteLine("Audible Book ID = " + tags.id);
Console.WriteLine($"Audible Book ID = {tags.id}");
Console.WriteLine("Book: " + tags.title);
Console.WriteLine("Author: " + tags.author);
Console.WriteLine("Narrator: " + tags.narrator);
Console.WriteLine("Year: " + tags.year);
Console.WriteLine("Total Time: "
+ tags.duration.GetTotalTimeFormatted()
+ " in " + chapters.Count() + " chapters");
Console.WriteLine("WARNING-Source is "
+ encodingInfo.originalBitrate + " kbits @ "
+ encodingInfo.sampleRate + "Hz, "
+ encodingInfo.channels + " channels");
Console.WriteLine($"Book: {tags.title}");
Console.WriteLine($"Author: {tags.author}");
Console.WriteLine($"Narrator: {tags.narrator}");
Console.WriteLine($"Year: {tags.year}");
Console.WriteLine($"Total Time: {tags.duration.GetTotalTimeFormatted()} in {chapters.Count} chapters");
Console.WriteLine($"WARNING-Source is {encodingInfo.originalBitrate} kbits @ {encodingInfo.sampleRate}Hz, {encodingInfo.channels} channels");
}
public bool Run()
@@ -156,21 +148,19 @@ namespace AaxDecrypter
public void SetOutputFilename(string outFileName)
{
this.outputFileName = outFileName;
outputFileName = PathLib.ReplaceExtension(outFileName, ".m4b");
outDir = Path.GetDirectoryName(outputFileName);
if (Path.GetExtension(this.outputFileName) != ".m4b")
this.outputFileName = outputFileWithNewExt(".m4b");
this.outDir = Path.GetDirectoryName(this.outputFileName);
if (File.Exists(outputFileName))
File.Delete(outputFileName);
}
private string outputFileWithNewExt(string extension)
=> Path.Combine(this.outDir, Path.GetFileNameWithoutExtension(this.outputFileName) + '.' + extension.Trim('.'));
private string outputFileWithNewExt(string extension) => PathLib.ReplaceExtension(outputFileName, extension);
public bool Step1_CreateDir()
{
ProcessRunner.WorkingDir = this.outDir;
Directory.CreateDirectory(this.outDir);
ProcessRunner.WorkingDir = outDir;
Directory.CreateDirectory(outDir);
return true;
}
@@ -178,7 +168,7 @@ namespace AaxDecrypter
{
DecryptProgressUpdate?.Invoke(this, 0);
var tempRipFile = Path.Combine(this.outDir, "funny.aac");
var tempRipFile = Path.Combine(outDir, "funny.aac");
var fail = "WARNING-Decrypt failure. ";
@@ -193,7 +183,7 @@ namespace AaxDecrypter
if (returnCode == -99)
{
Console.WriteLine($"{fail}Incorrect decrypt key: {decryptKey}");
this.decryptKey = null;
decryptKey = null;
returnCode = getKey_decrypt(tempRipFile);
}
}
@@ -232,7 +222,7 @@ namespace AaxDecrypter
Console.WriteLine("Cracking activation bytes");
var activation_bytes = BytesCracker.GetActivationBytes(checksum);
this.decryptKey = activation_bytes;
decryptKey = activation_bytes;
Console.WriteLine("Activation bytes cracked. Decrypt key: " + activation_bytes);
}
@@ -243,10 +233,10 @@ namespace AaxDecrypter
Console.WriteLine("Decrypting with key " + decryptKey);
var returnCode = 100;
var thread = new Thread(() => returnCode = this.ngDecrypt());
var thread = new Thread(() => returnCode = ngDecrypt());
thread.Start();
double fileLen = new FileInfo(this.inputFileName).Length;
double fileLen = new FileInfo(inputFileName).Length;
while (thread.IsAlive && returnCode == 100)
{
Thread.Sleep(500);
@@ -266,34 +256,35 @@ namespace AaxDecrypter
var info = new ProcessStartInfo
{
FileName = DecryptSupportLibraries.mp4trackdumpPath,
Arguments = "-c " + this.encodingInfo.channels + " -r " + this.encodingInfo.sampleRate + " \"" + this.inputFileName + "\""
Arguments = "-c " + encodingInfo.channels + " -r " + encodingInfo.sampleRate + " \"" + inputFileName + "\""
};
info.EnvironmentVariables["VARIABLE"] = decryptKey;
var (output, exitCode) = info.RunHidden();
var result = info.RunHidden();
// bad checksum -- bad decrypt key
if (output.Contains("checksums mismatch, aborting!"))
if (result.Output.Contains("checksums mismatch, aborting!"))
return -99;
return exitCode;
return result.ExitCode;
}
// temp file names for steps 3, 4, 5
string tempChapsPath => Path.Combine(this.outDir, "tempChaps.mp4");
// temp file names for steps 3, 4, 5
string tempChapsGuid { get; } = Guid.NewGuid().ToString().ToUpper().Replace("-", "");
string tempChapsPath => Path.Combine(outDir, $"tempChaps_{tempChapsGuid}.mp4");
string mp4_file => outputFileWithNewExt(".mp4");
string ff_txt_file => mp4_file + ".ff.txt";
public bool Step3_Chapterize()
{
string str1 = "";
if (this.chapters.FirstChapterStart != 0.0)
var str1 = "";
if (chapters.FirstChapter.StartTime != 0.0)
{
str1 = " -ss " + this.chapters.FirstChapterStart.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + (this.chapters.LastChapterStart - 1.0).ToString("0.000", CultureInfo.InvariantCulture) + " ";
str1 = " -ss " + chapters.FirstChapter.StartTime.ToString("0.000", CultureInfo.InvariantCulture) + " -t " + chapters.LastChapter.EndTime.ToString("0.000", CultureInfo.InvariantCulture) + " ";
}
string ffmpegTags = this.tags.GenerateFfmpegTags();
string ffmpegChapters = this.chapters.GenerateFfmpegChapters();
var ffmpegTags = tags.GenerateFfmpegTags();
var ffmpegChapters = chapters.GenerateFfmpegChapters();
File.WriteAllText(ff_txt_file, ffmpegTags + ffmpegChapters);
var tagAndChapterInfo = new ProcessStartInfo
@@ -309,8 +300,8 @@ namespace AaxDecrypter
public bool Step4_InsertCoverArt()
{
// save cover image as temp file
var coverPath = Path.Combine(this.outDir, "cover-" + Guid.NewGuid() + ".jpg");
FileExt.CreateFile(coverPath, this.coverBytes);
var coverPath = Path.Combine(outDir, "cover-" + Guid.NewGuid() + ".jpg");
FileExt.CreateFile(coverPath, coverBytes);
var insertCoverArtInfo = new ProcessStartInfo
{
@@ -329,26 +320,26 @@ namespace AaxDecrypter
{
FileExt.SafeDelete(mp4_file);
FileExt.SafeDelete(ff_txt_file);
FileExt.SafeMove(tempChapsPath, this.outputFileName);
FileExt.SafeMove(tempChapsPath, outputFileName);
return true;
}
public bool Step6_AddTags()
{
this.tags.AddAppleTags(this.outputFileName);
tags.AddAppleTags(outputFileName);
return true;
}
public bool End_CreateCue()
{
File.WriteAllText(outputFileWithNewExt(".cue"), this.chapters.GetCuefromChapters(Path.GetFileName(this.outputFileName)));
File.WriteAllText(outputFileWithNewExt(".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), chapters));
return true;
}
public bool End_CreateNfo()
{
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateNfoContents(AppName, this.tags, this.encodingInfo, this.chapters));
File.WriteAllText(outputFileWithNewExt(".nfo"), NFO.CreateContents(AppName, tags, encodingInfo, chapters));
return true;
}
}

View File

@@ -21,8 +21,7 @@ namespace AaxDecrypter
};
// checksum is in the debug info. ffprobe's debug info is written to stderr, not stdout
var readErrorOutput = true;
var ffprobeStderr = info.RunHidden(readErrorOutput).Output;
var ffprobeStderr = info.RunHidden().Error;
// example checksum line:
// ... [aax] file checksum == 0c527840c4f18517157eb0b4f9d6f9317ce60cd1

27
AaxDecrypter/Chapter.cs Normal file
View File

@@ -0,0 +1,27 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace AaxDecrypter
{
public class Chapter
{
public Chapter(double startTime, double endTime, string title)
{
StartTime = startTime;
EndTime = endTime;
Title = title;
}
/// <summary>
/// Chapter start time, in seconds.
/// </summary>
public double StartTime { get; private set; }
/// <summary>
/// Chapter end time, in seconds.
/// </summary>
public double EndTime { get; private set; }
public string Title { get; private set; }
}
}

40
AaxDecrypter/Chapters.cs Normal file
View File

@@ -0,0 +1,40 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
namespace AaxDecrypter
{
public abstract class Chapters
{
private List<Chapter> _chapterList = new();
public int Count => _chapterList.Count;
public Chapter FirstChapter => _chapterList[0];
public Chapter LastChapter => _chapterList[Count - 1];
public IEnumerable<Chapter> ChapterList => _chapterList.AsEnumerable();
public IEnumerable<TimeSpan> GetBeginningTimes() => ChapterList.Select(c => TimeSpan.FromSeconds(c.StartTime));
protected void AddChapter(Chapter chapter)
{
_chapterList.Add(chapter);
}
protected void AddChapters(IEnumerable<Chapter> chapters)
{
_chapterList.AddRange(chapters);
}
public string GenerateFfmpegChapters()
{
var stringBuilder = new StringBuilder();
foreach (Chapter c in ChapterList)
{
stringBuilder.Append("[CHAPTER]\n");
stringBuilder.Append("TIMEBASE=1/1000\n");
stringBuilder.Append("START=" + c.StartTime * 1000 + "\n");
stringBuilder.Append("END=" + c.EndTime * 1000 + "\n");
stringBuilder.Append("title=" + c.Title + "\n");
}
return stringBuilder.ToString();
}
}
}

60
AaxDecrypter/Cue.cs Normal file
View File

@@ -0,0 +1,60 @@
using System;
using System.IO;
using System.Linq;
using System.Text;
using Dinah.Core;
namespace AaxDecrypter
{
public static class Cue
{
public static string CreateContents(string filePath, Chapters chapters)
{
var stringBuilder = new StringBuilder();
stringBuilder.AppendLine(GetFileLine(filePath, "MP3"));
var trackCount = 0;
foreach (Chapter c in chapters.ChapterList)
{
trackCount++;
var startTime = TimeSpan.FromSeconds(c.StartTime);
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss\\:ff}");
}
return stringBuilder.ToString();
}
public static void UpdateFileName(FileInfo cueFileInfo, string audioFilePath)
=> UpdateFileName(cueFileInfo.FullName, audioFilePath);
public static void UpdateFileName(string cueFilePath, FileInfo audioFileInfo)
=> UpdateFileName(cueFilePath, audioFileInfo.FullName);
public static void UpdateFileName(FileInfo cueFileInfo, FileInfo audioFileInfo)
=> UpdateFileName(cueFileInfo.FullName, audioFileInfo.FullName);
public static void UpdateFileName(string cueFilePath, string audioFilePath)
{
var cueContents = File.ReadAllLines(cueFilePath);
for (var i = 0; i < cueContents.Length; i++)
{
var line = cueContents[i];
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
continue;
var fileTypeBegins = line.LastIndexOf(" ") + 1;
cueContents[i] = GetFileLine(audioFilePath, line[fileTypeBegins..]);
break;
}
File.WriteAllLines(cueFilePath, cueContents);
}
private static string GetFileLine(string filePath, string audioType) => $"FILE {Path.GetFileName(filePath).SurroundWithQuotes()} {audioType}";
}
}

54
AaxDecrypter/NFO.cs Normal file
View File

@@ -0,0 +1,54 @@
namespace AaxDecrypter
{
public static class NFO
{
public static string CreateContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters)
{
var _hours = (int)tags.duration.TotalHours;
var myDuration
= (_hours > 0 ? _hours + " hours, " : "")
+ tags.duration.Minutes + " minutes, "
+ tags.duration.Seconds + " seconds";
var header
= "General Information\r\n"
+ "===================\r\n"
+ $" Title: {tags.title}\r\n"
+ $" Author: {tags.author}\r\n"
+ $" Read By: {tags.narrator}\r\n"
+ $" Copyright: {tags.year}\r\n"
+ $" Audiobook Copyright: {tags.year}\r\n";
if (tags.genre != "")
header += $" Genre: {tags.genre}\r\n";
var s
= header
+ $" Publisher: {tags.publisher}\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: {encodingInfo.sampleRate} Hz\r\n"
+ $" Source Channels: {encodingInfo.channels}\r\n"
+ $" Source Bitrate: {encodingInfo.originalBitrate} kbits\r\n"
+ "\r\n"
+ " Lossless Encode: Yes\r\n"
+ " Encoded Codec: AAC / M4B\r\n"
+ $" Encoded Sample Rate: {encodingInfo.sampleRate} Hz\r\n"
+ $" Encoded Channels: {encodingInfo.channels}\r\n"
+ $" Encoded Bitrate: {encodingInfo.originalBitrate} kbits\r\n"
+ "\r\n"
+ $" Ripper: {ripper}\r\n"
+ "\r\n"
+ "\r\n"
+ "Book Description\r\n"
+ "================\r\n"
+ tags.comments;
return s;
}
}
}

67
AaxDecrypter/Tags.cs Normal file
View File

@@ -0,0 +1,67 @@
using System;
using TagLib;
using TagLib.Mpeg4;
using Dinah.Core;
namespace AaxDecrypter
{
public class Tags
{
public string title { get; }
public string album { get; }
public string author { get; }
public string comments { get; }
public string narrator { get; }
public string year { get; }
public string publisher { get; }
public string id { get; }
public string genre { get; }
public TimeSpan duration { get; }
// input file
public Tags(string file)
{
using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
title = tagLibFile.Tag.Title.Replace(" (Unabridged)", "");
album = tagLibFile.Tag.Album.Replace(" (Unabridged)", "");
author = tagLibFile.Tag.FirstPerformer ?? "[unknown]";
year = tagLibFile.Tag.Year.ToString();
comments = tagLibFile.Tag.Comment ?? "";
duration = tagLibFile.Properties.Duration;
genre = tagLibFile.Tag.FirstGenre ?? "";
var tag = tagLibFile.GetTag(TagTypes.Apple, true);
publisher = tag.Publisher ?? "";
narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer;
comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description;
id = tag.AudibleCDEK;
}
// my best guess of what this step is doing:
// re-publish the data we read from the input file => output file
public void AddAppleTags(string file)
{
using var tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
var tag = (AppleTag)tagLibFile.GetTag(TagTypes.Apple, true);
tag.Publisher = publisher;
tag.LongDescription = comments;
tag.Description = comments;
tagLibFile.Save();
}
public string GenerateFfmpegTags()
=> $";FFMETADATA1"
+ $"\nmajor_brand=aax"
+ $"\nminor_version=1"
+ $"\ncompatible_brands=aax M4B mp42isom"
+ $"\ndate={year}"
+ $"\ngenre={genre}"
+ $"\ntitle={title}"
+ $"\nartist={author}"
+ $"\nalbum={album}"
+ $"\ncomposer={narrator}"
+ $"\ncomment={comments.Truncate(254)}"
+ $"\ndescription={comments}"
+ $"\n";
}
}

View File

@@ -1,95 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using Dinah.Core.Diagnostics;
namespace AaxDecrypter
{
public class Chapters
{
private List<double> markers { get; }
public double FirstChapterStart => markers[0];
public double LastChapterStart => markers[markers.Count - 1];
public Chapters(string file, double totalTime)
{
this.markers = getAAXChapters(file);
// add end time
this.markers.Add(totalTime);
}
private static List<double> getAAXChapters(string file)
{
var info = new ProcessStartInfo
{
FileName = DecryptSupportLibraries.ffprobePath,
Arguments = "-loglevel panic -show_chapters -print_format xml \"" + file + "\""
};
var xml = info.RunHidden().Output;
var xmlDocument = new System.Xml.XmlDocument();
xmlDocument.LoadXml(xml);
var chapters = xmlDocument.SelectNodes("/ffprobe/chapters/chapter")
.Cast<System.Xml.XmlNode>()
.Select(xmlNode => double.Parse(xmlNode.Attributes["start_time"].Value.Replace(",", "."), CultureInfo.InvariantCulture))
.ToList();
return chapters;
}
// subtract 1 b/c end time marker is a real entry but isn't a real chapter
public int Count() => this.markers.Count - 1;
public string GetCuefromChapters(string fileName)
{
var stringBuilder = new StringBuilder();
if (fileName != "")
{
stringBuilder.Append("FILE \"" + fileName + "\" MP4\n");
}
for (var i = 0; i < Count(); i++)
{
var chapter = i + 1;
var timeSpan = TimeSpan.FromSeconds(this.markers[i]);
var minutes = Math.Floor(timeSpan.TotalMinutes).ToString();
var seconds = timeSpan.Seconds.ToString("D2");
var milliseconds = (timeSpan.Milliseconds / 10).ToString("D2");
string str = minutes + ":" + seconds + ":" + milliseconds;
stringBuilder.Append("TRACK " + chapter + " AUDIO\n");
stringBuilder.Append(" TITLE \"Chapter " + chapter.ToString("D2") + "\"\n");
stringBuilder.Append(" INDEX 01 " + str + "\n");
}
return stringBuilder.ToString();
}
public string GenerateFfmpegChapters()
{
var stringBuilder = new StringBuilder();
for (var i = 0; i < Count(); i++)
{
var chapter = i + 1;
var start = this.markers[i] * 1000.0;
var end = this.markers[i + 1] * 1000.0;
var chapterName = chapter.ToString("D3");
stringBuilder.Append("[CHAPTER]\n");
stringBuilder.Append("TIMEBASE=1/1000\n");
stringBuilder.Append("START=" + start + "\n");
stringBuilder.Append("END=" + end + "\n");
stringBuilder.Append("title=" + chapterName + "\n");
}
return stringBuilder.ToString();
}
}
}

View File

@@ -1,56 +0,0 @@
namespace AaxDecrypter
{
public static class NFO
{
public static string CreateNfoContents(string ripper, Tags tags, EncodingInfo encodingInfo, Chapters chapters)
{
int _hours = (int)tags.duration.TotalHours;
string myDuration
= (_hours > 0 ? _hours + " hours, " : "")
+ tags.duration.Minutes + " minutes, "
+ tags.duration.Seconds + " seconds";
string str4
= "General Information\r\n"
+ "===================\r\n"
+ " Title: " + tags.title + "\r\n"
+ " Author: " + tags.author + "\r\n"
+ " Read By: " + tags.narrator + "\r\n"
+ " Copyright: " + tags.year + "\r\n"
+ " Audiobook Copyright: " + tags.year + "\r\n";
if (tags.genre != "")
{
str4 = str4 + " Genre: " + tags.genre + "\r\n";
}
string s
= str4
+ " Publisher: " + tags.publisher + "\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: " + encodingInfo.sampleRate + " Hz\r\n"
+ " Source Channels: " + encodingInfo.channels + "\r\n"
+ " Source Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n"
+ "\r\n"
+ " Lossless Encode: Yes\r\n"
+ " Encoded Codec: AAC / M4B\r\n"
+ " Encoded Sample Rate: " + encodingInfo.sampleRate + " Hz\r\n"
+ " Encoded Channels: " + encodingInfo.channels + "\r\n"
+ " Encoded Bitrate: " + encodingInfo.originalBitrate + " kbits\r\n"
+ "\r\n"
+ " Ripper: " + ripper + "\r\n"
+ "\r\n"
+ "\r\n"
+ "Book Description\r\n"
+ "================\r\n"
+ tags.comments;
return s;
}
}
}

View File

@@ -1,74 +0,0 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.IO;
using System.Text;
using TagLib;
using TagLib.Mpeg4;
using Dinah.Core;
namespace AaxDecrypter
{
public class Tags
{
public string title { get; }
public string album { get; }
public string author { get; }
public string comments { get; }
public string narrator { get; }
public string year { get; }
public string publisher { get; }
public string id { get; }
public string genre { get; }
public TimeSpan duration { get; }
public Tags(string file)
{
using TagLib.File tagLibFile = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
this.title = tagLibFile.Tag.Title.Replace(" (Unabridged)", "");
this.album = tagLibFile.Tag.Album.Replace(" (Unabridged)", "");
this.author = tagLibFile.Tag.FirstPerformer;
this.year = tagLibFile.Tag.Year.ToString();
this.comments = tagLibFile.Tag.Comment;
this.duration = tagLibFile.Properties.Duration;
this.genre = tagLibFile.Tag.FirstGenre;
var tag = tagLibFile.GetTag(TagTypes.Apple, true);
this.publisher = tag.Publisher;
this.narrator = string.IsNullOrWhiteSpace(tagLibFile.Tag.FirstComposer) ? tag.Narrator : tagLibFile.Tag.FirstComposer;
this.comments = !string.IsNullOrWhiteSpace(tag.LongDescription) ? tag.LongDescription : tag.Description;
this.id = tag.AudibleCDEK;
}
public void AddAppleTags(string file)
{
using var file1 = TagLib.File.Create(file, "audio/mp4", ReadStyle.Average);
var tag = (AppleTag)file1.GetTag(TagTypes.Apple, true);
tag.Publisher = this.publisher;
tag.LongDescription = this.comments;
tag.Description = this.comments;
file1.Save();
}
public string GenerateFfmpegTags()
{
StringBuilder stringBuilder = new StringBuilder();
stringBuilder.Append(";FFMETADATA1\n");
stringBuilder.Append("major_brand=aax\n");
stringBuilder.Append("minor_version=1\n");
stringBuilder.Append("compatible_brands=aax M4B mp42isom\n");
stringBuilder.Append("date=" + this.year + "\n");
stringBuilder.Append("genre=" + this.genre + "\n");
stringBuilder.Append("title=" + this.title + "\n");
stringBuilder.Append("artist=" + this.author + "\n");
stringBuilder.Append("album=" + this.album + "\n");
stringBuilder.Append("composer=" + this.narrator + "\n");
stringBuilder.Append("comment=" + this.comments.Truncate(254) + "\n");
stringBuilder.Append("description=" + this.comments + "\n");
return stringBuilder.ToString();
}
}
}

View File

@@ -1,9 +1,14 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="27.0.2" />
<PackageReference Include="NPOI" Version="2.5.3" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />

View File

@@ -0,0 +1,16 @@
using System;
using DataLayer;
using FileManager;
namespace ApplicationServices
{
public static class DbContexts
{
//// idea for future command/query separation
// public static LibationContext GetCommandContext() { }
// public static LibationContext GetQueryContext() { }
public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString);
}
}

View File

@@ -0,0 +1,123 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using DtoImporterService;
using InternalUtilities;
using Serilog;
namespace ApplicationServices
{
public static class LibraryCommands
{
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts)
{
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
{
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts);
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var newCount = await importIntoDbAsync(importItems);
Log.Logger.Information($"Import: New count {newCount}");
await Task.Run(() => SearchEngineCommands.FullReIndex());
Log.Logger.Information("FullReIndex: success");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.MoveResponseBodyFile(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.ResponseBodyFilePath
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
var callback = loginCallbackFactoryFunc(account);
// get APIs in serial, esp b/c of logins
var api = await AudibleApiActions.GetApiAsync(callback, account);
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(api, account));
}
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
});
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api);
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)
{
using var context = DbContexts.GetContext();
var libraryImporter = new LibraryImporter(context);
var newCount = await Task.Run(() => libraryImporter.Import(importItems));
context.SaveChanges();
return newCount;
}
public static int UpdateTags(this LibationContext context, Book book, string newTags)
{
try
{
book.UserDefinedItem.Tags = newTags;
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
SearchEngineCommands.UpdateBookTags(book);
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error updating tags");
throw;
}
}
}
}

View File

@@ -0,0 +1,268 @@
using System;
using System.Collections.Generic;
using System.Linq;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using NPOI.XSSF.UserModel;
using Serilog;
namespace ApplicationServices
{
public class ExportDto
{
public static string GetName(string fieldName)
{
var property = typeof(ExportDto).GetProperty(fieldName);
var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0];
var description = (NameAttribute)attribute;
var text = description.Names;
return text[0];
}
[Name("Account")]
public string Account { get; set; }
[Name("Date Added to library")]
public DateTime DateAdded { get; set; }
[Name("Audible Product Id")]
public string AudibleProductId { get; set; }
[Name("Locale")]
public string Locale { get; set; }
[Name("Title")]
public string Title { get; set; }
[Name("Authors")]
public string AuthorNames { get; set; }
[Name("Narrators")]
public string NarratorNames { get; set; }
[Name("Length In Minutes")]
public int LengthInMinutes { get; set; }
[Name("Publisher")]
public string Publisher { get; set; }
[Name("Pdf url")]
public string PdfUrl { get; set; }
[Name("Series Names")]
public string SeriesNames { get; set; }
[Name("Series Order")]
public string SeriesOrder { get; set; }
[Name("Community Rating: Overall")]
public float? CommunityRatingOverall { get; set; }
[Name("Community Rating: Performance")]
public float? CommunityRatingPerformance { get; set; }
[Name("Community Rating: Story")]
public float? CommunityRatingStory { get; set; }
[Name("Cover Id")]
public string PictureId { get; set; }
[Name("Is Abridged?")]
public bool IsAbridged { get; set; }
[Name("Date Published")]
public DateTime? DatePublished { get; set; }
[Name("Categories")]
public string CategoriesNames { get; set; }
[Name("My Rating: Overall")]
public float? MyRatingOverall { get; set; }
[Name("My Rating: Performance")]
public float? MyRatingPerformance { get; set; }
[Name("My Rating: Story")]
public float? MyRatingStory { get; set; }
[Name("My Libation Tags")]
public string MyLibationTags { get; set; }
}
public static class LibToDtos
{
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
=> library.Select(a => new ExportDto
{
Account = a.Account,
DateAdded = a.DateAdded,
AudibleProductId = a.Book.AudibleProductId,
Locale = a.Book.Locale,
Title = a.Book.Title,
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Publisher = a.Book.Publisher,
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url,
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,
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
CommunityRatingStory = a.Book.Rating?.StoryRating,
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = a.Book.CategoriesNames.Any() ? a.Book.CategoriesNames.Aggregate((a, b) => $"{a}, {b}") : "",
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
MyLibationTags = a.Book.UserDefinedItem.Tags
}).ToList();
}
public static class LibraryExporter
{
public static void ToCsv(string saveFilePath)
{
using var context = DbContexts.GetContext();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
csv.WriteHeader(typeof(ExportDto));
csv.NextRecord();
csv.WriteRecords(dtos);
}
public static void ToJson(string saveFilePath)
{
using var context = DbContexts.GetContext();
var dtos = context.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 workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Library");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new[] {
nameof (ExportDto.Account),
nameof (ExportDto.DateAdded),
nameof (ExportDto.AudibleProductId),
nameof (ExportDto.Locale),
nameof (ExportDto.Title),
nameof (ExportDto.AuthorNames),
nameof (ExportDto.NarratorNames),
nameof (ExportDto.LengthInMinutes),
nameof (ExportDto.Publisher),
nameof (ExportDto.PdfUrl),
nameof (ExportDto.SeriesNames),
nameof (ExportDto.SeriesOrder),
nameof (ExportDto.CommunityRatingOverall),
nameof (ExportDto.CommunityRatingPerformance),
nameof (ExportDto.CommunityRatingStory),
nameof (ExportDto.PictureId),
nameof (ExportDto.IsAbridged),
nameof (ExportDto.DatePublished),
nameof (ExportDto.CategoriesNames),
nameof (ExportDto.MyRatingOverall),
nameof (ExportDto.MyRatingPerformance),
nameof (ExportDto.MyRatingStory),
nameof (ExportDto.MyLibationTags)
};
var col = 0;
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
var name = ExportDto.GetName(c);
cell.SetCellValue(name);
cell.CellStyle = detailSubtotalCellStyle;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
rowIndex++;
// Add data rows
foreach (var dto in dtos)
{
col = 0;
row = sheet.CreateRow(rowIndex);
row.CreateCell(col++).SetCellValue(dto.Account);
var dateAddedCell = row.CreateCell(col++);
dateAddedCell.CellStyle = dateStyle;
dateAddedCell.SetCellValue(dto.DateAdded);
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
row.CreateCell(col++).SetCellValue(dto.Locale);
row.CreateCell(col++).SetCellValue(dto.Title);
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
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.SeriesNames);
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
col = createCell(row, col, dto.CommunityRatingOverall);
col = createCell(row, col, dto.CommunityRatingPerformance);
col = createCell(row, col, dto.CommunityRatingStory);
row.CreateCell(col++).SetCellValue(dto.PictureId);
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
var datePubCell = row.CreateCell(col++);
datePubCell.CellStyle = dateStyle;
if (dto.DatePublished.HasValue)
datePubCell.SetCellValue(dto.DatePublished.Value);
else
datePubCell.SetCellValue("");
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
col = createCell(row, col, dto.MyRatingOverall);
col = createCell(row, col, dto.MyRatingPerformance);
col = createCell(row, col, dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
rowIndex++;
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
}
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
{
if (nullableFloat.HasValue)
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
else
row.CreateCell(col++).SetCellValue("");
return col;
}
}
}

View File

@@ -0,0 +1,56 @@
using System;
using System.IO;
using DataLayer;
using LibationSearchEngine;
namespace ApplicationServices
{
public static class SearchEngineCommands
{
public static void FullReIndex()
{
var engine = new SearchEngine(DbContexts.GetContext());
engine.CreateNewIndex();
}
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
e.Search(searchString)
);
public static void UpdateBookTags(Book book) => performSearchEngineAction_safe(e =>
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags)
);
public static void UpdateIsLiberated(Book book) => performSearchEngineAction_safe(e =>
e.UpdateIsLiberated(book.AudibleProductId)
);
private static void performSearchEngineAction_safe(Action<SearchEngine> action)
{
var engine = new SearchEngine(DbContexts.GetContext());
try
{
action(engine);
}
catch (FileNotFoundException)
{
FullReIndex();
action(engine);
}
}
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action)
{
var engine = new SearchEngine(DbContexts.GetContext());
try
{
return action(engine);
}
catch (FileNotFoundException)
{
FullReIndex();
return action(engine);
}
}
}
}

View File

@@ -1,25 +0,0 @@
using System;
using System.Threading.Tasks;
using AudibleApi;
using DtoImporterService;
using InternalUtilities;
namespace ApplicationService
{
public class LibraryIndexer
{
public async Task<(int totalCount, int newCount)> IndexAsync(ILoginCallback callback)
{
var audibleApiActions = new AudibleApiActions();
var items = await audibleApiActions.GetAllLibraryItemsAsync(callback);
var totalCount = items.Count;
var libImporter = new LibraryImporter();
var newCount = await Task.Run(() => libImporter.Import(items));
await SearchEngineActions.FullReIndexAsync();
return (totalCount, newCount);
}
}
}

View File

@@ -1,26 +0,0 @@
using System.Threading.Tasks;
using DataLayer;
namespace ApplicationService
{
public static class SearchEngineActions
{
public static async Task FullReIndexAsync()
{
var engine = new LibationSearchEngine.SearchEngine();
await engine.CreateNewIndexAsync().ConfigureAwait(false);
}
public static void UpdateBookTags(Book book)
{
var engine = new LibationSearchEngine.SearchEngine();
engine.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
}
public static async Task ProductReIndexAsync(string productId)
{
var engine = new LibationSearchEngine.SearchEngine();
await engine.UpdateBookAsync(productId).ConfigureAwait(false);
}
}
}

View File

@@ -1,11 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,66 +0,0 @@
using System;
using System.Linq;
using Dinah.Core;
namespace AudibleDotCom
{
public enum AudiblePageType
{
ProductDetails = 1,
Library = 2
}
public static class AudiblePageExt
{
public static AudiblePage GetAudiblePageRobust(this AudiblePageType audiblePage) => AudiblePage.FromPageType(audiblePage);
}
public abstract partial class AudiblePage : Enumeration<AudiblePage>
{
// useful for generic classes:
// public abstract class PageScraper<T> where T : AudiblePageRobust {
// public AudiblePage AudiblePage => AudiblePageRobust.GetAudiblePageFromType(typeof(T));
public static AudiblePageType GetAudiblePageFromType(Type audiblePageRobustType)
=> (AudiblePageType)GetAll().Single(t => t.GetType() == audiblePageRobustType).Id;
public AudiblePageType AudiblePageType { get; }
protected AudiblePage(AudiblePageType audiblePage, string abbreviation) : base((int)audiblePage, abbreviation) => AudiblePageType = audiblePage;
public static AudiblePage FromPageType(AudiblePageType audiblePage) => FromValue((int)audiblePage);
/// <summary>For pages which need a param, the param is marked with {0}</summary>
protected abstract string Url { get; }
public string GetUrl(string id) => string.Format(Url, id);
public string Abbreviation => DisplayName;
}
public abstract partial class AudiblePage : Enumeration<AudiblePage>
{
public static AudiblePage Library { get; } = LibraryPage.Instance;
public class LibraryPage : AudiblePage
{
#region singleton stuff
public static LibraryPage Instance { get; } = new LibraryPage();
static LibraryPage() { }
private LibraryPage() : base(AudiblePageType.Library, "LIB") { }
#endregion
protected override string Url => "http://www.audible.com/lib";
}
}
public abstract partial class AudiblePage : Enumeration<AudiblePage>
{
public static AudiblePage Product { get; } = ProductDetailPage.Instance;
public class ProductDetailPage : AudiblePage
{
#region singleton stuff
public static ProductDetailPage Instance { get; } = new ProductDetailPage();
static ProductDetailPage() { }
private ProductDetailPage() : base(AudiblePageType.ProductDetails, "PD") { }
#endregion
protected override string Url => "http://www.audible.com/pd/{0}";
}
}
}

View File

@@ -1,43 +0,0 @@
using FileManager;
namespace AudibleDotCom
{
public class AudiblePageSource
{
public AudiblePageType AudiblePage { get; }
public string Source { get; }
public string PageId { get; }
public AudiblePageSource(AudiblePageType audiblePage, string source, string pageId)
{
AudiblePage = audiblePage;
Source = source;
PageId = pageId;
}
/// <summary>declawed allows local file to safely be reloaded in chrome
/// NOTE ABOUT DECLAWED FILES
/// making them safer also breaks functionality
/// eg: previously hidden parts become visible. this changes how selenium can parse pages.
/// hidden elements don't expose .Text property</summary>
public AudiblePageSource Declawed() => new AudiblePageSource(AudiblePage, FileUtility.Declaw(Source), PageId);
public string Serialized() => $"<!-- |{AudiblePage.GetAudiblePageRobust().Abbreviation}|{(PageId ?? "").Trim()}| -->\r\n" + Source;
public static AudiblePageSource Deserialize(string serializedSource)
{
var endOfLine1 = serializedSource.IndexOf('\n');
var parameters = serializedSource
.Substring(0, endOfLine1)
.Split('|');
var abbrev = parameters[1];
var pageId = parameters[2];
var source = serializedSource.Substring(endOfLine1 + 1);
var audiblePage = AudibleDotCom.AudiblePage.FromDisplayName(abbrev).AudiblePageType;
return new AudiblePageSource(audiblePage, source, pageId);
}
}
}

View File

@@ -1,23 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Selenium.Support" Version="3.141.0" />
<PackageReference Include="Selenium.WebDriver" Version="3.141.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AudibleDotCom\AudibleDotCom.csproj" />
<ProjectReference Include="..\CookieMonster\CookieMonster.csproj" />
</ItemGroup>
<ItemGroup>
<None Update="chromedriver.exe">
<CopyToOutputDirectory>Always</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -1,184 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleDotCom;
using Dinah.Core.Humanizer;
using OpenQA.Selenium;
using OpenQA.Selenium.Chrome;
using OpenQA.Selenium.Support.UI;
namespace AudibleDotComAutomation
{
/// <summary>browser manipulation. web driver access
/// browser operators. create and store web driver, browser navigation which can vary depending on whether anon or auth'd
///
/// this base class: is online. no auth. used for most pages. retain no chrome cookies</summary>
public abstract class SeleniumRetriever : IPageRetriever
{
#region // chrome driver details
/*
HIDING CHROME CONSOLE WINDOW
hiding chrome console window has proven to cause more headaches than it solves. here's how to do it though:
// can also use CreateDefaultService() overloads to specify driver path and/or file name
var chromeDriverService = ChromeDriverService.CreateDefaultService();
chromeDriverService.HideCommandPromptWindow = true;
return new ChromeDriver(chromeDriverService, options);
HEADLESS CHROME
this WOULD be how to do headless. but amazon/audible are far too tricksy about their changes and anti-scraping measures
which renders 'headless' mode useless
var options = new ChromeOptions();
options.AddArgument("--headless");
SPECIFYING DRIVER LOCATION
if continues to have trouble finding driver:
var driver = new ChromeDriver(@"C:\my\path\to\chromedriver\directory");
var chromeDriverService = ChromeDriverService.CreateDefaultService(@"C:\my\path\to\chromedriver\directory");
*/
#endregion
protected IWebDriver Driver { get; }
Humanizer humanizer { get; } = new Humanizer();
protected SeleniumRetriever()
{
Driver = new ChromeDriver(ctorCreateChromeOptions());
}
/// <summary>no auth. retain no chrome cookies</summary>
protected virtual ChromeOptions ctorCreateChromeOptions() => new ChromeOptions();
protected async Task AudibleLinkClickAsync(IWebElement element)
{
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
await humanizer.Wait();
await Task.Run(() => Driver.Click(element));
await waitForSpinnerAsync();
// sometimes these clicks just take a while. add a few more seconds
await Task.Delay(5000);
}
By spinnerLocator { get; } = By.Id("library-main-overlay");
private async Task waitForSpinnerAsync()
{
// if loading overlay w/spinner exists: pause, wait for it to end
await Task.Delay(100);
if (Driver.FindElements(spinnerLocator).Count > 0)
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
.Until(ExpectedConditions.InvisibilityOfElementLocated(spinnerLocator));
}
private bool isFirstRun = true;
protected virtual async Task FirstRunAsync()
{
// load with no beginning wait. then wait 7 seconds to allow for page flicker. it usually happens after ~5 seconds. can happen irrespective of login state
await Task.Run(() => Driver.Navigate().GoToUrl("http://www.audible.com/"));
await Task.Delay(7000);
}
public async Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
{
if (isFirstRun)
{
await FirstRunAsync();
isFirstRun = false;
}
await initFirstPageAsync(audiblePage, pageId);
return await processUrl(audiblePage, pageId);
}
private async Task initFirstPageAsync(AudiblePageType audiblePage, string pageId)
{
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
await humanizer.Wait();
var url = audiblePage.GetAudiblePageRobust().GetUrl(pageId);
await Task.Run(() => Driver.Navigate().GoToUrl(url));
await waitForSpinnerAsync();
}
private async Task<IEnumerable<AudiblePageSource>> processUrl(AudiblePageType audiblePage, string pageId)
{
var pageSources = new List<AudiblePageSource>();
do
{
pageSources.Add(new AudiblePageSource(audiblePage, Driver.PageSource, pageId));
}
while (await hasMorePagesAsync());
return pageSources;
}
#region has more pages
/// <summary>if no more pages, return false. else, navigate to next page and return true</summary>
private async Task<bool> hasMorePagesAsync()
{
var next = //old_hasMorePages() ??
new_hasMorePages();
if (next == null)
return false;
await AudibleLinkClickAsync(next);
return true;
}
private IWebElement old_hasMorePages()
{
var parentElements = Driver.FindElements(By.ClassName("adbl-page-next"));
if (parentElements.Count == 0)
return null;
var childElements = parentElements[0].FindElements(By.LinkText("NEXT"));
if (childElements.Count != 1)
return null;
return childElements[0];
}
// ~ oct 2017
private IWebElement new_hasMorePages()
{
// get all active/enabled navigation links
var pageNavLinks = Driver.FindElements(By.ClassName("library-load-page"));
if (pageNavLinks.Count == 0)
return null;
// get only the right chevron if active.
// note: there are also right chevrons which are not for wish list navigation which is why we first filter by library-load-page
var nextLink = pageNavLinks
.Where(p => p.FindElements(By.ClassName("bc-icon-chevron-right")).Count > 0)
.ToList(); // cut-off delayed execution
if (nextLink.Count == 0)
return null;
return nextLink.Single().FindElement(By.TagName("button"));
}
#endregion
#region IDisposable pattern
public void Dispose()
{
Dispose(true);
GC.SuppressFinalize(this);
}
protected virtual void Dispose(bool disposing)
{
if (disposing && Driver != null)
{
// Quit() does cleanup AND disposes
Driver.Quit();
}
}
#endregion
}
}

View File

@@ -1,26 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OpenQA.Selenium;
namespace AudibleDotComAutomation
{
/// <summary>for user collections: lib, WL</summary>
public abstract class AuthSeleniumRetriever : SeleniumRetriever
{
protected bool IsLoggedIn => GetListenerPageLink() != null;
// needed?
protected AuthSeleniumRetriever() : base() { }
protected IWebElement GetListenerPageLink()
{
var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]"));
if (listenerPageElement.Count > 0)
return listenerPageElement[0];
return null;
}
}
}

View File

@@ -1,130 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Threading.Tasks;
using AudibleDotCom;
using CookieMonster;
using Dinah.Core;
using Dinah.Core.Humanizer;
namespace AudibleDotComAutomation
{
public class BrowserlessRetriever : IPageRetriever
{
Humanizer humanizer { get; } = new Humanizer();
public async Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null)
{
switch (audiblePage)
{
case AudiblePageType.Library: return await getLibraryPageSourcesAsync();
default: throw new NotImplementedException();
}
}
private async Task<IEnumerable<AudiblePageSource>> getLibraryPageSourcesAsync()
{
var collection = new List<AudiblePageSource>();
var cookies = await getAudibleCookiesAsync();
var currPageNum = 1;
bool hasMorePages;
do
{
// EACH CALL to audible should have a small random wait to reduce chances of scrape detection
await humanizer.Wait();
var html = await getLibraryPageAsync(cookies, currPageNum);
var pageSource = new AudiblePageSource(AudiblePageType.Library, html, null);
collection.Add(pageSource);
hasMorePages = getHasMorePages(pageSource.Source);
currPageNum++;
} while (hasMorePages);
return collection;
}
private static async Task<CookieContainer> getAudibleCookiesAsync()
{
var liveCookies = await CookiesHelper.GetLiveCookieValuesAsync();
var audibleCookies = liveCookies.Where(c
=> c.Domain.ContainsInsensitive("audible.com")
|| c.Domain.ContainsInsensitive("adbl")
|| c.Domain.ContainsInsensitive("amazon.com"))
.ToList();
var cookies = new CookieContainer();
foreach (var c in audibleCookies)
cookies.Add(new Cookie(c.Name, c.Value, "/", c.Domain));
return cookies;
}
private static bool getHasMorePages(string html)
{
var doc = new HtmlAgilityPack.HtmlDocument();
doc.LoadHtml(html);
// final page, invalid page:
// <span class="bc-button
// bc-button-secondary
// nextButton
// bc-button-disabled">
// only page: ???
// has more pages:
// <span class="bc-button
// bc-button-secondary
// refinementFormButton
// nextButton">
var next_active_link = doc
.DocumentNode
.Descendants()
.FirstOrDefault(n =>
n.HasClass("nextButton") &&
!n.HasClass("bc-button-disabled"));
return next_active_link != null;
}
private static async Task<string> getLibraryPageAsync(CookieContainer cookies, int pageNum)
{
#region // POST example (from 2017 ajax)
// var destination = "https://www.audible.com/lib-ajax";
// var webRequest = (HttpWebRequest)WebRequest.Create(destination);
// webRequest.Method = "POST";
// webRequest.Accept = "*/*";
// webRequest.AllowAutoRedirect = false;
// webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)";
// webRequest.ContentType = "application/x-www-form-urlencoded; charset=UTF-8";
// webRequest.Credentials = null;
//
// webRequest.CookieContainer = new CookieContainer();
// webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination)));
//
// var postData = $"progType=all&timeFilter=all&itemsPerPage={itemsPerPage}&searchTerm=&searchType=&sortColumn=&sortType=down&page={pageNum}&mode=normal&subId=&subTitle=";
// var data = Encoding.UTF8.GetBytes(postData);
// webRequest.ContentLength = data.Length;
// using var dataStream = webRequest.GetRequestStream();
// dataStream.Write(data, 0, data.Length);
#endregion
var destination = "https://" + $"www.audible.com/lib?purchaseDateFilter=all&programFilter=all&sortBy=PURCHASE_DATE.dsc&page={pageNum}";
var webRequest = (HttpWebRequest)WebRequest.Create(destination);
webRequest.UserAgent = "Mozilla/4.0 (compatible; MSIE 6.0; Windows NT 5.0; .NET CLR 1.0.3705)";
webRequest.CookieContainer = new CookieContainer();
webRequest.CookieContainer.Add(cookies.GetCookies(new Uri(destination)));
var webResponse = await webRequest.GetResponseAsync();
return new StreamReader(webResponse.GetResponseStream()).ReadToEnd();
}
public void Dispose() { }
}
}

View File

@@ -1,75 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
namespace AudibleDotComAutomation
{
/// <summary>online. get auth by logging in with provided username and password
/// retain no chrome cookies. enter user + pw login</summary>
public class ManualLoginSeleniumRetriever : AuthSeleniumRetriever
{
string _username;
string _password;
public ManualLoginSeleniumRetriever(string username, string password) : base()
{
_username = username;
_password = password;
}
protected override async Task FirstRunAsync()
{
await base.FirstRunAsync();
// can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete
// click login link
await AudibleLinkClickAsync(getLoginLink());
// wait until login page loads
new WebDriverWait(Driver, TimeSpan.FromSeconds(60)).Until(ExpectedConditions.ElementIsVisible(By.Id("ap_email")));
// insert credentials
Driver
.FindElement(By.Id("ap_email"))
.SendKeys(_username);
Driver
.FindElement(By.Id("ap_password"))
.SendKeys(_password);
// submit
var submitElement
= Driver.FindElements(By.Id("signInSubmit")).FirstOrDefault()
?? Driver.FindElement(By.Id("signInSubmit-input"));
await AudibleLinkClickAsync(submitElement);
// wait until audible page loads
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
.Until(d => GetListenerPageLink());
if (!IsLoggedIn)
throw new Exception("not logged in");
}
private IWebElement getLoginLink()
{
{
var loginLinkElements1 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]"));
if (loginLinkElements1.Any())
return loginLinkElements1[0];
}
//
// ADD ADDITIONAL ACCEPTABLE PATTERNS HERE
//
//{
// var loginLinkElements2 = Driver.FindElements(By.XPath("//a[contains(@href, '/signin')]"));
// if (loginLinkElements2.Any())
// return loginLinkElements2[0];
//}
throw new NotFoundException("Cannot locate login link");
}
}
}

View File

@@ -1,38 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OpenQA.Selenium.Chrome;
namespace AudibleDotComAutomation
{
/// <summary>online. load auth, cookies etc from user data</summary>
public class UserDataSeleniumRetriever : AuthSeleniumRetriever
{
public UserDataSeleniumRetriever() : base()
{
// can't extract this into AuthSeleniumRetriever ctor. can't use username/pw until prev ctors are complete
if (!IsLoggedIn)
throw new Exception("not logged in");
}
/// <summary>Use current user data/chrome cookies. DO NOT use if chrome is already open</summary>
protected override ChromeOptions ctorCreateChromeOptions()
{
var options = base.ctorCreateChromeOptions();
// load user data incl cookies. default on windows:
// %LOCALAPPDATA%\Google\Chrome\User Data
// C:\Users\username\AppData\Local\Google\Chrome\User Data
var chromeDefaultWindowsUserDataDir = System.IO.Path.Combine(
Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData),
"Google",
"Chrome",
"User Data");
options.AddArguments($"user-data-dir={chromeDefaultWindowsUserDataDir}");
return options;
}
}
}

View File

@@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Threading.Tasks;
using AudibleDotCom;
namespace AudibleDotComAutomation
{
public interface IPageRetriever : IDisposable
{
Task<IEnumerable<AudiblePageSource>> GetPageSourcesAsync(AudiblePageType audiblePage, string pageId = null);
}
}

View File

@@ -1,115 +0,0 @@
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using OpenQA.Selenium;
using OpenQA.Selenium.Support.UI;
namespace AudibleDotComAutomation.Examples
{
public class SeleniumExamples
{
public IWebDriver Driver { get; set; }
IWebElement GetListenerPageLink()
{
var listenerPageElement = Driver.FindElements(By.XPath("//a[contains(@href, '/review-by-author')]"));
if (listenerPageElement.Count > 0)
return listenerPageElement[0];
return null;
}
void wait_examples()
{
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
.Until(ExpectedConditions.ElementIsVisible(By.Id("mast-member-acct-name")));
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
.Until(d => GetListenerPageLink());
// https://stackoverflow.com/questions/21339339/how-to-add-custom-expectedconditions-for-selenium
new WebDriverWait(Driver, TimeSpan.FromSeconds(60))
.Until((d) =>
{
// could be refactored into OR, AND per the java selenium library
// check 1
var e1 = Driver.FindElements(By.Id("mast-member-acct-name"));
if (e1.Count > 0)
return e1[0];
// check 2
var e2 = Driver.FindElements(By.Id("header-account-info-0"));
if (e2.Count > 0)
return e2[0];
return null;
});
}
void XPath_examples()
{
// <tr>
// <td>1</td>
// <td>2</td>
// </tr>
// <tr>
// <td>3</td>
// <td>4</td>
// </tr>
ReadOnlyCollection<IWebElement> all_tr = Driver.FindElements(By.XPath("/tr"));
IWebElement first_tr = Driver.FindElement(By.XPath("/tr"));
IWebElement second_tr = Driver.FindElement(By.XPath("/tr[2]"));
// beginning with a single / starts from root
IWebElement ERROR_not_at_root = Driver.FindElement(By.XPath("/td"));
// 2 slashes searches all, NOT just descendants
IWebElement td1 = Driver.FindElement(By.XPath("//td"));
// 2 slashes still searches all, NOT just descendants
IWebElement still_td1 = first_tr.FindElement(By.XPath("//td"));
// dot operator starts from current node specified by first_tr
// single slash: immediate descendant
IWebElement td3 = first_tr.FindElement(By.XPath(
".//td"));
// double slash: descendant at any depth
IWebElement td3_also = first_tr.FindElement(By.XPath(
"./td"));
// <input type="hidden" name="asin" value="ABCD1234">
IWebElement find_anywhere_in_doc = first_tr.FindElement(By.XPath(
"//input[@name='asin']"));
IWebElement find_in_subsection = first_tr.FindElement(By.XPath(
".//input[@name='asin']"));
// search entire page. useful for:
// - RulesLocator to find something that only appears once on the page
// - non-list pages. eg: product details
var onePerPageRules = new RuleFamily
{
RowsLocator = By.XPath("/*"), // search entire page
Rules = new RuleSet {
(row, productItem) => productItem.CustomerId = row.FindElement(By.XPath("//input[@name='cust_id']")).GetValue(),
(row, productItem) => productItem.UserName = row.FindElement(By.XPath("//input[@name='user_name']")).GetValue()
}
};
// - applying conditionals to entire page
var ruleFamily = new RuleFamily
{
RowsLocator = By.XPath("//*[starts-with(@id,'adbl-library-content-row-')]"),
// Rules = getRuleSet()
};
}
#region Rules classes stubs
public class RuleFamily { public By RowsLocator; public IRuleClass Rules; }
public interface IRuleClass { }
public class RuleSet : IRuleClass, IEnumerable<IRuleClass>
{
public void Add(IRuleClass ruleClass) { }
public void Add(RuleAction action) { }
public IEnumerator<IRuleClass> GetEnumerator() => throw new NotImplementedException();
System.Collections.IEnumerator System.Collections.IEnumerable.GetEnumerator() => throw new NotImplementedException();
}
public delegate void RuleAction(IWebElement row, ProductItem productItem);
public class ProductItem { public string CustomerId; public string UserName; }
#endregion
}
}

View File

@@ -1,47 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using OpenQA.Selenium;
using OpenQA.Selenium.Interactions;
namespace AudibleDotComAutomation
{
public static class IWebElementExt
{
// allows getting Text from elements even if hidden
// this only works on visible elements: webElement.Text
// http://yizeng.me/2014/04/08/get-text-from-hidden-elements-using-selenium-webdriver/#c-sharp
//
public static string GetText(this IWebElement webElement) => webElement.GetAttribute("textContent");
public static string GetValue(this IWebElement webElement) => webElement.GetAttribute("value");
}
public static class IWebDriverExt
{
/// <summary>Use this instead of element.Click() to ensure that the element is clicked even if it's not currently scrolled into view</summary>
public static void Click(this IWebDriver driver, IWebElement element)
{
// from: https://stackoverflow.com/questions/12035023/selenium-webdriver-cant-click-on-a-link-outside-the-page
//// this works but isn't really the same
//element.SendKeys(Keys.Enter);
//// didn't work for me
//new Actions(driver)
// .MoveToElement(element)
// .Click()
// .Build()
// .Perform();
driver.ScrollIntoView(element);
element.Click();
}
public static void ScrollIntoView(this IWebDriver driver, IWebElement element)
=> ((IJavaScriptExecutor)driver).ExecuteScript($"window.scroll({element.Location.X}, {element.Location.Y})");
}
}

View File

Binary file not shown.

View File

@@ -1,16 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="System.Data.SQLite.Core" Version="1.0.112" />
<PackageReference Include="System.Security.Cryptography.ProtectedData" Version="4.6.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>
</Project>

View File

@@ -1,66 +0,0 @@
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.IO;
using System.Text;
using System.Threading.Tasks;
using FileManager;
namespace CookieMonster
{
internal class Chrome : IBrowser
{
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
{
var col = new List<CookieValue>();
var strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), @"Google\Chrome\User Data\Default\Cookies");
if (!FileUtility.FileExists(strPath))
return col;
//
// IF WE GET AN ERROR HERE
// then add a reference to sqlite core in the project which is ultimately calling this.
// a project which directly references CookieMonster doesn't need to also ref sqlite.
// however, for any further number of abstractions, the project needs to directly ref sqlite.
// eg: this will not work unless the winforms proj adds sqlite to ref.s:
// LibationWinForm > AudibleDotComAutomation > CookieMonster
//
using var conn = new SQLiteConnection("Data Source=" + strPath + ";pooling=false");
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT host_key, name, value, encrypted_value, last_access_utc, expires_utc FROM cookies;";
conn.Open();
using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
while (reader.Read())
{
var host_key = reader.GetString(0);
var name = reader.GetString(1);
var value = reader.GetString(2);
var last_access_utc = reader.GetInt64(4);
var expires_utc = reader.GetInt64(5);
// https://stackoverflow.com/a/25874366
if (string.IsNullOrWhiteSpace(value))
{
var encrypted_value = (byte[])reader[3];
var decodedData = System.Security.Cryptography.ProtectedData.Unprotect(encrypted_value, null, System.Security.Cryptography.DataProtectionScope.CurrentUser);
value = Encoding.ASCII.GetString(decodedData);
}
try
{
// if something goes wrong in this step (eg: a cookie has an invalid filetime), then just skip this cookie
col.Add(new CookieValue { Browser = "chrome", Domain = host_key, Name = name, Value = value, LastAccess = chromeTimeToDateTimeUtc(last_access_utc), Expires = chromeTimeToDateTimeUtc(expires_utc) });
}
catch { }
}
return col;
}
// Chrome uses 1601-01-01 00:00:00 UTC as the epoch (ie the starting point for the millisecond time counter).
// this is the same as "FILETIME" in Win32 except FILETIME uses 100ns ticks instead of ms.
private static DateTime chromeTimeToDateTimeUtc(long time) => DateTime.SpecifyKind(DateTime.FromFileTime(time * 10), DateTimeKind.Utc);
}
}

View File

@@ -1,61 +0,0 @@
using System;
using System.Collections.Generic;
using System.Data.SQLite;
using System.IO;
using System.Threading.Tasks;
using FileManager;
namespace CookieMonster
{
internal class FireFox : IBrowser
{
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
{
var col = new List<CookieValue>();
string strPath = Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), @"Mozilla\Firefox\Profiles");
if (!FileUtility.FileExists(strPath))
return col;
var dirs = new DirectoryInfo(strPath).GetDirectories("*.default");
if (dirs.Length != 1)
return col;
strPath = Path.Combine(strPath, dirs[0].Name, "cookies.sqlite");
if (!FileUtility.FileExists(strPath))
return col;
// First copy the cookie jar so that we can read the cookies from unlocked copy while FireFox is running
var strTemp = strPath + ".temp";
File.Copy(strPath, strTemp, true);
// Now open the temporary cookie jar and extract Value from the cookie if we find it.
using var conn = new SQLiteConnection("Data Source=" + strTemp + ";pooling=false");
using var cmd = conn.CreateCommand();
cmd.CommandText = "SELECT host, name, value, lastAccessed, expiry FROM moz_cookies; ";
conn.Open();
using var reader = await cmd.ExecuteReaderAsync().ConfigureAwait(false);
while (reader.Read())
{
var host_key = reader.GetString(0);
var name = reader.GetString(1);
var value = reader.GetString(2);
var lastAccessed = reader.GetInt32(3);
var expiry = reader.GetInt32(4);
col.Add(new CookieValue { Browser = "firefox", Domain = host_key, Name = name, Value = value, LastAccess = lastAccessedToDateTime(lastAccessed), Expires = expiryToDateTime(expiry) });
}
if (FileUtility.FileExists(strTemp))
File.Delete(strTemp);
return col;
}
// time is in microseconds since unix epoch
private static DateTime lastAccessedToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, DateTimeKind.Utc).AddMilliseconds(time);
// time is in normal seconds since unix epoch
private static DateTime expiryToDateTime(int time) => new DateTime(1970, 1, 1, 0, 0, 0, 0, System.DateTimeKind.Utc).AddSeconds(time);
}
}

View File

@@ -1,12 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
namespace CookieMonster
{
internal interface IBrowser
{
Task<IEnumerable<CookieValue>> GetAllCookiesAsync();
}
}

View File

@@ -1,87 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace CookieMonster
{
internal class InternetExplorer : IBrowser
{
public async Task<IEnumerable<CookieValue>> GetAllCookiesAsync()
{
// real locations of Windows Cookies folders
//
// Windows 7:
// C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies
// C:\Users\username\AppData\Roaming\Microsoft\Windows\Cookies\Low
//
// Windows 8, Windows 8.1, Windows 10:
// C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies
// C:\Users\username\AppData\Local\Microsoft\Windows\INetCookies\Low
var strPath = Environment.GetFolderPath(Environment.SpecialFolder.Cookies);
var col = (await getIECookiesAsync(strPath).ConfigureAwait(false)).ToList();
col = col.Concat(await getIECookiesAsync(Path.Combine(strPath, "Low"))).ToList();
return col;
}
private static async Task<IEnumerable<CookieValue>> getIECookiesAsync(string strPath)
{
var cookies = new List<CookieValue>();
var files = await Task.Run(() => Directory.EnumerateFiles(strPath, "*.txt"));
foreach (string path in files)
{
var cookiesInFile = new List<CookieValue>();
var cookieLines = File.ReadAllLines(path);
CookieValue currCookieVal = null;
for (var i = 0; i < cookieLines.Length; i++)
{
var line = cookieLines[i];
// IE cookie format
// 0 Cookie name
// 1 Cookie value
// 2 Host / path for the web server setting the cookie
// 3 Flags
// 4 Expiration time (low int)
// 5 Expiration time (high int)
// 6 Creation time (low int)
// 7 Creation time (high int)
// 8 Record delimiter == "*"
var pos = i % 9;
long expLoTemp = 0;
long creatLoTemp = 0;
if (pos == 0)
{
currCookieVal = new CookieValue { Browser = "ie", Name = line };
cookiesInFile.Add(currCookieVal);
}
else if (pos == 1)
currCookieVal.Value = line;
else if (pos == 2)
currCookieVal.Domain = line;
else if (pos == 4)
expLoTemp = Int64.Parse(line);
else if (pos == 5)
currCookieVal.Expires = LoHiToDateTime(expLoTemp, Int64.Parse(line));
else if (pos == 6)
creatLoTemp = Int64.Parse(line);
else if (pos == 7)
currCookieVal.LastAccess = LoHiToDateTime(creatLoTemp, Int64.Parse(line));
}
cookies.AddRange(cookiesInFile);
}
return cookies;
}
private static DateTime LoHiToDateTime(long lo, long hi) => DateTime.FromFileTimeUtc(((hi << 32) + lo));
}
}

View File

@@ -1,32 +0,0 @@
using System;
namespace CookieMonster
{
public class CookieValue
{
public string Browser { get; set; }
public string Name { get; set; }
public string Value { get; set; }
public string Domain { get; set; }
public DateTime LastAccess { get; set; }
public DateTime Expires { get; set; }
public bool IsValid
{
get
{
// sanity check. datetimes are stored weird in each cookie type. make sure i haven't converted these incredibly wrong.
// some early conversion attempts produced years like 42, 1955, 4033
var _5yearsPast = DateTime.UtcNow.AddYears(-5);
if (LastAccess < _5yearsPast || LastAccess > DateTime.UtcNow)
return false;
// don't check expiry. some sites are setting stupid values for year. eg: 9999
return true;
}
}
public bool HasExpired => Expires < DateTime.UtcNow;
}
}

View File

@@ -1,57 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Dinah.Core.Collections.Generic;
namespace CookieMonster
{
public static class CookiesHelper
{
internal static IEnumerable<IBrowser> GetBrowsers()
=> AppDomain.CurrentDomain
.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => typeof(IBrowser).IsAssignableFrom(p) && !p.IsAbstract && !p.IsInterface)
.Select(t => Activator.CreateInstance(t) as IBrowser)
.ToList();
/// <summary>all. including expired</summary>
public static async Task<IEnumerable<CookieValue>> GetAllCookieValuesAsync()
{
//// foreach{await} runs in serial
//var allCookies = new List<CookieValue>();
//foreach (var b in GetBrowsers())
//{
// var browserCookies = await b.GetAllCookiesAsync().ConfigureAwait(false);
// allCookies.AddRange(browserCookies);
//}
//// WhenAll runs in parallel
// this 1st step LOOKS like a bug which runs each method until completion. However, since we don't use await, it's actually returning a Task. That resulting task is awaited asynchronously
var browserTasks = GetBrowsers().Select(b => b.GetAllCookiesAsync());
var results = await Task.WhenAll(browserTasks).ConfigureAwait(false);
var allCookies = results.SelectMany(a => a).ToList();
if (allCookies.Any(c => !c.IsValid))
throw new Exception("some date time was converted way too far");
foreach (var c in allCookies)
c.Domain = c.Domain.TrimEnd('/');
// for each domain+name, only keep the 1 with the most recent access
var sortedCookies = allCookies
.OrderByDescending(c => c.LastAccess)
.DistinctBy(c => new { c.Domain, c.Name })
.ToList();
return sortedCookies;
}
/// <summary>not expired</summary>
public static async Task<IEnumerable<CookieValue>> GetLiveCookieValuesAsync()
=> (await GetAllCookieValuesAsync().ConfigureAwait(false))
.Where(c => !c.HasExpired)
.ToList();
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFrameworks>netcoreapp3.0;netstandard2.1</TargetFrameworks>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
@@ -12,13 +12,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="3.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" Version="3.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="3.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -26,11 +26,13 @@ namespace DataLayer
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 string Locale { get; private set; }
// mutable
public string PictureId { get; set; }
// book details
public bool HasBookDetails { get; private set; }
public bool IsAbridged { get; private set; }
public DateTime? DatePublished { get; private set; }
@@ -62,12 +64,19 @@ namespace DataLayer
string title,
string description,
int lengthInMinutes,
IEnumerable<Contributor> authors)
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
Category category, string localeName)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
var productId = audibleProductId.Id;
ArgumentValidator.EnsureNotNullOrWhiteSpace(productId, nameof(productId));
// assign as soon as possible. stuff below relies on this
AudibleProductId = productId;
Locale = localeName;
ArgumentValidator.EnsureNotNullOrWhiteSpace(title, nameof(title));
// non-ef-ctor init.s
@@ -76,24 +85,17 @@ namespace DataLayer
_seriesLink = new HashSet<SeriesBook>();
_supplements = new HashSet<Supplement>();
// since category/id is never null, nullity means it hasn't been loaded
CategoryId = Category.GetEmpty().CategoryId;
Category = category;
// simple assigns
AudibleProductId = productId;
Title = title;
Description = description;
LengthInMinutes = lengthInMinutes;
// assigns with biz logic
ReplaceAuthors(authors);
//ReplaceNarrators(narrators);
// import previously saved tags
// do this immediately. any save occurs before reloading tags will overwrite persistent tags with new blank entries; all old persisted tags will be lost
// if refactoring, DO NOT use "ProductId" before it's assigned to. to be safe, just use "productId"
UserDefinedItem = new UserDefinedItem(this) { Tags = FileManager.TagsPersistence.GetTags(productId) };
}
ReplaceNarrators(narrators);
}
#region contributors, authors, narrators
// use uninitialised backing fields - this means we can detect if the collection was loaded
@@ -125,16 +127,10 @@ namespace DataLayer
ArgumentValidator.EnsureEnumerableNotNullOrEmpty(newContributors, nameof(newContributors));
// the edge cases of doing local-loaded vs remote-only got weird. just load it
if (_contributorsLink == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add contributors");
if (_contributorsLink is null)
getEntry(context).Collection(s => s.ContributorsLink).Load();
context.Entry(this).Collection(s => s.ContributorsLink).Load();
}
var roleContributions = getContributions(role);
var roleContributions = getContributions(role);
var isIdentical = roleContributions.Select(c => c.Contributor).SequenceEqual(newContributors);
if (isIdentical)
return;
@@ -142,7 +138,8 @@ namespace DataLayer
_contributorsLink.RemoveWhere(bc => bc.Role == role);
addNewContributors(newContributors, role);
}
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
private void addNewContributors(IEnumerable<Contributor> newContributors, Role role)
{
byte order = 0;
var newContributionsEnum = newContributors.Select(c => new BookContributor(this, c, role, order++));
@@ -157,6 +154,18 @@ namespace DataLayer
.ToList();
#endregion
private Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Book> getEntry(DbContext context)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
var entry = context.Entry(this);
if (!entry.IsKeySet)
throw new InvalidOperationException("Could not load a valid Book from database");
return entry;
}
#region series
private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
@@ -188,16 +197,10 @@ namespace DataLayer
// our add() is conditional upon what's already included in the collection.
// therefore if not loaded, a trip is required. might as well just load it
if (_seriesLink == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add series");
if (_seriesLink is null)
getEntry(context).Collection(s => s.SeriesLink).Load();
context.Entry(this).Collection(s => s.SeriesLink).Load();
}
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
if (singleSeriesBook == null)
_seriesLink.Add(new SeriesBook(series, this, index));
else
@@ -208,13 +211,12 @@ namespace DataLayer
#region supplements
private HashSet<Supplement> _supplements;
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
public bool HasPdfs => Supplements.Any();
public bool HasPdf => Supplements.Any();
public void AddSupplementDownloadUrl(string url)
{
// supplements are owned by Book, so no need to Load():
// OwnsMany: "Can only ever appear on navigation properties of other entity types.
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner."
// Are automatically loaded, and can only be tracked by a DbContext alongside their owner.
ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url));
@@ -231,24 +233,21 @@ namespace DataLayer
// don't overwrite with default values
IsAbridged |= isAbridged;
DatePublished = datePublished ?? DatePublished;
HasBookDetails = true;
}
public void UpdateCategory(Category category, DbContext context = null)
{
// since category is never null, nullity means it hasn't been loaded
if (Category != null || CategoryId == Category.GetEmpty().CategoryId)
{
Category = category;
return;
}
{
// since category is never null, nullity means it hasn't been loaded
if (Category is null)
getEntry(context).Reference(s => s.Category).Load();
if (context == null)
throw new Exception("need context");
context.Entry(this).Reference(s => s.Category).Load();
Category = category;
Category = category;
}
}
// needed for v3 => v4 upgrade
public void UpdateLocale(string localeName)
=> Locale ??= localeName;
public override string ToString() => $"[{AudibleProductId}] {Title}";
}
}

View File

@@ -23,5 +23,7 @@ namespace DataLayer
Role = role;
Order = order;
}
}
public override string ToString() => $"{Book} {Contributor} {Role} {Order}";
}
}

View File

@@ -18,8 +18,7 @@ namespace DataLayer
public class Category
{
// Empty is a special case. use private ctor w/o validation
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "", ParentCategory = null };
public bool IsEmpty() => string.IsNullOrWhiteSpace(AudibleCategoryId) || string.IsNullOrWhiteSpace(Name) || ParentCategory == null;
public static Category GetEmpty() => new Category { CategoryId = -1, AudibleCategoryId = "", Name = "" };
internal int CategoryId { get; private set; }
public string AudibleCategoryId { get; private set; }
@@ -48,5 +47,7 @@ namespace DataLayer
if (parentCategory != null)
ParentCategory = parentCategory;
}
}
public override string ToString() => $"[{AudibleCategoryId}] {Name}";
}
}

View File

@@ -0,0 +1,51 @@
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
namespace DataLayer
{
public class Contributor
{
// Empty is a special case. use private ctor w/o validation
public static Contributor GetEmpty() => new Contributor { ContributorId = -1, Name = "" };
// contributors search links are just name with url-encoding. space can be + or %20
// author search link: /search?searchAuthor=Robert+Bevan
// narrator search link: /search?searchNarrator=Robert+Bevan
// can also search multiples. concat with comma before url encode
// id.s
// ----
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
// goes to summary page
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
internal int ContributorId { get; private set; }
public string Name { get; private set; }
private HashSet<BookContributor> _booksLink;
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
public string AudibleContributorId { get; private set; }
private Contributor() { }
public Contributor(string name)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
_booksLink = new HashSet<BookContributor>();
Name = name;
}
public Contributor(string name, string audibleContributorId) : this(name)
{
// don't overwrite with null or whitespace but not an error
if (!string.IsNullOrWhiteSpace(audibleContributorId))
AudibleContributorId = audibleContributorId;
}
public override string ToString() => Name;
}
}

View File

@@ -0,0 +1,33 @@
using System;
using Dinah.Core;
namespace DataLayer
{
public class LibraryBook
{
internal int BookId { get; private set; }
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() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
ArgumentValidator.EnsureNotNull(account, nameof(account));
Book = book;
DateAdded = dateAdded;
Account = account;
}
// needed for v3 => v4 upgrade
public void UpdateAccount(string account)
=> Account ??= account;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

View File

@@ -72,5 +72,7 @@ namespace DataLayer
return string.Join("\r\n", items);
}
}
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
}
}

View File

@@ -66,5 +66,7 @@ namespace DataLayer
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
_booksLink.Add(new SeriesBook(this, book, index));
}
}
public override string ToString() => Name;
}
}

View File

@@ -34,5 +34,7 @@ namespace DataLayer
if (index.HasValue)
Index = index.Value;
}
}
public override string ToString() => $"Series={Series} Book={Book}";
}
}

View File

@@ -20,5 +20,7 @@ namespace DataLayer
Book = book;
Url = url;
}
}
public override string ToString() => $"{Book} {Url.Substring(Url.Length - 4)}";
}
}

View File

@@ -13,29 +13,36 @@ namespace DataLayer
private UserDefinedItem() { }
internal UserDefinedItem(Book book)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
}
// import previously saved tags
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId);
}
private string _tags = "";
public string Tags
{
get => _tags;
set => _tags = sanitize(value);
}
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
}
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#region sanitize tags: space delimited. Inline/denormalized. Lower case. Alpha numeric and hyphen
// only legal chars are letters numbers underscores and separating whitespace
//
// technically, the only char.s which aren't easily supported are \ [ ]
// however, whitelisting is far safer than blacklisting (eg: new lines, non-printable character)
// it's easy to expand whitelist as needed
// for lucene, ToLower() isn't needed because search is case-inspecific. for here, it prevents duplicates
//
// there are also other allowed but misleading characters. eg: the ^ operator defines a 'boost' score
// full list of characters which must be escaped:
// + - && || ! ( ) { } [ ] ^ " ~ * ? : \
static Regex regex { get; } = new Regex(@"[^\w\d\s_]", RegexOptions.Compiled);
private static string sanitize(string input)
{
if (string.IsNullOrWhiteSpace(input))
@@ -63,8 +70,6 @@ namespace DataLayer
return string.Join(" ", unique);
}
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
#endregion
// owned: not an optional one-to-one
@@ -73,5 +78,7 @@ namespace DataLayer
public void UpdateRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
}
public override string ToString() => $"{Book} {Rating} {Tags}";
}
}

View File

@@ -25,10 +25,10 @@ namespace DataLayer
public DbSet<Series> Series { get; private set; }
public DbSet<Category> Categories { get; private set; }
public static LibationContext Create()
public static LibationContext Create(string connectionString)
{
var factory = new LibationContextFactory();
var context = factory.Create();
var context = factory.Create(connectionString);
return context;
}
@@ -56,12 +56,17 @@ namespace DataLayer
modelBuilder.ApplyConfiguration(new SeriesBookConfig());
modelBuilder.ApplyConfiguration(new CategoryConfig());
// seeds go here. examples in scratch pad
// seeds go here. examples in Dinah.EntityFrameworkCore.Tests\DbContextFactoryExample.cs
modelBuilder
.Entity<Category>()
.HasData(Category.GetEmpty());
.HasData(Category.GetEmpty());
modelBuilder
.Entity<Contributor>()
.HasData(Contributor.GetEmpty());
// views are now supported via "query types" (instead of "entity types"): https://docs.microsoft.com/en-us/ef/core/modeling/query-types
}
}
// views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"):
// https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types
}
}
}

View File

@@ -6,6 +6,6 @@ namespace DataLayer
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
{
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlServer(connectionString);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
}
}

View File

@@ -0,0 +1,332 @@
// <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("20191125182309_Fresh")]
partial class Fresh
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.0.0");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<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>("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<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");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.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");
});
});
});
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();
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
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();
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -3,7 +3,7 @@ using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class UpgradeToCore3 : Migration
public partial class Fresh : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
@@ -12,7 +12,7 @@ namespace DataLayer.Migrations
columns: table => new
{
CategoryId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
AudibleCategoryId = table.Column<string>(nullable: true),
Name = table.Column<string>(nullable: true),
ParentCategoryCategoryId = table.Column<int>(nullable: true)
@@ -33,9 +33,9 @@ namespace DataLayer.Migrations
columns: table => new
{
ContributorId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(nullable: true),
AudibleAuthorId = table.Column<string>(nullable: true)
AudibleContributorId = table.Column<string>(nullable: true)
},
constraints: table =>
{
@@ -47,7 +47,7 @@ namespace DataLayer.Migrations
columns: table => new
{
SeriesId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
AudibleSeriesId = table.Column<string>(nullable: true),
Name = table.Column<string>(nullable: true)
},
@@ -61,13 +61,12 @@ namespace DataLayer.Migrations
columns: table => new
{
BookId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
AudibleProductId = table.Column<string>(nullable: true),
Title = table.Column<string>(nullable: true),
Description = table.Column<string>(nullable: true),
LengthInMinutes = table.Column<int>(nullable: false),
PictureId = table.Column<string>(nullable: true),
HasBookDetails = table.Column<bool>(nullable: false),
IsAbridged = table.Column<bool>(nullable: false),
DatePublished = table.Column<DateTime>(nullable: true),
CategoryId = table.Column<int>(nullable: false),
@@ -117,8 +116,7 @@ namespace DataLayer.Migrations
columns: table => new
{
BookId = table.Column<int>(nullable: false),
DateAdded = table.Column<DateTime>(nullable: false),
DownloadBookLink = table.Column<string>(nullable: true)
DateAdded = table.Column<DateTime>(nullable: false)
},
constraints: table =>
{
@@ -161,7 +159,7 @@ namespace DataLayer.Migrations
columns: table => new
{
SupplementId = table.Column<int>(nullable: false)
.Annotation("SqlServer:Identity", "1, 1"),
.Annotation("Sqlite:Autoincrement", true),
BookId = table.Column<int>(nullable: false),
Url = table.Column<string>(nullable: true)
},
@@ -202,6 +200,11 @@ namespace DataLayer.Migrations
columns: new[] { "CategoryId", "AudibleCategoryId", "Name", "ParentCategoryCategoryId" },
values: new object[] { -1, "", "", null });
migrationBuilder.InsertData(
table: "Contributors",
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
values: new object[] { -1, null, "" });
migrationBuilder.CreateIndex(
name: "IX_BookContributor_BookId",
table: "BookContributor",

View File

@@ -3,57 +3,53 @@ using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20191007202808_UpgradeToCore3")]
partial class UpgradeToCore3
[Migration("20200812152646_AddLocaleAndAccount")]
partial class AddLocaleAndAccount
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasAnnotation("ProductVersion", "3.1.7");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("datetime2");
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("HasBookDetails")
.HasColumnType("bit");
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("bit");
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -67,16 +63,16 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("tinyint");
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
@@ -91,17 +87,16 @@ namespace DataLayer.Migrations
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
@@ -124,32 +119,38 @@ namespace DataLayer.Migrations
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleAuthorId")
.HasColumnType("nvarchar(max)");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
.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("int");
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
b.Property<string>("DownloadBookLink")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -160,14 +161,13 @@ namespace DataLayer.Migrations
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("SeriesId");
@@ -179,13 +179,13 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("real");
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
@@ -207,18 +207,16 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.HasKey("BookId");
@@ -232,14 +230,13 @@ namespace DataLayer.Migrations
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
@@ -254,10 +251,10 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("BookId");
@@ -269,16 +266,16 @@ namespace DataLayer.Migrations
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");

View File

@@ -0,0 +1,31 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class AddLocaleAndAccount : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "Account",
table: "Library",
nullable: true);
migrationBuilder.AddColumn<string>(
name: "Locale",
table: "Books",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Account",
table: "Library");
migrationBuilder.DropColumn(
name: "Locale",
table: "Books");
}
}
}

View File

@@ -3,7 +3,6 @@ using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Metadata;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
@@ -15,43 +14,40 @@ namespace DataLayer.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "3.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 128)
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasAnnotation("ProductVersion", "3.1.7");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("datetime2");
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("nvarchar(max)");
b.Property<bool>("HasBookDetails")
.HasColumnType("bit");
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("bit");
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -65,16 +61,16 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("tinyint");
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
@@ -89,17 +85,16 @@ namespace DataLayer.Migrations
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
@@ -122,32 +117,38 @@ namespace DataLayer.Migrations
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleAuthorId")
.HasColumnType("nvarchar(max)");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(450)");
.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("int");
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("datetime2");
b.Property<string>("DownloadBookLink")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("BookId");
@@ -158,14 +159,13 @@ namespace DataLayer.Migrations
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("nvarchar(450)");
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b.HasKey("SeriesId");
@@ -177,13 +177,13 @@ namespace DataLayer.Migrations
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("real");
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
@@ -205,18 +205,16 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b1.HasKey("BookId");
@@ -230,14 +228,13 @@ namespace DataLayer.Migrations
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("int")
.HasAnnotation("SqlServer:ValueGenerationStrategy", SqlServerValueGenerationStrategy.IdentityColumn);
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
@@ -252,10 +249,10 @@ namespace DataLayer.Migrations
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("nvarchar(max)");
.HasColumnType("TEXT");
b1.HasKey("BookId");
@@ -267,16 +264,16 @@ namespace DataLayer.Migrations
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("int");
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("real");
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");

View File

@@ -8,22 +8,11 @@ namespace DataLayer
{
public static class BookQueries
{
public static int BooksWithoutDetailsCount()
{
using var context = LibationContext.Create();
return context
.Books
.Count(b => !b.HasBookDetails);
}
public static Book GetBook_Flat_NoTracking(string productId)
{
using var context = LibationContext.Create();
return context
public static Book GetBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.Books
.AsNoTracking()
.GetBook(productId);
}
public static Book GetBook(this IQueryable<Book> books, string productId)
=> books

View File

@@ -5,25 +5,25 @@ using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public static class LibraryQueries
{
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
{
using var context = LibationContext.Create();
return context
{
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()
.AsNoTracking()
.GetLibrary()
.ToList();
}
public static LibraryBook GetLibraryBook_Flat_NoTracking(string productId)
{
using var context = LibationContext.Create();
return context
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.Library
//.AsNoTracking()
.GetLibraryBook(productId);
}
.AsNoTracking()
.GetLibraryBook(productId);
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)

View File

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

View File

@@ -1,19 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
public static class RemoveOrphansCommand
{
public static int RemoveOrphans(this LibationContext context)
=> context.Database.ExecuteSqlRaw(@"
delete c
from Contributors c
left join BookContributor bc on c.ContributorId = bc.ContributorId
left join Books b on bc.BookId = b.BookId
where bc.ContributorId is null
");
}
}

View File

@@ -1,82 +0,0 @@
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
namespace DataLayer
{
public class Contributor
{
// contributors search links are just name with url-encoding. space can be + or %20
// author search link: /search?searchAuthor=Robert+Bevan
// narrator search link: /search?searchNarrator=Robert+Bevan
// can also search multiples. concat with comma before url encode
// id.s
// ----
// https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2
// goes to summary page
// at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman
// some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears
// all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman
internal int ContributorId { get; private set; }
public string Name { get; private set; }
private HashSet<BookContributor> _booksLink;
public IEnumerable<BookContributor> BooksLink => _booksLink?.ToList();
private Contributor() { }
public Contributor(string name)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(name, nameof(name));
_booksLink = new HashSet<BookContributor>();
Name = name;
}
public string AudibleAuthorId { get; private set; }
public void UpdateAudibleAuthorId(string authorId)
{
// don't overwrite with null or whitespace but not an error
if (!string.IsNullOrWhiteSpace(authorId))
AudibleAuthorId = authorId;
}
#region // AudibleAuthorId refactor: separate author-specific info. overkill for a single optional string
///// <summary>Most authors in Audible have a unique id</summary>
//public AudibleAuthorProperty AudibleAuthorProperty { get; private set; }
//public void UpdateAuthorId(string authorId, LibationContext context = null)
//{
// if (authorId == null)
// return;
// if (AudibleAuthorProperty != null)
// {
// AudibleAuthorProperty.UpdateAudibleAuthorId(authorId);
// return;
// }
// if (context == null)
// throw new ArgumentNullException(nameof(context), "You must provide a context");
// if (context.Contributors.Find(ContributorId) == null)
// throw new InvalidOperationException("Could not update audible author id.");
// var audibleAuthorProperty = new AudibleAuthorProperty();
// audibleAuthorProperty.UpdateAudibleAuthorId(authorId);
// context.AuthorProperties.Add(audibleAuthorProperty);
//}
//public class AudibleAuthorProperty
//{
// public int ContributorId { get; private set; }
// public Contributor Contributor { get; set; }
// public string AudibleAuthorId { get; private set; }
// public void UpdateAudibleAuthorId(string authorId)
// {
// if (!string.IsNullOrWhiteSpace(authorId))
// AudibleAuthorId = authorId;
// }
//}
//// ...and create EF table config
#endregion
}
}

View File

@@ -1,27 +0,0 @@
using System;
using Dinah.Core;
namespace DataLayer
{
public class LibraryBook
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
public DateTime DateAdded { get; private set; }
/// <summary>For downloading AAX file</summary>
public string DownloadBookLink { get; private set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded
, string downloadBookLink = null
)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
DateAdded = dateAdded;
DownloadBookLink = downloadBookLink;
}
}
}

View File

@@ -1,60 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core.Collections.Generic;
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace DataLayer
{
internal class TagPersistenceInterceptor : IDbInterceptor
{
public void Executing(DbContext context)
{
doWork__EFCore(context);
}
public void Executed(DbContext context) { }
static void doWork__EFCore(DbContext context)
{
// persist tags:
var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State.In(EntityState.Modified, EntityState.Added)).ToList();
var tagSets = modifiedEntities.Select(e => e.Entity as UserDefinedItem).Where(a => a != null).ToList();
foreach (var t in tagSets)
FileManager.TagsPersistence.Save(t.Book.AudibleProductId, t.Tags);
}
#region // notes: working with proxies, esp EF 6
// EF 6: entities are proxied with lazy loading when collections are virtual
// EF Core: lazy loading is supported in 2.1 (there is a version of lazy loading with proxy-wrapping and a proxy-less version with DI) but not on by default and are not supported here
//static void doWork_EF6(DbContext context)
//{
// var modifiedEntities = context.ChangeTracker.Entries().Where(p => p.State == EntityState.Modified).ToList();
// var unproxiedEntities = modifiedEntities.Select(me => UnProxy(context, me.Entity)).ToList();
// // persist tags
// var tagSets = unproxiedEntities.Select(ue => ue as UserDefinedItem).Where(a => a != null).ToList();
// foreach (var t in tagSets)
// FileManager.TagsPersistence.Save(t.ProductId, t.TagsRaw);
//}
//// https://stackoverflow.com/a/25774651
//private static T UnProxy<T>(DbContext context, T proxyObject) where T : class
//{
// // alternative: https://docs.microsoft.com/en-us/ef/ef6/fundamentals/proxies
// var proxyCreationEnabled = context.Configuration.ProxyCreationEnabled;
// try
// {
// context.Configuration.ProxyCreationEnabled = false;
// return context.Entry(proxyObject).CurrentValues.ToObject() as T;
// }
// finally
// {
// context.Configuration.ProxyCreationEnabled = proxyCreationEnabled;
// }
//}
#endregion
}
}

View File

@@ -1,122 +0,0 @@
using System;
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
namespace _scratch_pad
{
////// to use this as a console, open properties and change from class library => console
//// DON'T FORGET TO REVERT IT
//public class Program
//{
// public static void Main(string[] args)
// {
// var user = new Student() { Name = "Dinah Cheshire" };
// var udi = new UserDef { UserDefId = 1, TagsRaw = "my,tags" };
// using var context = new MyTestContextDesignTimeDbContextFactory().Create();
// context.Add(user);
// //context.Add(udi);
// context.Update(udi);
// context.SaveChanges();
// Console.WriteLine($"Student was saved in the database with id: {user.Id}");
// }
//}
public class MyTestContextDesignTimeDbContextFactory : DesignTimeDbContextFactoryBase<MyTestContext>
{
protected override MyTestContext CreateNewInstance(DbContextOptions<MyTestContext> options) => new MyTestContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString) => optionsBuilder.UseSqlite(connectionString);
}
public class MyTestContext : DbContext
{
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
public MyTestContext(DbContextOptions<MyTestContext> options) : base(options) { }
#region classes for OnModelCreating() seed example
class Blog
{
public int BlogId { get; set; }
public string Url { get; set; }
public System.Collections.Generic.ICollection<Post> Posts { get; set; }
}
class Post
{
public int PostId { get; set; }
public string Content { get; set; }
public string Title { get; set; }
public int BlogId { get; set; }
public Blog Blog { get; set; }
public Name AuthorName { get; set; }
}
class Name
{
public string First { get; set; }
public string Last { get; set; }
}
#endregion
protected override void OnModelCreating(ModelBuilder modelBuilder)
{
base.OnModelCreating(modelBuilder);
// config
modelBuilder.Entity<Blog>(entity => entity.Property(e => e.Url).IsRequired());
modelBuilder.Entity<Order>().OwnsOne(p => p.OrderDetails, cb =>
{
cb.OwnsOne(c => c.BillingAddress);
cb.OwnsOne(c => c.ShippingAddress);
});
modelBuilder.Entity<Post>(entity =>
entity
.HasOne(d => d.Blog)
.WithMany(p => p.Posts)
.HasForeignKey("BlogId"));
// BlogSeed
modelBuilder.Entity<Blog>().HasData(new Blog { BlogId = 1, Url = "http://sample.com" });
// PostSeed
modelBuilder.Entity<Post>().HasData(new Post() { BlogId = 1, PostId = 1, Title = "First post", Content = "Test 1" });
// AnonymousPostSeed
modelBuilder.Entity<Post>().HasData(new { BlogId = 1, PostId = 2, Title = "Second post", Content = "Test 2" });
// OwnedTypeSeed
modelBuilder.Entity<Post>().OwnsOne(p => p.AuthorName).HasData(
new { PostId = 1, First = "Andriy", Last = "Svyryd" },
new { PostId = 2, First = "Diego", Last = "Vega" });
}
public DbSet<Student> Students { get; set; }
public DbSet<UserDef> UserDefs { get; set; }
public DbSet<Order> Orders { get; set; }
}
public class Student
{
public int Id { get; set; }
public string Name { get; set; }
}
public class UserDef
{
public int UserDefId { get; set; }
public string TagsRaw { get; set; }
}
public class Order
{
public int Id { get; set; }
public OrderDetails OrderDetails { get; set; }
}
public class OrderDetails
{
public StreetAddress BillingAddress { get; set; }
public StreetAddress ShippingAddress { get; set; }
}
public class StreetAddress
{
public string Street { get; set; }
public string City { get; set; }
}
}

View File

@@ -0,0 +1,38 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DataLayer.Utilities
{
public static class LocalDatabaseInfo
{
public static List<string> GetLocalDBInstances()
{
// Start the child process.
using var p = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardOutput = true,
FileName = "cmd.exe",
Arguments = "/C sqllocaldb info",
CreateNoWindow = true,
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden
}
};
p.Start();
var output = p.StandardOutput.ReadToEnd();
p.WaitForExit();
// if LocalDb is not installed then it will return that 'sqllocaldb' is not recognized as an internal or external command operable program or batch file
return string.IsNullOrWhiteSpace(output) || output.Contains("not recognized")
? new List<string>()
: output
.Split(new string[] { Environment.NewLine }, StringSplitOptions.None)
.Select(i => i.Trim())
.Where(i => !string.IsNullOrEmpty(i))
.ToList();
}
}
}

View File

@@ -1,6 +1,9 @@
HOW TO CREATE: EF CORE PROJECT
FOR QUICK MIGRATION INSTRUCTIONS:
_DB_NOTES.txt
HOW TO CREATE: EF CORE PROJECT
==============================
easiest with .NET Core but there's also a work-around for .NET Standard
example is for sqlite but the same works with MsSql
@@ -8,15 +11,22 @@ nuget
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
Microsoft.EntityFrameworkCore.Sqlite
MIGRATIONS require standard, not core
using standard instead of core. edit 3 things in csproj
1of3: pluralize xml TargetFramework tag to TargetFrameworks
2of2: TargetFrameworks from: netstandard2.1
to: netcoreapp3.0;netstandard2.1
3of3: add
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
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'.
@@ -26,7 +36,6 @@ set project "Set as StartUp Project"
Tools >> Nuget Package Manager >> Package Manager Console
default project: Examples\SQLite_NETCore2_0
note: in EFCore, Enable-Migrations is no longer used. start with add-migration
PM> add-migration InitialCreate
PM> Update-Database

View File

@@ -1,55 +0,0 @@
proposed extensible schema to generalize beyond audible
problems
0) reeks of premature optimization
- i'm currently only doing audible audiobooks. this adds several layers of abstraction for the sake of possible expansion
- there's a good chance that supporting another platform may not conform to this schema, in which case i'd have done this for nothing. genres are one likely pain point
- libation is currently single-user. hopefully the below would suffice for adding users, but if i'm wrong it might be all pain and no gain
1) very thorough == very complex
2) there are some books which would still be difficult to taxonimize
- joy of cooking. has become more of a brand
- the bible. has different versions that aren't just editions
- dictionary. authored by a publisher
3) "books" vs "editions" is a confusing problem waiting to happen
[AIPK=auto increm PK]
(libation) users [AIPK id, name, join date]
audible users [AIPK id, AUDIBLE-PK username]
libation audible users [PK user id, PK audible user id -- cluster PK across all FKs]
- potential danger in multi-user environment. wouldn't want one libation user getting access to a different libation user's audible info
contributors [AIPK id, name]. prev people. incl publishers
audible authors [PK/FK contributor id, AUDIBLE-PK author id]
roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table
books [AIPK id, title, desc]
book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs]
- likely only authors
editions [AIPK id, FK book id, title]. could expand to include year, is first edition, is abridged
- reasons for optional different title: "Ender's Game: Special 20th Anniversary Edition", "Harry Potter and the Sorcerer's Stone" vs "Harry Potter and the Philosopher's Stone" vs "Harry Potter y la piedra filosofal", "Midnight Riot" vs "Rivers of London"
edition contributors [FK edition id, FK contributor id, FK role id, order -- cluster PK across all FKs]
- likely everything except authors. eg narrators, publisher
audiobooks [PK/FK edition id, lengthInMinutes]
- could expand to other formats by adding other similar tables. eg: print with #pages and isbn, ebook with mb
audible origins [AIPK id, name]. seeded: library. detail. json. series
audible books [PK/FK edition id, AUDIBLE-PK product id, picture id, sku, 3 ratings, audible category id, audible origin id]
- could expand to other vendors by adding other similar tables
audible user ratings [PK/FK edition id, audible user id, 3 ratings]
audible supplements [AIPK id, FK edition id, download url]
- pdfs only. although book download info could be the same format, they're substantially different and subject to change
audible book downloads [PK/FK edition id, audible user id, bookdownloadlink]
pictures [AIPK id, FK edition id, filename (xyz.jpg -- not incl path)]
audible categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep
(libation) library [FK libation user id, FK edition id, date added -- cluster PK across all FKs]
(libation) user defined [FK libation user id, FK edition id, tagsRaw (, notes...) -- cluster PK across all FKs]
- there's no reason to restrict tags to library items, so don't combine/link this table with library
series [AIPK id, name]
audible series [FK series id, AUDIBLE-PK series id/asin, audible origin id]
- could also include a 'name' field for what audible calls this series
series books [FK series id, FK book id (NOT edition id), index -- cluster PK across all FKs]
- "index" not "order". display this number; don't just put in this sequence
- index is float instead of int to allow for in-between books. eg 2.5
- if only using "editions" (ie: getting rid of the "books" table), to show 2 editions as the same book in a series, give them the same index
(libation) user shelves [AIPK id, FK libation user id, name, desc]
- custom shelf. similar to library but very different in philosophy. likely different in evolving details
(libation) shelf books [AIPK id, FK user shelf id, date added, order]
- technically, it's no violation to list a book more than once so use AIPK

View File

@@ -1,76 +0,0 @@
ignore for now:
authorProperties [PK/FK contributor id, AUDIBLE-PK author id]
notes in Contributor.cs for later refactoring
c# enum only, not their own tables:
roles [AIPK id, name]. seeded: author, narrator, publisher. could expand (eg: translator, editor) without each needing a new table
origins [AIPK id, name]. seeded: library. detail. json. series
-- begin SCHEMA ---------------------------------------------------------------------------------------------------------------------
any audible keys should be indexed
SCHEMA
======
contributors [AIPK id, name]. people and publishers
books [AIPK id, AUDIBLE-PK product id, title, desc, lengthInMinutes, picture id, 3 ratings, category id, origin id]
- product instances. each edition and version is discrete: unique and disconnected from different editions of the same book
- on book re-import
update:
update book origin and series origin with the new source type
overwrite simple fields
invoke complex contributor updates
details page gets
un/abridged
release date
language
publisher
series info incl name
categories
if new == series: ignore. do update series info. do not update book info
else if old == json: update (incl if new == json)
else if old == library && new == detail: update
else: ignore
book contributors [FK book id, FK contributor id, FK role id, order -- cluster PK across all FKs]
supplements [AIPK id, FK book id, download url]
categories [AIPK id, AUDIBLE-PK category id, name, parent]. may only nest 1 deep
user defined [PK/FK book id, 3 ratings, tagsRaw]
series [AIPK id, AUDIBLE-PK series id/asin, name, origin id]
series books [FK series id, FK book id, index -- cluster PK across all FKs]
- "index" not "order". display this number; don't just put in this sequence
- index is float instead of int to allow for in-between books. eg 2.5
- to show 2 editions as the same book in a series, give them the same index
- re-import using series page, there will need to be a re-eval of import logic
library [PK/FK book id, date added, bookdownloadlink]
-- end SCHEMA ---------------------------------------------------------------------------------------------------------------------
-- begin SIMPLIFIED DDD ---------------------------------------------------------------------------------------------------------------------
combine domain and persistence (C(r)UD). no repository pattern. encapsulated in domain objects; direct calls to EF Core
https://www.thereformedprogrammer.net/creating-domain-driven-design-entity-classes-with-entity-framework-core/
// pattern for x-to-many
public void AddReview(int numStars, DbContext context = null)
{
if (_reviews != null) _reviews.Add(new Review(numStars));
else if (context == null) throw new Exception("need context");
else if (context.Entry(this).IsKeySet) context.Add(new Review(numStars, BookId));
else throw new Exception("Could not add");
}
// pattern for optional one-to-one
MyPropClass MyProps { get; private set; }
public void AddMyProps(string s, int i, DbContext context = null)
{
// avoid a trip to the db
if (MyProps != null) { MyProps.Update(s, i); return; }
if (BookId == 0) { MyProps = new MyPropClass(s, i); return; }
if (context == null) throw new Exception("need context");
// per Jon P Smith, this single trip to db loads the property if there is one
// note: .Reference() is for single object references. for collections use .Collection()
context.Entry(this).Reference(s => s.MyProps).Load();
if (MyProps != null) MyProps.Update(s, i);
else MyProps = new MyPropClass(s, i);
}
repository reads are 'query object'-like extension methods
https://www.thereformedprogrammer.net/is-the-repository-pattern-useful-with-entity-framework-core/#1-query-objects-a-way-to-isolate-and-hide-database-read-code
-- and SIMPLIFIED DDD ---------------------------------------------------------------------------------------------------------------------

View File

@@ -1,8 +1,6 @@
{
"ConnectionStrings": {
"LibationContext": "Server=(LocalDb)\\MSSQLLocalDB;Database=DataLayer.LibationContext;Integrated Security=true;",
"// on windows sqlite paths accept windows and/or unix slashes": "",
"MyTestContext": "Data Source=%DESKTOP%/sample.db"
"// this connection string is ONLY used for DataLayer's Migrations. this appsettings.json file is NOT used at all by application; it is overwritten": "",
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
}
}

View File

@@ -9,29 +9,31 @@ namespace DtoImporterService
{
public class BookImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new BookValidator().Validate(items);
public BookImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new BookValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// pre-req.s
new ContributorImporter().Import(items, context);
new SeriesImporter().Import(items, context);
new CategoryImporter().Import(items, context);
new ContributorImporter(DbContext).Import(importItems);
new SeriesImporter(DbContext).Import(importItems);
new CategoryImporter(DbContext).Import(importItems);
// get distinct
var productIds = items.Select(i => i.ProductId).ToList();
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList();
// load db existing => .Local
loadLocal_books(productIds, context);
loadLocal_books(productIds);
// upsert
var qtyNew = upsertBooks(items, context);
var qtyNew = upsertBooks(importItems);
return qtyNew;
}
private void loadLocal_books(List<string> productIds, LibationContext context)
private void loadLocal_books(List<string> productIds)
{
var localProductIds = context.Books.Local.Select(b => b.AudibleProductId);
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId);
var remainingProductIds = productIds
.Distinct()
.Except(localProductIds)
@@ -39,87 +41,126 @@ namespace DtoImporterService
// GetBooks() eager loads Series, category, et al
if (remainingProductIds.Any())
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
}
private int upsertBooks(IEnumerable<Item> items, LibationContext context)
private int upsertBooks(IEnumerable<ImportItem> importItems)
{
var qtyNew = 0;
foreach (var item in items)
foreach (var item in importItems)
{
var book = context.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.ProductId);
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
if (book is null)
{
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = item
.Authors
.Select(a => context.Contributors.Local.Single(c => a.Name == c.Name))
.ToList();
book = context.Books.Add(new Book(
new AudibleProductId(item.ProductId), item.Title, item.Description, item.LengthInMinutes, authors))
.Entity;
book = createNewBook(item);
qtyNew++;
}
// if no narrators listed, author is the narrator
if (item.Narrators is null || !item.Narrators.Any())
item.Narrators = item.Authors;
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var narrators = item
.Narrators
.Select(n => context.Contributors.Local.Single(c => n.Name == c.Name))
.ToList();
// not all books have narrators. these will already be using author as narrator. don't undo this
if (narrators.Any())
book.ReplaceNarrators(narrators);
// set/update book-specific info which may have changed
book.PictureId = item.PictureId;
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
book.AddSupplementDownloadUrl(item.SupplementUrl);
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = context.Contributors.Local.Single(c => publisherName == c.Name);
book.ReplacePublisher(publisher);
}
// 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);
//
// this was round 1 when it was a 2 step process
//
//// update series even for existing books. these are occasionally updated
//var seriesIds = item.Series.Select(kvp => kvp.SeriesId).ToList();
//var allSeries = context.Series.Local.Where(c => seriesIds.Contains(c.AudibleSeriesId)).ToList();
//foreach (var series in allSeries)
// book.UpsertSeries(series);
// these will upsert over library-scraped series, but will not leave orphans
if (item.Series != null)
{
foreach (var seriesEntry in item.Series)
{
var series = context.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
book.UpsertSeries(series, seriesEntry.Index);
}
}
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == item.Categories.LastOrDefault().CategoryId);
if (category != null)
book.UpdateCategory(category, context);
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
updateBook(item, book);
}
return qtyNew;
}
private Book createNewBook(ImportItem importItem)
{
var item = importItem.DtoItem;
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
item.Authors = new[] { new Person { Name = "", Asin = null } };
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = item
.Authors
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name))
.ToList();
var narrators
= item.Narrators is null || !item.Narrators.Any()
// if no narrators listed, author is the narrator
? authors
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
: item
.Narrators
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name))
.ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
// absence of categories is also possible
// CATEGORY HACK: only use the 1st 2 categories
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
var lastCategory
= item.Categories.Length == 0 ? ""
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
// 2+
: item.Categories[1].CategoryId;
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory);
var book = DbContext.Books.Add(new Book(
new AudibleProductId(item.ProductId),
item.Title,
item.Description,
item.LengthInMinutes,
authors,
narrators,
category,
importItem.LocaleName)
).Entity;
var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName))
{
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name);
book.ReplacePublisher(publisher);
}
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl))
book.AddSupplementDownloadUrl(item.SupplementUrl);
return book;
}
private void updateBook(ImportItem importItem, Book book)
{
var item = importItem.DtoItem;
// set/update book-specific info which may have changed
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);
// update series even for existing books. these are occasionally updated
// these will upsert over library-scraped series, but will not leave orphans
if (item.Series != null)
{
foreach (var seriesEntry in item.Series)
{
var series = DbContext.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId);
var index = 0f;
try
{
index = seriesEntry.Index;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"Error parsing series index. Title: {item.Title}. ASIN: {item.Asin}. Series index: {seriesEntry.Sequence}");
}
book.UpsertSeries(series, index);
}
}
}
}
}

View File

@@ -9,36 +9,47 @@ namespace DtoImporterService
{
public class CategoryImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new CategoryValidator().Validate(items);
public CategoryImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new CategoryValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// get distinct
var categoryIds = items.GetCategoriesDistinct().Select(c => c.CategoryId).ToList();
var categoryIds = importItems
.Select(i => i.DtoItem)
.GetCategoriesDistinct()
.Select(c => c.CategoryId).ToList();
// load db existing => .Local
loadLocal_categories(categoryIds, context);
loadLocal_categories(categoryIds);
// upsert
var categoryPairs = items.GetCategoryPairsDistinct().ToList();
var qtyNew = upsertCategories(categoryPairs, context);
var categoryPairs = importItems
.Select(i => i.DtoItem)
.GetCategoryPairsDistinct()
.ToList();
var qtyNew = upsertCategories(categoryPairs);
return qtyNew;
}
private void loadLocal_categories(List<string> categoryIds, LibationContext context)
private void loadLocal_categories(List<string> categoryIds)
{
var localIds = context.Categories.Local.Select(c => c.AudibleCategoryId);
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId);
var remainingCategoryIds = categoryIds
.Distinct()
.Except(localIds)
.ToList();
// load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingCategoryIds.Any())
context.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId)).ToList();
DbContext.Categories.Where(c => remainingCategoryIds.Contains(c.AudibleCategoryId) || c.Name == emptyName).ToList();
}
// only use after loading contributors => local
private int upsertCategories(List<Ladder[]> categoryPairs, LibationContext context)
private int upsertCategories(List<Ladder[]> categoryPairs)
{
var qtyNew = 0;
@@ -46,17 +57,21 @@ namespace DtoImporterService
{
for (var i = 0; i < pair.Length; i++)
{
// CATEGORY HACK: not yet supported: depth beyond 0 and 1
if (i > 1)
break;
var id = pair[i].CategoryId;
var name = pair[i].CategoryName;
Category parentCategory = null;
if (i == 1)
parentCategory = context.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
parentCategory = DbContext.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId);
var category = context.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id);
if (category is null)
{
category = context.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
qtyNew++;
}

View File

@@ -9,84 +9,89 @@ namespace DtoImporterService
{
public class ContributorImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new ContributorValidator().Validate(items);
public ContributorImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new ContributorValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// get distinct
var authors = items.GetAuthorsDistinct().ToList();
var narrators = items.GetNarratorsDistinct().ToList();
var publishers = items.GetPublishersDistinct().ToList();
var authors = importItems
.Select(i => i.DtoItem)
.GetAuthorsDistinct()
.ToList();
var narrators = importItems
.Select(i => i.DtoItem)
.GetNarratorsDistinct()
.ToList();
var publishers = importItems
.Select(i => i.DtoItem)
.GetPublishersDistinct()
.ToList();
// load db existing => .Local
var allNames = authors
.Select(a => a.Name)
var allNames = publishers
.Union(authors.Select(n => n.Name))
.Union(narrators.Select(n => n.Name))
.Union(publishers)
.Where(name => !string.IsNullOrWhiteSpace(name))
.ToList();
loadLocal_contributors(allNames, context);
loadLocal_contributors(allNames);
// upsert
var qtyNew = 0;
qtyNew += upsertPeople(authors, context);
qtyNew += upsertPeople(narrators, context);
qtyNew += upsertPublishers(publishers, context);
qtyNew += upsertPeople(authors);
qtyNew += upsertPeople(narrators);
qtyNew += upsertPublishers(publishers);
return qtyNew;
}
private void loadLocal_contributors(List<string> contributorNames, LibationContext context)
private void loadLocal_contributors(List<string> contributorNames)
{
contributorNames.Remove(null);
contributorNames.Remove("");
//// 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 = context.Contributors.Local.Select(c => c.Name);
var localNames = DbContext.Contributors.Local.Select(c => c.Name);
var remainingContribNames = contributorNames
.Distinct()
.Except(localNames)
.ToList();
// load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingContribNames.Any())
context.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
// _________________________________^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// i tried to extract this pattern, but this part prohibits doing so
// wouldn't work anyway for Books.GetBooks()
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList();
}
// only use after loading contributors => local
private int upsertPeople(List<Person> people, LibationContext context)
private int upsertPeople(List<Person> people)
{
var qtyNew = 0;
foreach (var p in people)
{
var person = context.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
var person = DbContext.Contributors.Local.SingleOrDefault(c => c.Name == p.Name);
if (person == null)
{
person = context.Contributors.Add(new Contributor(p.Name)).Entity;
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity;
qtyNew++;
}
person.UpdateAudibleAuthorId(p.Asin);
}
return qtyNew;
}
// only use after loading contributors => local
private int upsertPublishers(List<string> publishers, LibationContext context)
private int upsertPublishers(List<string> publishers)
{
var qtyNew = 0;
foreach (var publisherName in publishers)
{
if (context.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null)
{
context.Contributors.Add(new Contributor(publisherName));
DbContext.Contributors.Add(new Contributor(publisherName));
qtyNew++;
}
}

View File

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

View File

@@ -1,44 +1,57 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using DataLayer;
using Dinah.Core;
using InternalUtilities;
namespace DtoImporterService
{
public interface IContextRunner<T>
public abstract class ImporterBase<T>
{
public TResult Run<TResult>(Func<T, LibationContext, TResult> func, T param, LibationContext context = null)
protected LibationContext DbContext { get; }
protected ImporterBase(LibationContext context)
{
if (context is null)
ArgumentValidator.EnsureNotNull(context, nameof(context));
DbContext = context;
}
/// <summary>LONG RUNNING. call with await Task.Run</summary>
public int Import(T param) => Run(DoImport, param);
public TResult Run<TResult>(Func<T, TResult> func, T param)
{
try
{
using (context = LibationContext.Create())
{
var r = Run(func, param, context);
context.SaveChanges();
return r;
}
var exceptions = Validate(param);
if (exceptions != null && exceptions.Any())
throw new AggregateException($"Importer validation failed", exceptions);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Import error: validation");
throw;
}
var exceptions = Validate(param);
if (exceptions != null && exceptions.Any())
throw new AggregateException($"Device Jobs Service configuration validation failed", exceptions);
var result = func(param, context);
return result;
try
{
var result = func(param);
return result;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Import error: post-validation importing");
throw;
}
}
IEnumerable<Exception> Validate(T param);
}
public abstract class ImporterBase<T> : IContextRunner<T>
{
/// <summary>LONG RUNNING. call with await Task.Run</summary>
public int Import(T param, LibationContext context = null)
=> ((IContextRunner<T>)this).Run(DoImport, param, context);
protected abstract int DoImport(T elements, LibationContext context);
protected abstract int DoImport(T elements);
public abstract IEnumerable<Exception> Validate(T param);
}
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<Item>> { }
public abstract class ItemsImporterBase : ImporterBase<IEnumerable<ImportItem>>
{
protected ItemsImporterBase(LibationContext context) : base(context) { }
}
}

View File

@@ -9,30 +9,50 @@ namespace DtoImporterService
{
public class LibraryImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new LibraryValidator().Validate(items);
public LibraryImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
new BookImporter().Import(items, context);
new BookImporter(DbContext).Import(importItems);
var qtyNew = upsertLibraryBooks(items, context);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<Item> items, LibationContext context)
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
var currentLibraryProductIds = context.Library.Select(l => l.Book.AudibleProductId).ToList();
var newItems = items.Where(dto => !currentLibraryProductIds.Contains(dto.ProductId)).ToList();
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - 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
//
// 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 newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
foreach (var newItem in newItems)
{
var libraryBook = new LibraryBook(
context.Books.Local.Single(b => b.AudibleProductId == newItem.ProductId),
newItem.DateAdded
// needed for scraping
//,FileManager.FileUtility.RestoreDeclawed(newLibraryDTO.DownloadBookLink)
);
context.Library.Add(libraryBook);
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);
}
var qtyNew = newItems.Count;

View File

@@ -9,44 +9,49 @@ namespace DtoImporterService
{
public class SeriesImporter : ItemsImporterBase
{
public override IEnumerable<Exception> Validate(IEnumerable<Item> items) => new SeriesValidator().Validate(items);
public SeriesImporter(LibationContext context) : base(context) { }
protected override int DoImport(IEnumerable<Item> items, LibationContext context)
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new SeriesValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
// get distinct
var series = items.GetSeriesDistinct().ToList();
var series = importItems
.Select(i => i.DtoItem)
.GetSeriesDistinct()
.ToList();
// load db existing => .Local
var seriesIds = series.Select(s => s.SeriesId).ToList();
loadLocal_series(seriesIds, context);
loadLocal_series(series);
// upsert
var qtyNew = upsertSeries(series, context);
var qtyNew = upsertSeries(series);
return qtyNew;
}
private void loadLocal_series(List<string> seriesIds, LibationContext context)
private void loadLocal_series(List<AudibleApiDTOs.Series> series)
{
var localIds = context.Series.Local.Select(s => s.AudibleSeriesId);
var seriesIds = series.Select(s => s.SeriesId).ToList();
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
var remainingSeriesIds = seriesIds
.Distinct()
.Except(localIds)
.ToList();
if (remainingSeriesIds.Any())
context.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
}
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries, LibationContext context)
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries)
{
var qtyNew = 0;
foreach (var s in requestedSeries)
{
var series = context.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
var series = DbContext.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId);
if (series is null)
{
series = context.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
qtyNew++;
}
series.UpdateName(s.SeriesName);

View File

@@ -0,0 +1,65 @@
using System;
using System.Linq;
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 DownloadBook DownloadBook { get; } = new DownloadBook();
public DecryptBook DecryptBook { get; } = new DecryptBook();
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 DownloadBook.TryProcessAsync(libraryBook);
if (statusHandler.HasErrors)
return statusHandler;
}
{
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,205 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileManager;
using InternalUtilities;
namespace FileLiberator
{
/// <summary>
/// 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 DecryptBook : IDecryptable
{
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
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<string> DecryptCompleted;
public event EventHandler<LibraryBook> Completed;
public bool Validate(LibraryBook libraryBook)
=> AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
&& !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 aaxFilename = AudibleFileStorage.AAX.GetPath(libraryBook.Book.AudibleProductId);
if (aaxFilename == null)
return new StatusHandler { "aaxFilename parameter is null" };
if (!File.Exists(aaxFilename))
return new StatusHandler { $"Cannot find AAX file: {aaxFilename}" };
if (AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId))
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
var chapters = await downloadChapterNames(libraryBook);
var outputAudioFilename = await aaxToM4bConverterDecrypt(aaxFilename, libraryBook, chapters);
// decrypt failed
if (outputAudioFilename == null)
return new StatusHandler { "Decrypt failed" };
var destinationDir = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename);
var config = Configuration.Instance;
if (config.RetainAaxFiles)
{
var newAaxFilename = FileUtility.GetValidFilename(
destinationDir,
Path.GetFileNameWithoutExtension(aaxFilename),
"aax");
File.Move(aaxFilename, newAaxFilename);
}
else
{
Dinah.Core.IO.FileExt.SafeDelete(aaxFilename);
}
var finalAudioExists = AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId);
if (!finalAudioExists)
return new StatusHandler { "Cannot find final audio file after decryption" };
return new StatusHandler();
}
finally
{
Completed?.Invoke(this, libraryBook);
}
}
private async Task<string> aaxToM4bConverterDecrypt(string aaxFilename, LibraryBook libraryBook, Chapters chapters = null)
{
DecryptBegin?.Invoke(this, $"Begin decrypting {aaxFilename}");
try
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var account = persister
.AccountsSettings
.GetAccount(libraryBook.Account, libraryBook.Book.Locale);
var converter = await AaxToM4bConverter.CreateAsync(aaxFilename, account.DecryptKey, chapters);
converter.AppName = "Libation";
TitleDiscovered?.Invoke(this, converter.tags.title);
AuthorsDiscovered?.Invoke(this, converter.tags.author);
NarratorsDiscovered?.Invoke(this, converter.tags.narrator);
CoverImageFilepathDiscovered?.Invoke(this, converter.coverBytes);
// override default which was set in CreateAsync
var proposedOutputFile = Path.Combine(AudibleFileStorage.DecryptInProgress, $"[{libraryBook.Book.AudibleProductId}].m4b");
converter.SetOutputFilename(proposedOutputFile);
converter.DecryptProgressUpdate += (s, progress) => UpdateProgress?.Invoke(this, progress);
// REAL WORK DONE HERE
var success = await Task.Run(() => converter.Run());
// decrypt failed
if (!success)
return null;
account.DecryptKey = converter.decryptKey;
return converter.outputFileName;
}
finally
{
DecryptCompleted?.Invoke(this, $"Completed decrypting {aaxFilename}");
}
}
private async Task<Chapters> downloadChapterNames(LibraryBook libraryBook)
{
try
{
var api = await AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
var contentMetadata = await api.GetLibraryBookMetadataAsync(libraryBook.Book.AudibleProductId);
if (contentMetadata?.ChapterInfo != null)
return new DownloadedChapters(contentMetadata.ChapterInfo);
return null;
}
catch
{
return null;
}
}
private static string moveFilesToBooksDir(Book product, string outputAudioFilename)
{
// create final directory. move each file into it. MOVE AUDIO FILE LAST
// new dir: safetitle_limit50char + " [" + productId + "]"
var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
Directory.CreateDirectory(destinationDir);
var sortedFiles = getProductFilesSorted(product, outputAudioFilename);
var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.');
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext
var audioFileName = FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId);
foreach (var f in sortedFiles)
{
var dest
= AudibleFileStorage.Audio.IsFileTypeMatch(f)
? audioFileName
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
: FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
if (Path.GetExtension(dest).Trim('.').ToLower() == "cue")
Cue.UpdateFileName(f, audioFileName);
File.Move(f.FullName, dest);
}
return destinationDir;
}
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
{
// files are: temp path\author\[asin].ext
var m4bDir = new FileInfo(outputAudioFilename).Directory;
var files = m4bDir
.EnumerateFiles()
.Where(f => f.Name.ContainsInsensitive(product.AudibleProductId))
.ToList();
// move audio files to the end of the collection so these files are moved last
var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f));
var sortedFiles = files
.Except(musicFiles)
.Concat(musicFiles)
.ToList();
return sortedFiles;
}
}
}

View File

@@ -0,0 +1,122 @@
using System;
using System.IO;
using System.Threading.Tasks;
using AudibleApi;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using FileManager;
using InternalUtilities;
namespace FileLiberator
{
/// <summary>
/// Download DRM book
///
/// 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 DownloadBook : DownloadableBase
{
private const string SERVICE_UNAVAILABLE = "Content Delivery Companion Service is not available.";
public override bool Validate(LibraryBook libraryBook)
=> !AudibleFileStorage.Audio.Exists(libraryBook.Book.AudibleProductId)
&& !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId);
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var tempAaxFilename = getDownloadPath(libraryBook);
var actualFilePath = await downloadBookAsync(libraryBook, tempAaxFilename);
moveBook(libraryBook, actualFilePath);
return verifyDownload(libraryBook);
}
private static string getDownloadPath(LibraryBook libraryBook)
=> FileUtility.GetValidFilename(
AudibleFileStorage.DownloadsInProgress,
libraryBook.Book.Title,
"aax",
libraryBook.Book.AudibleProductId);
private async Task<string> downloadBookAsync(LibraryBook libraryBook, string tempAaxFilename)
{
validate(libraryBook);
var api = await GetApiAsync(libraryBook);
var actualFilePath = await PerformDownloadAsync(
tempAaxFilename,
(p) => api.DownloadAaxWorkaroundAsync(libraryBook.Book.AudibleProductId, tempAaxFilename, p));
System.Threading.Thread.Sleep(100);
// if bad file download, a 0-33 byte file will be created
// if service unavailable, a 52 byte string will be saved as file
var length = new FileInfo(actualFilePath).Length;
if (length > 100)
return actualFilePath;
var contents = File.ReadAllText(actualFilePath);
File.Delete(actualFilePath);
var exMsg = contents.StartsWithInsensitive(SERVICE_UNAVAILABLE)
? SERVICE_UNAVAILABLE
: "Error downloading file";
var ex = new Exception(exMsg);
Serilog.Log.Logger.Error(ex, "Download error {@DebugInfo}", new
{
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,
libraryBook.Book.Locale,
Account = libraryBook.Account?.ToMask() ?? "[empty]",
tempAaxFilename,
actualFilePath,
length,
contents
});
throw ex;
}
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 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.Book.Locale))
throw new Exception(errorString("Locale"));
}
private void moveBook(LibraryBook libraryBook, string actualFilePath)
{
var newAaxFilename = FileUtility.GetValidFilename(
AudibleFileStorage.DownloadsFinal,
libraryBook.Book.Title,
"aax",
libraryBook.Book.AudibleProductId);
File.Move(actualFilePath, newAaxFilename);
Invoke_StatusUpdate($"Successfully downloaded. Moved to: {newAaxFilename}");
}
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !AudibleFileStorage.AAX.Exists(libraryBook.Book.AudibleProductId)
? new StatusHandler { "Downloaded AAX file cannot be found" }
: new StatusHandler();
}
}

View File

@@ -0,0 +1,35 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
// frustratingly copy pasta from DownloadableBase and DownloadPdf
public class DownloadFile : IDownloadable
{
public event EventHandler<string> DownloadBegin;
public event EventHandler<DownloadProgress> DownloadProgressChanged;
public event EventHandler<string> DownloadCompleted;
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);
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
try
{
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
return actualDownloadedFilePath;
}
finally
{
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
}
}
}
}

View File

@@ -0,0 +1,62 @@
using System;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using FileManager;
namespace FileLiberator
{
public class DownloadPdf : DownloadableBase
{
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId);
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook)
{
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
return verifyDownload(libraryBook);
}
private static StatusHandler verifyDownload(LibraryBook libraryBook)
=> !AudibleFileStorage.PDF.Exists(libraryBook.Book.AudibleProductId)
? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler();
private static string getProposedDownloadFilePath(LibraryBook libraryBook)
{
// if audio file exists, get it's dir. else return base Book dir
var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
var file = getdownloadUrl(libraryBook);
if (existingPath != null)
return Path.Combine(existingPath, Path.GetFileName(file));
var full = FileUtility.GetValidFilename(
AudibleFileStorage.PDF.StorageDirectory,
libraryBook.Book.Title,
Path.GetExtension(file),
libraryBook.Book.AudibleProductId);
return full;
}
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
{
var api = await GetApiAsync(libraryBook);
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var client = new HttpClient();
var actualDownloadedFilePath = await PerformDownloadAsync(
proposedDownloadFilePath,
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p));
}
private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
}
}

View File

@@ -0,0 +1,64 @@
using System;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public abstract class DownloadableBase : IDownloadableProcessable
{
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> StatusUpdate;
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
public abstract bool Validate(LibraryBook libraryBook);
public abstract Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook);
// 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
{
return await ProcessItemAsync(libraryBook);
}
finally
{
Completed?.Invoke(this, libraryBook);
}
}
protected static Task<AudibleApi.Api> GetApiAsync(LibraryBook libraryBook)
=> InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
{
var progress = new Progress<DownloadProgress>();
progress.ProgressChanged += (_, e) => DownloadProgressChanged?.Invoke(this, e);
DownloadBegin?.Invoke(this, proposedDownloadFilePath);
try
{
var result = await func(progress);
StatusUpdate?.Invoke(this, result);
return result;
}
finally
{
DownloadCompleted?.Invoke(this, proposedDownloadFilePath);
}
}
}
}

View File

@@ -0,0 +1,22 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Globalization;
using System.Linq;
using System.Text;
using AaxDecrypter;
using AudibleApiDTOs;
using Dinah.Core.Diagnostics;
namespace FileLiberator
{
public class DownloadedChapters : Chapters
{
public DownloadedChapters(ChapterInfo chapterInfo)
{
AddChapters(chapterInfo.Chapters
.Select(c => new AaxDecrypter.Chapter(c.StartOffsetMs / 1000d, (c.StartOffsetMs + c.LengthMs) / 1000d, c.Title)));
}
}
}

View File

@@ -1,14 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netstandard2.1</TargetFramework>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
<ProjectReference Include="..\ApplicationService\ApplicationService.csproj" />
<ProjectReference Include="..\AudibleDotComAutomation\AudibleDotComAutomation.csproj" />
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
</ItemGroup>

View File

@@ -1,6 +1,6 @@
using System;
namespace ScrapingDomainServices
namespace FileLiberator
{
public interface IDecryptable : IProcessable
{

View File

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

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