Compare commits

...

335 Commits

Author SHA1 Message Date
Robert McRackan
153e1b92bf Bug fixes, logging, options for how to handle illegal characters 2022-06-23 21:01:44 -04:00
rmcrackan
fc5ae7403a Merge pull request #295 from Mbucari/master
Optional illegal character replacement and more error handling/logging
2022-06-23 20:56:58 -04:00
Michael Bucari-Tovo
13149eff08 Make better use of heirarch chapters to combine section title audio (which is usually short, eg "Part 1") with the following full-length chapter. 2022-06-23 17:29:45 -06:00
Michael Bucari-Tovo
9c53d9bf87 Better open/close quote detection 2022-06-23 16:52:13 -06:00
Michael Bucari-Tovo
bc9625fece Disallow illegal chars in templates 2022-06-23 16:36:56 -06:00
Michael Bucari-Tovo
7e00162ef2 Code reuse and better naming 2022-06-23 16:28:21 -06:00
Michael Bucari-Tovo
af38750e29 Fix reverted changes 2022-06-23 16:19:00 -06:00
Michael Bucari-Tovo
314f4850bc Add logging and error handling to Process Queue. and Processables 2022-06-23 15:38:39 -06:00
Michael Bucari-Tovo
9ff2a83ba3 Rename Minimum to Barebones 2022-06-23 13:11:35 -06:00
Michael Bucari-Tovo
2ab466c570 Custom illegal character replacement 2022-06-23 13:01:24 -06:00
Mbucari
184ba84600 Merge branch 'rmcrackan:master' into master 2022-06-23 11:35:09 -06:00
Michael Bucari-Tovo
99dddb1af4 Revert "* bug fix: occasional hang bug in process queue"
This reverts commit b7fd87b09c.
2022-06-23 11:34:50 -06:00
Michael Bucari-Tovo
48eca3f5af Revert "Add character replacement"
This reverts commit 1470aefd42.
2022-06-23 11:34:39 -06:00
Michael Bucari-Tovo
71192cc2ee Revert "Match rmcrackan's changes"
This reverts commit 52622fadbb.
2022-06-23 11:34:29 -06:00
Michael Bucari-Tovo
29c7344540 Revert "linux + WINE link"
This reverts commit eff2634b32.
2022-06-23 11:34:24 -06:00
Michael Bucari-Tovo
6411d23744 Revert "Non-null disposed BlockingCollection can throw exception"
This reverts commit ba722487d8.
2022-06-23 11:34:20 -06:00
Michael Bucari-Tovo
1a74736115 Revert "Improve display and function of character replacement"
This reverts commit b698697256.
2022-06-23 11:34:11 -06:00
Michael Bucari-Tovo
7c11ecb3a7 Revert "Change type"
This reverts commit 839a62cb07.
2022-06-23 11:34:07 -06:00
Michael Bucari-Tovo
fd7c833de0 Revert "make auto-scan more fault-tolerant"
This reverts commit f802d1524f.
2022-06-23 11:34:00 -06:00
Michael Bucari-Tovo
7fec8b0d7e Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-23 11:07:18 -06:00
Michael Bucari-Tovo
52622fadbb Match rmcrackan's changes 2022-06-23 11:07:10 -06:00
Robert McRackan
57255e0aec comments 2022-06-23 07:53:12 -04:00
rmcrackan
17ecfa132d Merge pull request #293 from Dr-Blank/master
Spellcheck in Comments and Strings
2022-06-23 06:53:54 -04:00
Dr-Blank
d1365c3d7d Spellcheck in Comments and Strings
Corrected some spellings in Display messages and Comments.
2022-06-22 23:35:54 -04:00
Robert McRackan
c33891a4bc update dependencies 2022-06-22 22:13:56 -04:00
Michael Bucari-Tovo
9a63f57147 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-22 08:01:48 -06:00
Michael Bucari-Tovo
839a62cb07 Change type 2022-06-22 08:01:39 -06:00
Mbucari
dc598e466e Merge branch 'rmcrackan:master' into master 2022-06-21 23:40:21 -06:00
Michael Bucari-Tovo
b698697256 Improve display and function of character replacement 2022-06-21 23:39:24 -06:00
Robert McRackan
f802d1524f make auto-scan more fault-tolerant 2022-06-21 22:44:25 -04:00
Mbucari
0cb18f9e1a Merge branch 'rmcrackan:master' into master 2022-06-21 20:17:27 -06:00
Robert McRackan
ba722487d8 Non-null disposed BlockingCollection can throw exception 2022-06-21 21:08:49 -04:00
rmcrackan
eff2634b32 linux + WINE link 2022-06-21 20:54:38 -04:00
Michael Bucari-Tovo
1470aefd42 Add character replacement 2022-06-21 18:50:30 -06:00
Robert McRackan
b7fd87b09c * bug fix: occasional hang bug in process queue
* bug fix: #283 template folders
2022-06-21 10:42:57 -04:00
rmcrackan
ab82a1656d Merge pull request #282 from Mbucari/master
Fixed rare bug that would hang if an error occured while downloading
2022-06-21 10:34:31 -04:00
Michael Bucari-Tovo
71387e94d8 Fix bug if folder ended in trailing slash 2022-06-21 08:08:09 -06:00
Michael Bucari-Tovo
503379079b Fix WaitToPosition logic 2022-06-21 00:23:02 -06:00
Michael Bucari-Tovo
1ae767087f Check downloadEnded inside WaitToPosition 2022-06-20 23:13:34 -06:00
Michael Bucari-Tovo
cfd2b7b7aa Fixed rare bug that would cause a hang if an error occured in the download loop 2022-06-20 22:36:14 -06:00
Robert McRackan
2c42b4c585 * #278 -- new hier. chapters format
* #281 -- template bug fix

Thanks for the quick turn-around, @MBucari !
2022-06-20 21:02:15 -04:00
rmcrackan
d3a9ff539e Merge pull request #280 from Mbucari/master
Add support for Audible's new  hierarchical chapters
2022-06-20 20:57:06 -04:00
Michael Bucari-Tovo
58f01bd642 Fix possible x-thread error. 2022-06-20 18:45:44 -06:00
Michael Bucari-Tovo
38806740e1 Use Path.Join instead of string.Join 2022-06-20 18:12:29 -06:00
Michael Bucari-Tovo
df583e73c2 Fixed file naming template 2022-06-20 17:37:52 -06:00
Michael Bucari-Tovo
e787d33e5a Fix NRE on cancel when there's nothing to cancel. 2022-06-20 16:47:25 -06:00
Michael Bucari-Tovo
91db665428 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-06-20 15:45:00 -06:00
Michael Bucari-Tovo
94d155cff2 Add support for Audible's new hierarchical chapters. 2022-06-20 15:41:37 -06:00
Robert McRackan
ad79075fd7 Fix issues #183 and #186 2022-06-20 16:30:40 -04:00
rmcrackan
7baefe2f44 Merge pull request #277 from Mbucari/master
Issues #183 and #186, and a lot of other little things
2022-06-20 16:29:20 -04:00
Michael Bucari-Tovo
141a4c29bb Correct error in saving settings 2022-06-20 14:04:03 -06:00
Michael Bucari-Tovo
b2992da370 Move DownloadOptions to FileLiberator 2022-06-20 10:22:21 -06:00
Michael Bucari-Tovo
fdee254020 Only copy files if conversion succeeded. 2022-06-20 09:04:06 -06:00
Michael Bucari-Tovo
c51489ac74 Await cancell 2022-06-19 18:49:47 -06:00
Michael Bucari-Tovo
3cd394ec10 Change unicode asterisk 2022-06-19 18:04:00 -06:00
Michael Bucari-Tovo
8374fea776 Update tests for unicode chars 2022-06-19 17:59:16 -06:00
Michael Bucari-Tovo
733ca891de Fix unicode replacement 2022-06-19 17:29:46 -06:00
Michael Bucari-Tovo
490d121db3 Add unicode replacements for illegal characters 2022-06-19 16:57:44 -06:00
Michael Bucari-Tovo
45c5efffbd Add support for multipart title naming templates 2022-06-19 15:42:21 -06:00
Michael Bucari-Tovo
a24c929acf Update tests for long file paths 2022-06-19 15:38:59 -06:00
Michael Bucari-Tovo
86a39f10d1 Formatting 2022-06-19 12:59:35 -06:00
Michael Bucari-Tovo
4658afdc20 Add Track Number support and make Cancel async 2022-06-19 12:56:33 -06:00
Michael Bucari-Tovo
ae6c2afb30 Improve filename template 2022-06-18 13:04:57 -06:00
Michael Bucari-Tovo
a3844a3535 Add long path support 2022-06-18 11:28:48 -06:00
Michael Bucari-Tovo
b710075544 Make use of unauthenticated API 2022-06-17 23:09:22 -06:00
Mbucari
c4c9786050 Merge branch 'rmcrackan:master' into master 2022-06-17 16:46:31 -06:00
Robert McRackan
b4cc81139a Bug fix ( #276 ): x-thread error on fresh install 2022-06-17 12:40:37 -04:00
Mbucari
fb20eb9162 Merge branch 'rmcrackan:master' into master 2022-06-15 14:22:09 -06:00
Robert McRackan
263987d2c9 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-06-15 10:43:04 -04:00
Robert McRackan
0b30a35383 updated dependencies 2022-06-15 10:42:56 -04:00
Mbucari
47df1fc602 Merge branch 'rmcrackan:master' into master 2022-06-14 10:46:00 -06:00
rmcrackan
d8375454b9 Merge pull request #274 from maaximal/spelling
Fix spelling error
2022-06-14 09:16:05 -04:00
Max Byszio
ad535501c4 Fix spelling error 2022-06-14 14:43:21 +02:00
Michael Bucari-Tovo
159f5cbd00 Add lame options to ConvertToMp3 2022-06-13 22:18:00 -06:00
Michael Bucari-Tovo
2bc74d5378 Combine Streamable and Processable, remove unused events. 2022-06-13 21:40:37 -06:00
Robert McRackan
eb513f563e Allow sorting by "Remove" column 2022-06-13 13:55:39 -04:00
rmcrackan
09dc5e9846 Merge pull request #273 from Mbucari/master
Add option to save episodes to series parent
2022-06-13 12:00:05 -04:00
Mbucari
cf35a87d85 Update AppScaffolding.csproj 2022-06-12 19:48:51 -06:00
Michael Bucari-Tovo
9f25f619a8 Formatting 2022-06-12 19:37:42 -06:00
Michael Bucari-Tovo
7e989c730c Add option to save podcasts to series folder 2022-06-12 19:36:18 -06:00
Robert McRackan
0926e86956 Version 8 2022-06-12 21:20:02 -04:00
rmcrackan
75967730fd Merge pull request #271 from Mbucari/master
Move Remove Books function into main grid, added more db migrations and fixups for episodes
2022-06-12 21:14:51 -04:00
Michael Bucari-Tovo
a3be3e354f Code readability changes 2022-06-12 17:30:11 -06:00
Michael Bucari-Tovo
58c52196f1 Remove Books button now on Main button row 2022-06-12 17:03:29 -06:00
Michael Bucari-Tovo
b7b49a60cf Migration Exception handling 2022-06-12 16:35:48 -06:00
Michael Bucari-Tovo
fa195483d6 Set Import/Esport initial directory 2022-06-12 16:29:33 -06:00
Michael Bucari-Tovo
2341f6ea3b Better display and hiding of process queue 2022-06-12 16:29:06 -06:00
Michael Bucari-Tovo
ffe0f0730d Don't fire click for error books 2022-06-12 15:27:10 -06:00
Michael Bucari-Tovo
23b512910e Update 2022-06-12 15:23:55 -06:00
Michael Bucari-Tovo
b1c624b104 Revised stoplight icons 2022-06-12 15:17:07 -06:00
Michael Bucari-Tovo
fe35be6682 New libation icons 2022-06-12 13:39:35 -06:00
Michael Bucari-Tovo
2d3eb29bd5 Move event invoke out of lock 2022-06-11 19:10:08 -06:00
Michael Bucari-Tovo
26f0ff62df Additional safety check 2022-06-11 15:10:18 -06:00
Michael Bucari-Tovo
5e145846bd Only check non-liberated books when doing scan remove books. 2022-06-11 12:42:00 -06:00
Michael Bucari-Tovo
1ae5f99bf0 Add migration to try and fix db for incorrect or missing espiode series entries. 2022-06-11 12:41:20 -06:00
Michael Bucari-Tovo
984119c7ee Exit download loop if zero bytes are read. 2022-06-10 21:00:04 -06:00
Michael Bucari-Tovo
f8f5eac109 Refactor 2022-06-10 20:45:10 -06:00
Michael Bucari-Tovo
4111d5fa48 Remove redundant declarations. 2022-06-10 19:37:50 -06:00
Michael Bucari-Tovo
2eca9056b9 Reorder api calls 2022-06-10 19:36:00 -06:00
Michael Bucari-Tovo
60e96572ff Always refresh token, regardless of expiration date. 2022-06-10 19:34:49 -06:00
Michael Bucari-Tovo
52193933b2 Add scan and remove books tomain view, remove separate dialog. 2022-06-10 19:22:54 -06:00
Michael Bucari-Tovo
7bcabdda38 FindInactiveBooks now fires ScanBegin and ScanEnd events 2022-06-10 18:30:16 -06:00
Mbucari
d993941c4d Merge branch 'rmcrackan:master' into master 2022-06-10 15:41:07 -06:00
Michael Bucari-Tovo
b447bff9a6 Add audible-cli import/export accounts 2022-06-10 15:19:05 -06:00
Robert McRackan
73cb5ffba4 clearly Hoopla integration isn't going to happen. delete temp files 2022-06-09 17:01:33 -04:00
Robert McRackan
7d694229c1 Prep for version release 2022-06-08 14:48:44 -04:00
rmcrackan
cdb6c9a1a4 Merge pull request #268 from Mbucari/master
Address issues in 263
2022-06-08 14:46:17 -04:00
Michael Bucari-Tovo
cc1d2b423f Fix an oopsie 2022-06-08 12:15:21 -06:00
Michael Bucari-Tovo
508e031143 Move all event invocations outside locks 2022-06-08 12:08:15 -06:00
Michael Bucari-Tovo
5a093a9a04 add event keyword 2022-06-08 10:53:45 -06:00
Michael Bucari-Tovo
074d647d19 Improve Query 2022-06-08 10:36:06 -06:00
Michael Bucari-Tovo
6cb98f99c5 Use new content type queries 2022-06-08 10:34:05 -06:00
Michael Bucari-Tovo
7d28681b23 Move queries into DataLayer 2022-06-08 10:08:18 -06:00
Michael Bucari-Tovo
859a8e933c Formatting 2022-06-08 09:46:11 -06:00
Michael Bucari-Tovo
a476d5986d Update dependency 2022-06-08 09:44:06 -06:00
Michael Bucari-Tovo
31812bc2d9 Refactoring 2022-06-08 09:24:06 -06:00
Michael Bucari-Tovo
30ba69eca7 Minor refactoring. 2022-06-08 08:52:25 -06:00
Michael Bucari-Tovo
cf1bc1c252 By defauly, only get actual books and not parents from DB 2022-06-08 08:40:25 -06:00
Michael Bucari-Tovo
ee109ba67d Refactor 2022-06-08 08:39:59 -06:00
Michael Bucari-Tovo
9c6211e8e0 Improve UI speed when adding many books to queue at once. 2022-06-08 08:39:17 -06:00
Michael Bucari-Tovo
0729e4ab09 Minor refactor 2022-06-07 15:41:33 -06:00
Michael Bucari-Tovo
5cbe728631 Don't add series parents to list 2022-06-07 15:32:49 -06:00
Michael Bucari-Tovo
920f4df213 Use new ContentType.Parent to add series info to grid display 2022-06-07 15:28:16 -06:00
Michael Bucari-Tovo
c48eacd9af Add ContentType.Parent
Import Series parent when only individual episodes are in library
2022-06-07 15:27:18 -06:00
Michael Bucari-Tovo
30e6deeeaa Add migration to cleans DB of 7.10.1 hack 2022-06-07 15:25:52 -06:00
Robert McRackan
5bc76a3160 New debugging tool: "Hangover". Will be packaged with all releases 2022-06-01 11:49:30 -04:00
Robert McRackan
114925ebce Global exception handling. Threadsafe MessageBoxAlertAdminDialog 2022-05-27 13:38:28 -04:00
Robert McRackan
5a80a0cc06 Second bug fix for issue 263 2022-05-27 07:15:15 -04:00
rmcrackan
aebefac7e6 Merge pull request #266 from Mbucari/master
Fix my own screwup
2022-05-27 07:10:02 -04:00
Michael Bucari-Tovo
b2d0ee41f2 Fix my own screwup 2022-05-26 21:26:56 -06:00
Robert McRackan
9c20250b0a increm ver 2022-05-26 21:10:12 -04:00
rmcrackan
b196836fca Merge pull request #264 from Mbucari/master
Fix for episodes with no series link
2022-05-26 20:41:55 -04:00
Michael Bucari-Tovo
d9fbcc615a Change flow 2022-05-26 18:06:44 -06:00
Michael Bucari-Tovo
fb247fb33f Add better handling for parents and series with no children. 2022-05-26 17:29:55 -06:00
Michael Bucari-Tovo
61f4dbd896 No need to make a new list. 2022-05-26 16:50:43 -06:00
Michael Bucari-Tovo
2c86571818 Better identification of Chilv vs Parent from SeriesBook.Order 2022-05-26 16:49:03 -06:00
Michael Bucari-Tovo
1b2ec67726 Add series info for parent will null order. 2022-05-26 16:43:56 -06:00
Michael Bucari-Tovo
845af854bd Add exception handling to products display 2022-05-26 16:29:40 -06:00
Mbucari
15b6a66d98 Merge branch 'rmcrackan:master' into master 2022-05-26 16:13:13 -06:00
Michael Bucari-Tovo
c95ba0764b Fix bug and add groundwork for future feature 2022-05-26 16:11:52 -06:00
Robert McRackan
42c0648ba7 Bug fix #262 : 'file not found' after moved dir 2022-05-26 16:11:03 -04:00
Robert McRackan
0a6e55dcb7 * Much faster library scans
* Libraries of unlimited size now supported (prev limit was 10k)
2022-05-26 11:33:05 -04:00
rmcrackan
99b77decff Merge pull request #260 from Mbucari/master
Throttle episode scanning to 10 concurrent scans.
2022-05-26 11:29:18 -04:00
Robert McRackan
9e2ca4e586 update dependencies 2022-05-26 10:45:57 -04:00
Michael Bucari-Tovo
2e8acfdeef Limnit episode concurrency to 5 2022-05-26 08:44:39 -06:00
Michael Bucari-Tovo
630096e06d Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-26 08:43:33 -06:00
Michael Bucari-Tovo
d92d892dc7 logging 2022-05-25 20:45:18 -06:00
Michael Bucari-Tovo
a8f41841bd Throttle episode scanning to 10 concurrent scans. 2022-05-25 20:43:12 -06:00
rmcrackan
76954b5a0a Merge pull request #258 from Mbucari/master
Add support for unlimited library size.
2022-05-25 22:00:22 -04:00
Michael Bucari-Tovo
c57b184a09 Remove test params 2022-05-25 16:51:25 -06:00
Michael Bucari-Tovo
20ca4e0739 Refactor for clarity. 2022-05-25 16:49:22 -06:00
Mbucari
a972ed5e2e Merge branch 'rmcrackan:master' into master 2022-05-25 16:05:31 -06:00
Michael Bucari-Tovo
2b15bc6ebb Count Items as they come in and log total. 2022-05-25 15:11:38 -06:00
Robert McRackan
f7a482659c New feature #241 : Auto download episodes after scanning. Setting is on Import Library tab 2022-05-25 15:21:28 -04:00
Robert McRackan
99527453a7 add TODO 2022-05-25 12:56:34 -04:00
Robert McRackan
3408b4637c search engine cleanup 2022-05-25 12:49:24 -04:00
Robert McRackan
3f2899e97e * New event SearchEngineCommands.SearchEngineUpdated
* Clean up redundant event notifications
2022-05-25 10:09:27 -04:00
Michael Bucari-Tovo
562496cfaa Add more logging 2022-05-24 21:36:56 -06:00
Michael Bucari-Tovo
8283f19d6b Parallelize getChildEpisodesAsync 2022-05-24 21:17:59 -06:00
Michael Bucari-Tovo
242909b542 Don't import empty episode 2022-05-24 18:39:47 -06:00
Michael Bucari-Tovo
a7b83ad5e0 Remove 10,000 book limitation and simplify episode import 2022-05-24 18:27:20 -06:00
Michael Bucari-Tovo
ed66019d9a Cleanup 2022-05-24 18:24:53 -06:00
Michael Bucari-Tovo
bc0009be6c Use event return value instead of passing a set delegate. 2022-05-24 15:47:30 -06:00
Michael Bucari-Tovo
c88f47eed4 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-24 15:34:09 -06:00
Michael Bucari-Tovo
59de048ced Error handling network error. 2022-05-24 15:33:52 -06:00
Robert McRackan
7987dfb819 Rename 'liberate visible' menu items. Similar names are error-prone 2022-05-24 15:45:56 -04:00
Robert McRackan
1b101106e7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-05-24 15:32:42 -04:00
rmcrackan
7b75955aec Merge pull request #257 from Mbucari/master
Fix hang on grid update
2022-05-24 15:32:29 -04:00
Michael Bucari-Tovo
8f5467e6ca Revert stupid change. 2022-05-24 13:30:39 -06:00
Michael Bucari-Tovo
28764f92b9 Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-24 13:16:56 -06:00
Michael Bucari-Tovo
777dfe4c62 Fix hang on grid update 2022-05-24 13:16:44 -06:00
Robert McRackan
0878a704d9 search engine: podcast and episode should allow plural 2022-05-24 15:07:53 -04:00
Robert McRackan
f880897542 Increm version 2022-05-24 14:04:53 -04:00
rmcrackan
b37472a954 Merge pull request #255 from Mbucari/master
Implemented Episode grouping and refactored ProductsGrid
2022-05-24 13:59:12 -04:00
Michael Bucari-Tovo
68735a45dd Change episode color 2022-05-24 11:52:33 -06:00
Michael Bucari-Tovo
e26deb9092 Address comments 2022-05-24 11:15:41 -06:00
Michael Bucari-Tovo
43d6ea82cd Change failure behavior to match previous implementation 2022-05-24 09:17:09 -06:00
Mbucari
db1aa495ac Merge branch 'rmcrackan:master' into master 2022-05-24 08:48:32 -06:00
Michael Bucari-Tovo
ee62d9ae8d Attempt to fix app hang on LogMe event 2022-05-24 07:36:17 -06:00
Robert McRackan
4001124cfa AudibleApi. Better logging around getting pdf url 2022-05-24 09:03:43 -04:00
Michael Bucari-Tovo
43a4d0d1d7 Cleanup 2022-05-23 22:24:45 -06:00
Michael Bucari-Tovo
632b432b7c Revert to old column indexing 2022-05-23 22:21:37 -06:00
Michael Bucari-Tovo
e778c7a59d Create GridView namespace 2022-05-23 21:34:43 -06:00
Michael Bucari-Tovo
d71cdecd35 Refactoring and addressing comments 2022-05-23 21:20:26 -06:00
Michael Bucari-Tovo
4a82541ffd Fix error while removing filter on a sorted binding list 2022-05-23 17:46:55 -06:00
Michael Bucari-Tovo
f29dff3386 Fix filtering bug 2022-05-23 17:22:02 -06:00
Michael Bucari-Tovo
718d21f6cb NotifyPropertyChanged series on update 2022-05-23 16:42:05 -06:00
Michael Bucari-Tovo
440550ded9 Add binding source at design time 2022-05-23 16:35:18 -06:00
Michael Bucari-Tovo
593fe57ea1 Refactor ProductsGrid 2022-05-23 15:29:26 -06:00
Michael Bucari-Tovo
e8a320dac9 Add grid categories 2022-05-22 20:00:41 -06:00
Michael Bucari-Tovo
3cb43e5d3e Improve display 2022-05-22 20:00:06 -06:00
Robert McRackan
f86bdba3c3 Test in-place upgrade 2022-05-20 16:26:58 -04:00
Robert McRackan
98c3940297 New feature ( #153 ): in-place upgrade 2022-05-20 16:20:28 -04:00
Robert McRackan
b9e789bbcf Merge branch 'master' of https://github.com/rmcrackan/Libation 2022-05-20 14:22:09 -04:00
Robert McRackan
a108846731 Creating migrations shouldn't use file with conflicting name 2022-05-20 14:19:58 -04:00
rmcrackan
0b4ce8d6e7 Merge pull request #254 from Mbucari/master
Update installer
2022-05-18 23:02:58 -04:00
Mbucari
42df61b7dd Merge branch 'rmcrackan:master' into master 2022-05-18 17:33:10 -06:00
Michael Bucari-Tovo
6b46fa4cbc Use package installer 2022-05-18 17:32:53 -06:00
Robert McRackan
c0762eba18 Minor bug fix 2022-05-18 14:54:48 -04:00
rmcrackan
036fb848e1 Merge pull request #253 from Mbucari/master
Revert that unnecessary change
2022-05-18 14:53:47 -04:00
Mbucari
7198ae9025 Merge branch 'rmcrackan:master' into master 2022-05-18 12:49:31 -06:00
Michael Bucari-Tovo
d2822b06aa Revert "restore the old functionality to the stoplight"
This reverts commit 3648de3a8d.
2022-05-18 12:48:57 -06:00
Robert McRackan
17feca28b9 increm version 2022-05-18 14:48:46 -04:00
rmcrackan
898d38cb6a Merge pull request #252 from Mbucari/master
Fixed Issue #251
2022-05-18 14:47:40 -04:00
Michael Bucari-Tovo
95a99a2f0b Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-18 12:35:18 -06:00
Michael Bucari-Tovo
29a1e8ad34 MP3 settings always applicable for converting existing m4b 2022-05-18 12:35:12 -06:00
Mbucari
19f3a4f266 Update DownloadDecryptBook.cs 2022-05-18 12:31:32 -06:00
Michael Bucari-Tovo
12ddbc308a Fixed multipart book hanging 2022-05-18 12:29:16 -06:00
Mbucari
999bc7604e Merge branch 'rmcrackan:master' into master 2022-05-18 09:47:23 -06:00
Michael Bucari-Tovo
3648de3a8d restore the old functionality to the stoplight 2022-05-18 09:47:09 -06:00
Robert McRackan
051fa0a28f Bug fix #250 : recent refactor introduced a race condition for db creation on initial install. Moved db creation to before all other init/config is called 2022-05-18 08:13:18 -04:00
Robert McRackan
72e667e825 update dependencies 2022-05-17 17:13:20 -04:00
Robert McRackan
5ed59b41b5 fix image scaling bug when when scanning 2022-05-17 16:38:49 -04:00
Robert McRackan
c7c0d1632e Improve how highlighted index works post-filtering 2022-05-17 12:59:30 -04:00
Robert McRackan
2dc73acd20 Bug fix: incomplete refresh 2022-05-17 09:53:23 -04:00
Robert McRackan
ed71668c48 Reverted -- I might have been too hasty removing the GridEntry events 2022-05-17 08:08:53 -04:00
Robert McRackan
801e154d15 post-refactor clean up 2022-05-17 07:56:34 -04:00
rmcrackan
a89b07394f Merge pull request #249 from Mbucari/master
Add FilterableSortableBindingList to handle filtering the DataGridView
2022-05-16 22:08:31 -04:00
Mbucari
982f9b7c58 Merge branch 'rmcrackan:master' into master 2022-05-16 16:38:03 -06:00
Michael Bucari-Tovo
789b9207b5 Use that fancy patterm matching 2022-05-16 15:49:02 -06:00
Michael Bucari-Tovo
133dbb7471 Update Dinah 2022-05-16 15:11:21 -06:00
Robert McRackan
5d3ec493cd update dependencies 2022-05-16 17:06:59 -04:00
Michael Bucari-Tovo
6d7f234497 Remove unnecessary base form 2022-05-16 14:32:59 -06:00
Michael Bucari-Tovo
29a50bb640 typo 2022-05-16 14:31:03 -06:00
Michael Bucari-Tovo
843fddabde Changes discussed in email 2022-05-16 14:27:34 -06:00
Michael Bucari-Tovo
109ce0dd1f overwrite cached state 2022-05-16 14:26:43 -06:00
Robert McRackan
42508a82a0 txt file rename 2022-05-16 16:04:53 -04:00
Michael Bucari-Tovo
d860d39f5f Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-16 13:16:58 -06:00
Michael Bucari-Tovo
15396c611a Add documentation 2022-05-16 13:16:50 -06:00
Mbucari
41c4b12ae1 Merge branch 'rmcrackan:master' into master 2022-05-16 13:16:25 -06:00
Michael Bucari-Tovo
e51c30462f Revert "Use new ProcessQueue"
This reverts commit 9b5df99a61.
2022-05-16 13:16:11 -06:00
Michael Bucari-Tovo
9b5df99a61 Use new ProcessQueue 2022-05-16 12:56:15 -06:00
Michael Bucari-Tovo
3535156ea5 Edit 2022-05-16 12:47:50 -06:00
Robert McRackan
577145096d * GridEntry.DownloadBook is no longer called. it was the only one calling UpdateLiberatedStatus(true) or using DownloadInProgress flag
* cleaned up unused code, old forms, ProcessorAutomationController...
* what's left of LogMe and ProcessorAutomationController should be moved eventually
2022-05-16 14:44:17 -04:00
Michael Bucari-Tovo
89059510fd More logical naming 2022-05-16 12:37:56 -06:00
Michael Bucari-Tovo
aabc14c639 Make AllItems a method 2022-05-16 12:12:34 -06:00
Michael Bucari-Tovo
c28872544c Don't call concat for every book. 2022-05-16 12:10:54 -06:00
Michael Bucari-Tovo
7b8a4e4d72 Simplify filtering 2022-05-16 12:06:56 -06:00
Michael Bucari-Tovo
5dcdf670be Simplify RemoveFilter 2022-05-16 11:58:36 -06:00
Michael Bucari-Tovo
9721890a3c Update documentation 2022-05-16 11:50:11 -06:00
Michael Bucari-Tovo
1b9c4cfc23 Remove unused usings 2022-05-16 11:47:34 -06:00
Michael Bucari-Tovo
98a552e9af Optimization 2022-05-16 11:46:42 -06:00
Michael Bucari-Tovo
e1e265a101 Don't filter after every insert 2022-05-16 11:38:24 -06:00
Robert McRackan
b60a854de0 Formattable UI labels 2022-05-16 13:34:49 -04:00
Michael Bucari-Tovo
d1bddeccc8 Implement filtering in the sortable binding list. 2022-05-16 11:16:33 -06:00
Robert McRackan
0a106e64d8 liberate visible to use new process queue 2022-05-16 08:20:40 -04:00
Michael Bucari-Tovo
91d6181aec Better naming 2022-05-15 20:15:54 -06:00
Michael Bucari-Tovo
255c0a3359 Move filtering into SyncBindingSource 2022-05-15 19:58:59 -06:00
Robert McRackan
3a5ef999f0 Bug fix: fatal exception if no large picture 2022-05-15 15:38:36 -04:00
rmcrackan
983aa845d6 Merge pull request #247 from Mbucari/master
Fixed scaling issue
2022-05-15 15:21:05 -04:00
Robert McRackan
d1779726e6 New __ARCHITECTURE NOTES.txt incl. MVVM comments 2022-05-15 15:13:25 -04:00
Michael Bucari-Tovo
8e23062d0e Fix scaling for all display scalings 2022-05-15 13:12:46 -06:00
Michael Bucari-Tovo
7efbfffd99 Fixed scaling issue. 2022-05-15 12:47:39 -06:00
rmcrackan
ff4b2d2ecc Merge pull request #244 from Mbucari/master
New Processing Queue
2022-05-15 14:25:52 -04:00
Michael Bucari-Tovo
e079be0ad7 Make scrll look more natural when removing items from control 2022-05-15 11:27:49 -06:00
Michael Bucari-Tovo
a8a54aa443 Revert "Make scrll look more natural when removing items from control"
This reverts commit 88cbcf6baf.
2022-05-15 11:26:07 -06:00
Michael Bucari-Tovo
88cbcf6baf Make scrll look more natural when removing items from control 2022-05-15 11:25:33 -06:00
Michael Bucari-Tovo
8d6d26c9d2 Improve logging 2022-05-15 11:16:41 -06:00
Michael Bucari-Tovo
a490df0f7e Fix possible index range error 2022-05-15 11:10:37 -06:00
Michael Bucari-Tovo
a46041c958 More useful logging 2022-05-15 09:58:36 -06:00
Michael Bucari-Tovo
0a6a78bc58 Revert "More useful logging"
This reverts commit c9e850515e.
2022-05-15 09:56:56 -06:00
Michael Bucari-Tovo
c9e850515e More useful logging 2022-05-15 09:56:46 -06:00
Michael Bucari-Tovo
0ff8da2cf0 Add await and make cancel async 2022-05-15 09:30:44 -06:00
Michael Bucari-Tovo
c0ef3ccbea Tiny bugfix 2022-05-15 09:00:52 -06:00
Michael Bucari-Tovo
1ab628dee8 Better invocation. Post instead of Send 2022-05-14 23:45:13 -06:00
Michael Bucari-Tovo
b24df24b10 Detect Conversion cancelled 2022-05-14 23:44:15 -06:00
Michael Bucari-Tovo
341678d979 Remove my testing code. oops. 2022-05-14 20:47:13 -06:00
Michael Bucari-Tovo
49d10273a6 Add button to hide queue 2022-05-14 20:44:53 -06:00
Michael Bucari-Tovo
5b05c018d5 Remove ValidationFail books from queue display. Nothing to see there. 2022-05-14 20:00:23 -06:00
Michael Bucari-Tovo
d18d8c0ba4 Filter 2022-05-14 16:23:34 -06:00
Michael Bucari-Tovo
84a8fb0074 Minor refactor 2022-05-14 16:13:19 -06:00
Michael Bucari-Tovo
a40fb7f4bd Colorsto variables 2022-05-14 15:57:36 -06:00
Michael Bucari-Tovo
84eb3a3508 Remove debug button 2022-05-14 15:33:06 -06:00
Michael Bucari-Tovo
73a5d76503 Make thread safe and integrate with Libation UI 2022-05-14 14:39:46 -06:00
Michael Bucari-Tovo
50c35ed519 Change log to gridview and new INotifyPropertyChanged event 2022-05-14 13:52:54 -06:00
Michael Bucari-Tovo
a7b7e3efea Converted to INotifyPropertyChanged for more targeted view update 2022-05-14 13:52:10 -06:00
Michael Bucari-Tovo
88e892196f Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-14 11:20:30 -06:00
Michael Bucari-Tovo
7f08da96bb Documentation and organization 2022-05-14 11:20:19 -06:00
Mbucari
193f24768e Merge branch 'rmcrackan:master' into master 2022-05-14 11:15:23 -06:00
Michael Bucari-Tovo
a8bca3de98 Fix progressbar wiggling 2022-05-14 11:11:51 -06:00
Michael Bucari-Tovo
9692a802d0 Update documentation and add parameters 2022-05-14 11:11:20 -06:00
Robert McRackan
28a8b2e685 Revert: only call notifyPropertyChanged if actually set to new value 2022-05-14 12:34:01 -04:00
Michael Bucari-Tovo
3c9121b4af Improve scroll visualization 2022-05-14 10:03:57 -06:00
Michael Bucari-Tovo
dec1035258 Minor UI tweak 2022-05-14 04:11:44 -06:00
Michael Bucari-Tovo
9d81c86c1b Increase buffer size 2022-05-14 04:10:54 -06:00
Michael Bucari-Tovo
eeb4f4681a Saved 2022-05-14 03:16:48 -06:00
Michael Bucari-Tovo
676af0210b Finalized ProcessBookControl 2022-05-14 02:54:32 -06:00
Michael Bucari-Tovo
77c6a2890b Finalized VirtualFlowControl 2022-05-14 02:54:09 -06:00
Michael Bucari-Tovo
c39e748749 Finialized TrackedQueue 2022-05-14 01:33:05 -06:00
Mbucari
36e5a6ac8d Merge branch 'rmcrackan:master' into master 2022-05-13 21:00:56 -06:00
Robert McRackan
9bdcaa5eaa only call notifyPropertyChanged if actually set to new value 2022-05-13 16:30:46 -04:00
Mbucari
5511004db8 Merge branch 'rmcrackan:master' into master 2022-05-13 14:09:29 -06:00
Robert McRackan
0e46cdb514 refactor Form1. too much in 1 file 2022-05-13 13:39:49 -04:00
Mbucari
b028899949 Merge branch 'rmcrackan:master' into master 2022-05-13 11:12:37 -06:00
Robert McRackan
55285427f1 Add series in default search 2022-05-13 11:42:08 -04:00
Michael Bucari-Tovo
763a6cb31a Added VirtualFlowControl and BookQueue 2022-05-13 00:21:41 -06:00
Robert McRackan
24cb1aa84f remove legacy references 2022-05-12 13:35:08 -04:00
rmcrackan
886aa4938d Merge pull request #243 from Mbucari/master
Minor mods for future UI changes
2022-05-12 13:30:30 -04:00
Michael Bucari-Tovo
8871651549 Change namespace/folder name 2022-05-12 11:17:14 -06:00
Michael Bucari-Tovo
2ae8ef87d9 Remove unised 2022-05-12 11:06:53 -06:00
Michael Bucari-Tovo
de4fbe05f7 Remove old holdover 2022-05-12 11:01:41 -06:00
Michael Bucari-Tovo
b8abed37c2 Merged popout with main brainch. 2022-05-12 10:52:55 -06:00
Mbucari
255e26435c Merge branch 'rmcrackan:master' into master 2022-05-12 10:32:05 -06:00
Michael Bucari-Tovo
9e0550619b Merging 2022-05-12 10:31:50 -06:00
Michael Bucari-Tovo
5c171fd0f0 Merging 2022-05-12 10:29:44 -06:00
Michael Bucari-Tovo
3dd3b710b7 merge fix 2022-05-12 10:17:29 -06:00
Robert McRackan
bce3bdba7e Feature requests #229 , #148 : Bulk actions on filtered books 2022-05-12 11:57:56 -04:00
Robert McRackan
360f077da3 * fixed where the filter was being called multiple times on launch
* simplified productsGrid init means a lot of defensive code is no longer needed
2022-05-12 10:28:30 -04:00
Robert McRackan
75c5f662dc * Batch actions for visible books: 'remove from library' complete
* refactor entity properties into extension methods
* refactor shared simple message boxes => MessageBoxLib
2022-05-12 09:53:21 -04:00
Michael Bucari-Tovo
3c0485cfa9 Prepare Form1 for new docking queue 2022-05-12 00:10:13 -06:00
Michael Bucari-Tovo
d5ba405de0 MatchCurrent 2022-05-11 23:44:33 -06:00
Michael Bucari-Tovo
71b8bca86d Revert "Revert "Changes for dockable process""
This reverts commit 6d6434b4d4.
2022-05-11 21:12:18 -06:00
Mbucari
c53b9eabd6 Merge branch 'rmcrackan:master' into master 2022-05-11 21:11:44 -06:00
Michael Bucari-Tovo
6d6434b4d4 Revert "Changes for dockable process"
This reverts commit a447e88b86.
2022-05-11 21:11:25 -06:00
Michael Bucari-Tovo
a447e88b86 Changes for dockable process 2022-05-11 21:10:02 -06:00
Michael Bucari-Tovo
e2d2e00913 Fix conflict? 2022-05-11 21:07:32 -06:00
Michael Bucari-Tovo
cbfea37b3a Fix conflict 2022-05-11 21:06:01 -06:00
Michael Bucari-Tovo
d6de647974 Fix conflict 2022-05-11 21:04:11 -06:00
Robert McRackan
b784bd6b8d Batch actions for visible books: LIberate complete 2022-05-11 22:16:15 -04:00
Robert McRackan
00df6da366 'visible books' only enabled when applicable 2022-05-11 21:47:48 -04:00
Michael Bucari-Tovo
dad36c73e5 Expose grid entries 2022-05-11 18:45:57 -06:00
Michael Bucari-Tovo
936a1d60a0 Minor mods for future UI changes 2022-05-11 18:36:48 -06:00
rmcrackan
e0248c2d8e Merge pull request #242 from Mbucari/master
Add option to download cover art & full-size cover art viewer
2022-05-11 10:53:11 -04:00
Robert McRackan
b12731e3d5 resolve merge conflict 2022-05-11 10:52:46 -04:00
Robert McRackan
9636aca47c update grid-visible in main form 2022-05-11 10:50:19 -04:00
Robert McRackan
4138183352 improve grid 'visible' 2022-05-11 10:49:41 -04:00
Robert McRackan
c3871d3bca * Bug fix: grid doesn't update correctly if all books are removed
* Beginning (incomplete) new menu for batch actions on visible books
2022-05-11 10:13:07 -04:00
Michael Bucari-Tovo
dd8b0783a9 Address comments 2022-05-10 20:43:07 -06:00
Michael Bucari-Tovo
9a50aa4c7c Start task earlier. 2022-05-10 16:11:44 -06:00
Michael Bucari-Tovo
c40185030f Merge branch 'master' of https://github.com/Mbucari/Libation 2022-05-10 16:11:09 -06:00
Michael Bucari-Tovo
7cba28019c Remove lambda body 2022-05-10 16:10:17 -06:00
Mbucari
926f8a957e Merge branch 'rmcrackan:master' into master 2022-05-10 16:03:52 -06:00
Michael Bucari-Tovo
59aeaf24e4 Full-size cover picture viewer 2022-05-10 16:03:02 -06:00
Michael Bucari-Tovo
64eaa157e5 Add option for downloading cover 2022-05-10 15:36:31 -06:00
Michael Bucari-Tovo
9a5d9f3867 Move cover art downloader to DownloadDecryptBook 2022-05-10 15:25:47 -06:00
217 changed files with 16289 additions and 7968 deletions

View File

@@ -27,7 +27,7 @@ To make upgrades and reinstalls easier, Libation separates all of its responsibi
### Linux and Mac
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592))
Although Libation only currently officially supports Windows, some users have had success with WINE. ([Linux](https://github.com/rmcrackan/Libation/issues/28#issuecomment-890594158), [OSX Crossover and WINE](https://github.com/rmcrackan/Libation/issues/150#issuecomment-1004918592), [Linux and WINE](https://github.com/rmcrackan/Libation/issues/28#issuecomment-1161111014))
### Settings

View File

@@ -40,7 +40,7 @@ Libation's advanced searching is built on the powerful Lucene search engine. Sim
* Full official guide: https://lucene.apache.org/core/2_9_4/queryparsersyntax.html
* Tons of search fields, specific to audiobooks
* Synonyms so you don't have to memorize magic words. Eg: author and author**s** will both work
* Click [?] button for a full list of search fields and synonyms ![Filter options](images/FilterOptions.png)
* Click [?] button for a full list of search fields and synonyms ![Filter options](images/FilterOptionsButton.png)
* Search by tag like \[this\]
* When tags have an underscore you can use part of the tag. This is useful for quick categories. The below examples make this more clear.

View File

@@ -66,4 +66,4 @@
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
I made this for myself and I want to share it with the great programming and audible/audiobook communiites which have been so generous with their time and help.
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.

View File

@@ -5,8 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean" Version="0.4.6" />
<PackageReference Include="AAXClean.Codecs" Version="0.2.7" />
<PackageReference Include="AAXClean.Codecs" Version="0.2.10" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,4 +1,5 @@
using System;
using System.Threading.Tasks;
using AAXClean;
using Dinah.Core.Net.Http;
@@ -10,8 +11,8 @@ namespace AaxDecrypter
protected AaxFile AaxFile;
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic) { }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
@@ -109,10 +110,11 @@ namespace AaxDecrypter
});
}
public override void Cancel()
public override async Task CancelAsync()
{
IsCanceled = true;
AaxFile?.Cancel();
if (AaxFile != null)
await AaxFile.CancelAsync();
AaxFile?.Dispose();
CloseInputFileStream();
}

View File

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

View File

@@ -1,55 +1,98 @@
using System;
using System.IO;
using System.Threading.Tasks;
using AAXClean;
using AAXClean.Codecs;
using Dinah.Core.StepRunner;
using FileManager;
namespace AaxDecrypter
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
protected override StepSequence Steps { get; }
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + DownloadOptions.OutputFormat,
public override async Task<bool> RunAsync()
{
try
{
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsSingleFile,
["Step 3: Create Cue"] = Step_CreateCue,
["Step 4: Cleanup"] = Step_Cleanup,
};
}
//Step 1
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
else
{
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
return false;
}
private bool Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
//Step 2
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
if (await Step_DownloadAudiobookAsSingleFile())
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
return false;
}
FileUtility.SaferDelete(OutputFileName);
//Step 3
Serilog.Log.Information("Begin Step 3: Create Cue");
if (await Task.Run(Step_CreateCue))
Serilog.Log.Information("Completed Step 3: Create Cue");
else
{
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
return false;
}
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
//Step 4
Serilog.Log.Information("Begin Step 4: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 4: Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
return false;
}
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
var decryptionResult
= DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMp4a(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
: AaxFile.ConvertToMp3(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
}
DownloadOptions.ChapterInfo = AaxFile.Chapters;
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
Step_DownloadAudiobook_End(zeroProgress);
FileUtility.SaferDelete(OutputFileName);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
return success;
}
}
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
var decryptionResult
= DownloadOptions.OutputFormat == OutputFormat.M4b
? await AaxFile.ConvertToMp4aAsync(outputFile, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength)
: await AaxFile.ConvertToMp3Async(outputFile, DownloadOptions.LameConfig, DownloadOptions.ChapterInfo, DownloadOptions.TrimOutputToChapterLength);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
DownloadOptions.ChapterInfo = AaxFile.Chapters;
Step_DownloadAudiobook_End(zeroProgress);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
return success;
}
}
}

View File

@@ -1,9 +1,9 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Dinah.Core;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
namespace AaxDecrypter
@@ -20,42 +20,41 @@ namespace AaxDecrypter
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
protected bool IsCanceled { get; set; }
public bool IsCanceled { get; set; }
public string TempFilePath { get; }
protected string OutputFileName { get; private set; }
protected DownloadOptions DownloadOptions { get; }
protected IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
protected abstract StepSequence Steps { get; }
private NetworkFileStreamPersister nfsPersister;
private string jsonDownloadState { get; }
public string TempFilePath { get; }
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadOptions dlLic)
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
{
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
var outDir = Path.GetDirectoryName(OutputFileName);
if (!Directory.Exists(outDir))
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
Directory.CreateDirectory(outDir);
if (!Directory.Exists(cacheDirectory))
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
}
}
public abstract void Cancel();
public abstract Task CancelAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
@@ -63,15 +62,7 @@ namespace AaxDecrypter
OnRetrievedCoverArt(coverArt);
}
public bool Run()
{
var (IsSuccess, Elapsed) = Steps.Run();
if (!IsSuccess)
Serilog.Log.Logger.Error("Conversion failed");
return IsSuccess;
}
public abstract Task<bool> RunAsync();
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);
@@ -79,10 +70,8 @@ namespace AaxDecrypter
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
@@ -104,7 +93,7 @@ namespace AaxDecrypter
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
path = FileUtility.GetValidFilename(path);
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters);
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
OnFileCreated(path);
}

View File

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

View File

@@ -0,0 +1,24 @@
using AAXClean;
namespace AaxDecrypter
{
public interface IDownloadOptions
{
FileManager.ReplacementCharacters ReplacementCharacters { get; }
string DownloadUrl { get; }
string UserAgent { get; }
string AudibleKey { get; }
string AudibleIV { get; }
OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
ChapterInfo ChapterInfo { get; set; }
NAudio.Lame.LameConfig LameConfig { get; set; }
bool Downsample { get; set; }
bool MatchSourceBitrate { get; set; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitleName(MultiConvertFileProperties props);
}
}

View File

@@ -11,15 +11,5 @@ namespace AaxDecrypter
public int PartsTotal { get; set; }
public string Title { get; set; }
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
{
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
return fileNamingTemplate.GetFilePath();
}
}
}

View File

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

View File

@@ -1,33 +1,69 @@
using System;
using System.Threading;
using System.Threading.Tasks;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
namespace AaxDecrypter
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
{
protected override StepSequence Steps { get; }
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, DownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic) { }
public override async Task<bool> RunAsync()
{
Steps = new StepSequence
try
{
Name = "Download Mp3 Audiobook",
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
["Step 3: Create Cue"] = Step_CreateCue,
["Step 4: Cleanup"] = Step_Cleanup,
};
//Step 1
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
if (await Task.Run(Step_GetMetadata))
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
else
{
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
return false;
}
//Step 2
Serilog.Log.Information("Begin Step 2: Download Audiobook");
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
Serilog.Log.Information("Completed Step 2: Download Audiobook");
else
{
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
return false;
}
//Step 3
Serilog.Log.Information("Begin Step 3: Cleanup");
if (await Task.Run(Step_Cleanup))
Serilog.Log.Information("Completed Step 3: Cleanup");
else
{
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
return false;
}
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return true;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
return false;
}
}
public override void Cancel()
public override Task CancelAsync()
{
IsCanceled = true;
CloseInputFileStream();
return Task.CompletedTask;
}
protected bool Step_GetMetadata()
@@ -65,8 +101,8 @@ namespace AaxDecrypter
}
CloseInputFileStream();
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName);
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
SetOutputFileName(realOutputFileName);
OnFileCreated(realOutputFileName);

View File

@@ -3,7 +3,7 @@
<PropertyGroup>
<TargetFramework>net6.0-windows</TargetFramework>
<Version>7.4.0.1</Version>
<Version>8.1.2.1</Version>
</PropertyGroup>
<ItemGroup>
@@ -15,6 +15,7 @@
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>

View File

@@ -3,11 +3,12 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json.Linq;
using Serilog;
@@ -36,7 +37,8 @@ namespace AppScaffolding
public static Configuration RunPreConfigMigrations()
{
// must occur before access to Configuration instance
Migrations.migrate_to_v5_2_0__pre_config();
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
//***********************************************//
// //
@@ -57,6 +59,7 @@ namespace AppScaffolding
//
Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_from_7_10_1(config);
}
public static void PopulateMissingConfigValues(Configuration config)
@@ -114,6 +117,9 @@ namespace AppScaffolding
if (!config.Exists(nameof(config.DownloadEpisodes)))
config.DownloadEpisodes = true;
if (!config.Exists(nameof(config.ReplacementCharacters)))
config.ReplacementCharacters = FileManager.ReplacementCharacters.Default;
if (!config.Exists(nameof(config.FolderTemplate)))
config.FolderTemplate = Templates.Folder.DefaultTemplate;
@@ -123,6 +129,9 @@ namespace AppScaffolding
if (!config.Exists(nameof(config.ChapterFileTemplate)))
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
if (!config.Exists(nameof(config.ChapterTitleTemplate)))
config.ChapterTitleTemplate = Templates.ChapterTitle.DefaultTemplate;
if (!config.Exists(nameof(config.AutoScan)))
config.AutoScan = true;
@@ -134,14 +143,22 @@ namespace AppScaffolding
if (!config.Exists(nameof(config.GridColumnsWidths)))
config.GridColumnsWidths = new Dictionary<string, int>();
if (!config.Exists(nameof(config.DownloadCoverArt)))
config.DownloadCoverArt = true;
if (!config.Exists(nameof(config.AutoDownloadEpisodes)))
config.AutoDownloadEpisodes = false;
}
/// <summary>Initialize logging. Run after migration</summary>
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Configuration config)
{
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
wireUpSystemEvents(config);
}
private static void ensureSerilogConfig(Configuration config)
@@ -247,18 +264,21 @@ namespace AppScaffolding
private static void logStartupState(Configuration config)
{
#if DEBUG
var mode = "Debug";
#else
var mode = "Release";
#endif
if (System.Diagnostics.Debugger.IsAttached)
mode += " (Debugger attached)";
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
Log.Logger.Information("Begin. {@DebugInfo}", new
{
AppName = EntryAssembly.GetName().Name,
Version = BuildVersion.ToString(),
#if DEBUG
Mode = "Debug",
#else
Mode = "Release",
#endif
Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
@@ -279,22 +299,26 @@ namespace AppScaffolding
});
}
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
private static void wireUpSystemEvents(Configuration configuration)
{
(bool, string, string, string) isFalse = (false, null, null, null);
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
}
public static UpgradeProperties GetLatestRelease()
{
// timed out
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null)
return isFalse;
return null;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return isFalse;
return null;
// we're up to date
if (latestRelease <= BuildVersion)
return isFalse;
return null;
// we have an update
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
@@ -307,7 +331,7 @@ namespace AppScaffolding
zipUrl
});
return (true, zipUrl, latest.HtmlUrl, zip.Name);
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease);
}
private static Octokit.Release getLatestRelease(TimeSpan timeout)
{
@@ -338,41 +362,6 @@ namespace AppScaffolding
internal static class Migrations
{
#region migrate to v5.2.0
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
public static void migrate_to_v5_2_0__pre_config()
{
{
var settingsKey = "DownloadsInProgressEnum";
if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
{
UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
}
}
{
UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
}
{ // appsettings.json
var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
}
}
private static string translatePath(string path)
=> path switch
{
"AppDir" => @".\LibationFiles",
"MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
"UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
_ => path
};
#endregion
public static void migrate_to_v6_6_9(Configuration config)
{
var writeToPath = $"Serilog.WriteTo";
@@ -419,5 +408,74 @@ namespace AppScaffolding
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
public static void migrate_from_7_10_1(Configuration config)
{
var lastNigrationThres = config.GetNonString<bool>($"{nameof(migrate_from_7_10_1)}_ThrewError");
if (lastNigrationThres) return;
try
{
//https://github.com/rmcrackan/Libation/issues/270#issuecomment-1152863629
//This migration helps fix databases contaminated with the 7.10.1 hack workaround
//and those with improperly identified or missing series. This does not solve cases
//where individual episodes are in the db with a valid series link, but said series'
//parents have not been imported into the database. For those cases, Libation will
//attempt fixup by retrieving parents from the catalog endpoint
using var context = DbContexts.GetContext();
//This migration removes books and series with SERIES_ prefix that were created
//as a hack workaround in 7.10.1. Said workaround was removed in 7.10.2
string removeHackSeries = "delete " +
"from series " +
"where AudibleSeriesId like 'SERIES%'";
string removeHackBooks = "delete " +
"from books " +
"where AudibleProductId like 'SERIES%'";
//Detect series parents that were added to the database as books with ContentType.Episode,
//and change them to ContentType.Parent
string updateContentType =
"UPDATE books " +
"SET contenttype = 4 " +
"WHERE audibleproductid IN (SELECT books.audibleproductid " +
"FROM books " +
"INNER JOIN series " +
"ON ( books.audibleproductid = " +
"series.audibleseriesid) " +
"WHERE books.contenttype = 2)";
//Then detect series parents that were added to the database as books with ContentType.Parent
//but are missing a series link, and add the link (don't know how this happened)
string addMissingSeriesLink =
"INSERT INTO seriesbook " +
"SELECT series.seriesid, " +
"books.bookid, " +
"'- 1' " +
"FROM books " +
"LEFT OUTER JOIN seriesbook " +
"ON books.bookid = seriesbook.bookid " +
"INNER JOIN series " +
"ON books.audibleproductid = series.audibleseriesid " +
"WHERE books.contenttype = 4 " +
"AND seriesbook.seriesid IS NULL";
context.Database.ExecuteSqlRaw(removeHackSeries);
context.Database.ExecuteSqlRaw(removeHackBooks);
context.Database.ExecuteSqlRaw(updateContentType);
context.Database.ExecuteSqlRaw(addMissingSeriesLink);
LibraryCommands.SaveContext(context);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occurred while running database migrations in {0}", nameof(migrate_from_7_10_1));
config.SetObject($"{nameof(migrate_from_7_10_1)}_ThrewError", true);
}
}
}
}

View File

@@ -17,8 +17,13 @@ namespace AppScaffolding
///
///
/// </summary>
internal static class UNSAFE_MigrationHelper
public static class UNSAFE_MigrationHelper
{
public static string SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
#region appsettings.json
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
@@ -87,19 +92,11 @@ namespace AppScaffolding
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath
{
get
{
var success = APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value);
return !success || value is null ? null : Path.Combine(value, SETTINGS_JSON);
}
}
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static bool Settings_TryGet(string key, out string value)
@@ -267,5 +264,10 @@ namespace AppScaffolding
System.Threading.Thread.Sleep(100);
}
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}
}

View File

@@ -0,0 +1,6 @@
using System;
namespace AppScaffolding
{
public record UpgradeProperties(string ZipUrl, string HtmlUrl, string ZipName, Version LatestRelease);
}

View File

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

View File

@@ -27,10 +27,17 @@ namespace ApplicationServices
ScanEnd += (_, __) => Scanning = false;
}
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
lock (_lock)
{
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
@@ -83,6 +90,7 @@ namespace ApplicationServices
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
}
@@ -100,8 +108,8 @@ namespace ApplicationServices
{
if (Scanning)
return (0, 0);
ScanBegin?.Invoke(null, accounts.Length);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
@@ -118,6 +126,22 @@ namespace ApplicationServices
if (totalCount == 0)
return default;
Log.Logger.Information("Begin scan for orphaned episode parents");
var newParents = await findAndAddMissingParents(accounts);
Log.Logger.Information($"Orphan episode scan complete. New parents count {newParents}");
if (newParents >= 0)
{
//If any episodes are still orphaned, their series have been
//removed from the catalog and we'll never be able to find them.
//only do this if findAndAddMissingParents returned >= 0. If it
//returned < 0, an error happened and there's still a chance that
//a future successful run will find missing parents.
removedOrphanedEpisodes();
}
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
@@ -199,8 +223,8 @@ namespace ApplicationServices
using var context = DbContexts.GetContext();
var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = saveChanges(context);
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
// this is any changes at all to the database, not just new books
@@ -211,7 +235,85 @@ namespace ApplicationServices
return newCount;
}
private static int saveChanges(LibationContext context)
static void removedOrphanedEpisodes()
{
using var context = DbContexts.GetContext();
try
{
var orphanedEpisodes =
context
.GetLibrary_Flat_NoTracking(includeParents: true)
.FindOrphanedEpisodes();
context.LibraryBooks.RemoveRange(orphanedEpisodes);
context.Books.RemoveRange(orphanedEpisodes.Select(lb => lb.Book));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occurred while trying to remove orphaned episodes from the database");
}
}
static async Task<int> findAndAddMissingParents(Account[] accounts)
{
using var context = DbContexts.GetContext();
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
try
{
var orphanedEpisodes = library.FindOrphanedEpisodes().ToList();
if (!orphanedEpisodes.Any())
return -1;
var orphanedSeries =
orphanedEpisodes
.SelectMany(lb => lb.Book.SeriesLink)
.DistinctBy(s => s.Series.AudibleSeriesId)
.ToList();
// The Catalog endpoint does not require authentication.
var api = new ApiUnauthenticated(accounts[0].Locale);
var seriesParents = orphanedSeries.Select(o => o.Series.AudibleSeriesId).ToList();
var items = await api.GetCatalogProductsAsync(seriesParents, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
List<ImportItem> newParentsImportItems = new();
foreach (var sp in orphanedSeries)
{
var seriesItem = items.First(i => i.Asin == sp.Series.AudibleSeriesId);
if (seriesItem.Relationships is null)
continue;
var episode = orphanedEpisodes.First(l => l.Book.AudibleProductId == sp.Book.AudibleProductId);
seriesItem.PurchaseDate = new DateTimeOffset(episode.DateAdded);
seriesItem.Series = new AudibleApi.Common.Series[]
{
new AudibleApi.Common.Series{ Asin = seriesItem.Asin, Title = seriesItem.TitleWithSubtitle, Sequence = "-1"}
};
newParentsImportItems.Add(new ImportItem { DtoItem = seriesItem, AccountId = episode.Account, LocaleName = episode.Book.Locale });
}
var newCoutn = new LibraryBookImporter(context)
.Import(newParentsImportItems);
await context.SaveChangesAsync();
return newCoutn;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "An error occurred while trying to scan for orphaned episode parents.");
return -1;
}
}
public static int SaveContext(LibationContext context)
{
try
{
@@ -219,7 +321,7 @@ namespace ApplicationServices
}
catch (Microsoft.EntityFrameworkCore.DbUpdateException ex)
{
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culpret is the "WithExceptionDetails" serilog extension
// DbUpdateException exceptions can wreck serilog. Condense it until we can find a better solution. I suspect the culprit is the "WithExceptionDetails" serilog extension
static string format(Exception ex) => $"\r\nMessage: {ex.Message}\r\nStack Trace:\r\n{ex.StackTrace}";
@@ -253,22 +355,55 @@ namespace ApplicationServices
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange()
{
SearchEngineCommands.FullReIndex();
LibrarySizeChanged?.Invoke(null, null);
}
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
/// <summary>Occurs when books are added or removed from library</summary>
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
/// <summary>
/// Occurs when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/>
/// changed values are successfully persisted.
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler BookUserDefinedItemCommitted;
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
{
book.UserDefinedItem.BookStatus = bookStatus;
return UpdateUserDefinedItem(book);
}
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
{
book.UserDefinedItem.PdfStatus = pdfStatus;
return UpdateUserDefinedItem(book);
}
public static int UpdateBook(
this Book book,
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null)
=> UpdateBooks(tags, bookStatus, pdfStatus, book);
public static int UpdateBooks(
string tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
params Book[] books)
{
foreach (var book in books)
{
// blank tags are expected. null tags are not
if (tags is not null && book.UserDefinedItem.Tags != tags)
book.UserDefinedItem.Tags = tags;
if (bookStatus is not null && book.UserDefinedItem.BookStatus != bookStatus.Value)
book.UserDefinedItem.BookStatus = bookStatus.Value;
// even though PdfStatus is nullable, there's no case where we'd actually overwrite with null
if (pdfStatus is not null && book.UserDefinedItem.PdfStatus != pdfStatus.Value)
book.UserDefinedItem.PdfStatus = pdfStatus.Value;
}
return UpdateUserDefinedItem(books);
}
public static int UpdateUserDefinedItem(params Book[] books) => UpdateUserDefinedItem(books.ToList());
public static int UpdateUserDefinedItem(IEnumerable<Book> books)
{
@@ -284,23 +419,8 @@ namespace ApplicationServices
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
if (qtyChanges == 0)
return 0;
// semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
// I did not benchmark before choosing the number here
if (qtyChanges > 15)
SearchEngineCommands.FullReIndex();
else
{
foreach (var book in books)
{
SearchEngineCommands.UpdateLiberatedStatus(book);
SearchEngineCommands.UpdateBookTags(book);
}
}
BookUserDefinedItemCommitted?.Invoke(null, null);
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, books);
return qtyChanges;
}
@@ -314,7 +434,7 @@ namespace ApplicationServices
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists ? book.UserDefinedItem.BookStatus
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
@@ -323,7 +443,14 @@ namespace ApplicationServices
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
@@ -341,7 +468,7 @@ namespace ApplicationServices
var boolResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf)
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);

View File

@@ -111,13 +111,13 @@ namespace ApplicationServices
AudibleProductId = a.Book.AudibleProductId,
Locale = a.Book.Locale,
Title = a.Book.Title,
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
AuthorNames = a.Book.AuthorNames(),
NarratorNames = a.Book.NarratorNames(),
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames,
HasPdf = a.Book.HasPdf(),
SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating,
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
@@ -125,7 +125,7 @@ namespace ApplicationServices
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}") : "",
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,

View File

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

View File

@@ -1,10 +1,14 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Polly;
using Polly.Retry;
@@ -118,31 +122,38 @@ namespace AudibleUtilities
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes)
{
var items = new List<Item>();
#if DEBUG
//// this will not work for multi accounts
//var library_json = "library.json";
//library_json = System.IO.Path.GetFullPath(library_json);
//if (System.IO.File.Exists(library_json))
//{
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//}
#endif
Serilog.Log.Logger.Debug("Begin initial library scan");
Serilog.Log.Logger.Debug("Beginning library scan.");
if (!items.Any())
items = await Api.GetAllLibraryItemsAsync(libraryOptions);
List<Task<List<Item>>> getChildEpisodesTasks = new();
Serilog.Log.Logger.Debug("Initial library scan complete. Begin episode scan");
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
await manageEpisodesAsync(items, importEpisodes);
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
{
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
}
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
Serilog.Log.Logger.Debug("Episode scan complete");
count++;
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
//await and add all episodes from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
Serilog.Log.Logger.Debug("Completed library scan.");
#if DEBUG
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
#endif
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
@@ -156,56 +167,75 @@ namespace AudibleUtilities
}
#region episodes and podcasts
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
{
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
await concurrencySemaphore.WaitAsync();
try
{
// get parents
var parents = items.Where(i => i.IsEpisodes).ToList();
#if DEBUG
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText("parents.json", parentsDebug);
#endif
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
if (!parents.Any())
return;
List<Item> children;
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
items.RemoveAll(i => i.IsEpisodes);
if (importEpisodes)
if (parent.IsEpisodes)
{
// add children
var children = await getEpisodesAsync(parents);
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
items.AddRange(children);
//The 'parent' is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
children = new() { parent };
var parentAsins = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//and throw so we can figure out what to do about those special cases.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters =
{
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
var ex = new ApplicationException($"Found {numSeriesParents} parents for {parent.Asin}");
Serilog.Log.Logger.Error(ex, $"Episode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
throw ex;
}
var realParent = seriesParents.Single(p => p.IsSeriesParent);
realParent.PurchaseDate = parent.PurchaseDate;
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
parent = realParent;
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
}
}
private async Task<List<Item>> getEpisodesAsync(List<Item> parents)
{
var results = new List<Item>();
foreach (var parent in parents)
{
var children = await getEpisodeChildrenAsync(parent);
// actual individual episode, not the parent of a series.
// for now I'm keeping it inside this method since it fits the work flow, incl. importEpisodes logic
if (!children.Any())
else
{
results.Add(parent);
continue;
children = await getEpisodeChildrenAsync(parent);
if (!children.Any())
return new();
}
//A series parent will always have exactly 1 Series
parent.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
@@ -217,25 +247,22 @@ namespace AudibleUtilities
{
Asin = parent.Asin,
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin).Sort.ToString(),
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
// overload (read: abuse) IsEpisodes flag
child.Relationships = new Relationship[]
{
new Relationship
{
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode
}
};
}
results.AddRange(children);
}
children.Add(parent);
return results;
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children;
}
finally
{
concurrencySemaphore.Release();
}
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
@@ -261,8 +288,8 @@ namespace AudibleUtilities
{
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
#if DEBUG
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
#endif
}
catch (Exception ex)
@@ -277,7 +304,7 @@ namespace AudibleUtilities
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
@@ -295,7 +322,7 @@ namespace AudibleUtilities
if (childrenIds.Count != results.Count)
{
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
Serilog.Log.Logger.Error(ex, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
throw ex;
}

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="2.8.1.1" />
<PackageReference Include="AudibleApi" Version="4.2.2.1" />
</ItemGroup>
<ItemGroup>

View File

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

View File

@@ -12,13 +12,13 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.5">
<PackageReference Include="Dinah.EntityFrameworkCore" Version="4.1.1.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.5" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.5">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
@@ -29,7 +29,7 @@
</ItemGroup>
<ItemGroup>
<None Update="appsettings.json">
<None Update="migrate.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>

View File

@@ -16,8 +16,14 @@ namespace DataLayer
}
}
// enum will be easier than bool to extend later
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
// enum will be easier than bool to extend later.
public enum ContentType
{
Unknown = 0,
Product = 1,
Episode = 2,
Parent = 4,
}
public class Book
{
@@ -43,27 +49,10 @@ namespace DataLayer
// non-null. use "empty pattern"
internal int CategoryId { get; private set; }
public Category Category { get; private set; }
public string[] CategoriesNames
=> Category is null ? new string[0]
: Category.ParentCategory is null ? new[] { Category.Name }
: new[] { Category.ParentCategory.Name, Category.Name };
public string[] CategoriesIds
=> Category is null ? null
: Category.ParentCategory is null ? new[] { Category.AudibleCategoryId }
: new[] { Category.ParentCategory.AudibleCategoryId, Category.AudibleCategoryId };
public string TitleSortable => Formatters.GetSortName(Title);
public string SeriesSortable => Formatters.GetSortName(SeriesNames);
// is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; }
// UserDefinedItem convenience properties
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public bool Audio_Exists => UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
/// <summary>True if exists and IsLiberated. Else false</summary>
public bool PDF_Exists => UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
// is owned, not optional 1:1
/// <summary>The product's aggregate community rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0);
@@ -125,11 +114,7 @@ namespace DataLayer
.ToList();
public IEnumerable<Contributor> Authors => getContributions(Role.Author).Select(bc => bc.Contributor).ToList();
public string AuthorNames => string.Join(", ", Authors.Select(a => a.Name));
public IEnumerable<Contributor> Narrators => getContributions(Role.Narrator).Select(bc => bc.Contributor).ToList();
public string NarratorNames => string.Join(", ", Narrators.Select(n => n.Name));
public string Publisher => getContributions(Role.Publisher).SingleOrDefault()?.Contributor.Name;
public void ReplaceAuthors(IEnumerable<Contributor> authors, DbContext context = null)
@@ -185,30 +170,6 @@ namespace DataLayer
#region series
private HashSet<SeriesBook> _seriesLink;
public IEnumerable<SeriesBook> SeriesLink => _seriesLink?.ToList();
public string SeriesNames
{
get
{
if (_seriesLink is null)
return "";
// first: alphabetical by name
var withNames = _seriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.Name)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = _seriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
}
}
public void UpsertSeries(Series series, string order, DbContext context = null)
{
@@ -230,7 +191,6 @@ namespace DataLayer
#region supplements
private HashSet<Supplement> _supplements;
public IEnumerable<Supplement> Supplements => _supplements?.ToList();
public bool HasPdf => Supplements.Any();
public void AddSupplementDownloadUrl(string url)
{

View File

@@ -12,7 +12,7 @@ namespace DataLayer
public float StoryRating { get; private set; }
private Rating() { }
internal Rating(float overallRating, float performanceRating, float storyRating)
public Rating(float overallRating, float performanceRating, float storyRating)
{
OverallRating = overallRating;
PerformanceRating = performanceRating;
@@ -38,41 +38,6 @@ namespace DataLayer
yield return StoryRating;
}
public float FirstScore
=> OverallRating > 0 ? OverallRating
: PerformanceRating > 0 ? PerformanceRating
: StoryRating;
/// <summary>character: ★</summary>
const char STAR = '\u2605';
/// <summary>character: ½</summary>
const char HALF = '\u00BD';
string getStars(float score)
{
var fullStars = (int)Math.Floor(score);
var starString = "".PadLeft(fullStars, STAR);
if (score - fullStars == 0.5f)
starString += HALF;
return starString;
}
public string ToStarString()
{
var items = new List<string>();
if (OverallRating > 0)
items.Add($"Overall: {getStars(OverallRating)}");
if (PerformanceRating > 0)
items.Add($"Perform: {getStars(PerformanceRating)}");
if (StoryRating > 0)
items.Add($"Story: {getStars(StoryRating)}");
return string.Join("\r\n", items);
}
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
}
}

View File

@@ -0,0 +1,102 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
namespace DataLayer
{
public static class EntityExtensions
{
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title);
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public static bool Audio_Exists(this Book book) => book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
/// <summary>True if exists and IsLiberated. Else false</summary>
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames());
public static bool HasPdf(this Book book) => book.Supplements.Any();
public static string SeriesNames(this Book book)
{
if (book.SeriesLink is null)
return "";
// first: alphabetical by name
var withNames = book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.Name)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = book.SeriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
}
public static string[] CategoriesNames(this Book book)
=> book.Category is null ? new string[0]
: book.Category.ParentCategory is null ? new[] { book.Category.Name }
: new[] { book.Category.ParentCategory.Name, book.Category.Name };
public static string[] CategoriesIds(this Book book)
=> book.Category is null ? null
: book.Category.ParentCategory is null ? new[] { book.Category.AudibleCategoryId }
: new[] { book.Category.ParentCategory.AudibleCategoryId, book.Category.AudibleCategoryId };
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
{
if (libraryBooks is null || !libraryBooks.Any())
return "";
max = Math.Max(max, 1);
var titles = libraryBooks.Select(lb => "- " + lb.Book.Title).ToList();
var titlesAgg = titles.Take(max).Aggregate((a, b) => $"{a}\r\n{b}");
if (titles.Count == max + 1)
titlesAgg += $"\r\n\r\nand 1 other";
else if (titles.Count > max + 1)
titlesAgg += $"\r\n\r\nand {titles.Count - max } others";
return titlesAgg;
}
public static float FirstScore(this Rating rating)
=> rating.OverallRating > 0 ? rating.OverallRating
: rating.PerformanceRating > 0 ? rating.PerformanceRating
: rating.StoryRating;
public static string ToStarString(this Rating rating)
{
var items = new List<string>();
if (rating.OverallRating > 0)
items.Add($"Overall: {getStars(rating.OverallRating)}");
if (rating.PerformanceRating > 0)
items.Add($"Perform: {getStars(rating.PerformanceRating)}");
if (rating.StoryRating > 0)
items.Add($"Story: {getStars(rating.StoryRating)}");
return string.Join("\r\n", items);
}
/// <summary>character: ★</summary>
const char STAR = '\u2605';
/// <summary>character: ½</summary>
const char HALF = '\u00BD';
private static string getStars(float score)
{
var fullStars = (int)Math.Floor(score);
var starString = new string(STAR, fullStars);
if (score - fullStars >= 0.25f)
starString += HALF;
return starString;
}
}
}

View File

@@ -10,7 +10,7 @@ namespace DataLayer
// ========================
// these run against the db. linq queries against these MUST be translatable to sql. primatives only. no POCOs or this error occurs:
// Unable to create a constant value of type 'DataLayer.Contributor'. Only primitive types or enumeration types are supported in this context.
// to use full object-linq, load and use local
// to use full object-linq, load and use Local. HOWEVER, Local is only hashed/indexed on PK. All other searches are very slow
// load full table:
// List<Contributor> contributors = ...;
// Contributors.Load();

View File

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

View File

@@ -15,11 +15,13 @@ namespace DataLayer
// .GetLibrary()
// .ToList();
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context, bool includeParents = false)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibrary()
.AsEnumerable()
.Where(lb => !lb.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
@@ -40,5 +42,51 @@ namespace DataLayer
.Include(le => le.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.Include(le => le.Book).ThenInclude(b => b.ContributorsLink).ThenInclude(c => c.Contributor)
.Include(le => le.Book).ThenInclude(b => b.Category).ThenInclude(c => c.ParentCategory);
public static IEnumerable<LibraryBook> ParentedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks.Where(lb => lb.Book.IsEpisodeParent()).SelectMany(s => libraryBooks.FindChildren(s));
public static IEnumerable<LibraryBook> FindOrphanedEpisodes(this IEnumerable<LibraryBook> libraryBooks)
=> libraryBooks
.Where(lb => lb.Book.IsEpisodeChild())
.ExceptBy(
libraryBooks
.ParentedEpisodes()
.Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId);
#nullable enable
public static LibraryBook? FindSeriesParent(this IEnumerable<LibraryBook> libraryBooks, LibraryBook seriesEpisode)
{
if (seriesEpisode.Book.SeriesLink is null) return null;
try
{
//Parent books will always have exactly 1 SeriesBook due to how
//they are imported in ApiExtended.getChildEpisodesAsync()
return libraryBooks.FirstOrDefault(
lb =>
lb.Book.IsEpisodeParent() &&
seriesEpisode.Book.SeriesLink.Any(
s => s.Series.AudibleSeriesId == lb.Book.SeriesLink.Single().Series.AudibleSeriesId));
}
catch (System.Exception ex)
{
Serilog.Log.Error(ex, "Query error in {0}", nameof(FindSeriesParent));
return null;
}
}
#nullable disable
public static IEnumerable<LibraryBook> FindChildren(this IEnumerable<LibraryBook> bookList, LibraryBook parent)
=> bookList
.Where(
lb =>
lb.Book.IsEpisodeChild() &&
lb.Book.SeriesLink?
.Any(
s =>
s.Series.AudibleSeriesId == parent.Book.AudibleProductId
) == true
).ToList();
}
}

View File

@@ -1,6 +0,0 @@
{
"ConnectionStrings": {
"// 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

@@ -0,0 +1,5 @@
{
"ConnectionStrings": {
"LibationContext": "Data Source=LibationContext.db;Foreign Keys=False;"
}
}

View File

@@ -75,7 +75,7 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
var contentType = GetContentType(item);
// absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true)
@@ -101,7 +101,8 @@ namespace DtoImporterService
// absence of categories is also possible
// CATEGORY HACK: only use the 1st 2 categories
// (real impl: var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";)
// after we support full arbitrary-depth category trees and multiple categories per book, the real impl will be something like this
// var lastCategory = item.Categories.LastOrDefault()?.CategoryId ?? "";
var lastCategory
= item.Categories.Length == 0 ? ""
: item.Categories.Length == 1 ? item.Categories[0].CategoryId
@@ -184,5 +185,15 @@ namespace DtoImporterService
}
}
}
private static DataLayer.ContentType GetContentType(Item item)
{
if (item.IsEpisodes)
return DataLayer.ContentType.Episode;
else if (item.IsSeriesParent)
return DataLayer.ContentType.Parent;
else
return DataLayer.ContentType.Product;
}
}
}

View File

@@ -1,16 +1,44 @@
using System;
using LibationFileManager;
using NAudio.Lame;
using System;
using System.Threading.Tasks;
namespace FileLiberator
{
public abstract class AudioDecodable : Processable
{
public event EventHandler<Action<byte[]>> RequestCoverArt;
public delegate byte[] RequestCoverArtHandler(object sender, EventArgs eventArgs);
public event RequestCoverArtHandler RequestCoverArt;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageDiscovered;
public abstract void Cancel();
public abstract Task CancelAsync();
protected LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new();
lameConfig.Mode = MPEGMode.Mono;
if (config.LameTargetBitrate)
{
if (config.LameConstantBitrate)
lameConfig.BitRate = config.LameBitrate;
else
{
lameConfig.ABRRateKbps = config.LameBitrate;
lameConfig.VBR = VBRMode.ABR;
lameConfig.WriteVBRTag = true;
}
}
else
{
lameConfig.VBR = VBRMode.Default;
lameConfig.VBRQuality = config.LameVBRQuality;
lameConfig.WriteVBRTag = true;
}
return lameConfig;
}
protected void OnTitleDiscovered(string title) => OnTitleDiscovered(null, title);
protected void OnTitleDiscovered(object _, string title)
{
@@ -32,10 +60,10 @@ namespace FileLiberator
NarratorsDiscovered?.Invoke(this, narrators);
}
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
protected byte[] OnRequestCoverArt()
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
RequestCoverArt?.Invoke(this, setCoverArtDel);
return RequestCoverArt?.Invoke(this, new());
}
protected void OnCoverImageDiscovered(byte[] coverImage)

View File

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

View File

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

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
using AaxDecrypter;
using ApplicationServices;
using AudibleApi;
using DataLayer;
using Dinah.Core;
@@ -13,248 +14,254 @@ using LibationFileManager;
namespace FileLiberator
{
public class DownloadDecryptBook : AudioDecodable
{
private AudiobookDownloadBase abDownloader;
public class DownloadDecryptBook : AudioDecodable
{
public override string Name => "Download & Decrypt";
private AudiobookDownloadBase abDownloader;
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override void Cancel() => abDownloader?.Cancel();
public override Task CancelAsync() => abDownloader?.CancelAsync() ?? Task.CompletedTask;
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
var entries = new List<FilePathCache.CacheEntry>();
// these only work so minimally b/c CacheEntry is a record.
// in case of parallel decrypts, only capture the ones for this book id.
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
{
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
entries.Add(e);
}
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
{
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
entries.Remove(e);
}
OnBegin(libraryBook);
try
{
if (libraryBook.Book.Audio_Exists)
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
bool success = false;
try
{
FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook);
}
finally
{
FilePathCache.Inserted -= FilePathCache_Inserted;
FilePathCache.Removed -= FilePathCache_Removed;
}
// decrypt failed
if (!success)
{
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
FileUtility.SaferDelete(tmpFile.Path);
return new StatusHandler { "Decrypt failed" };
}
// moves new files from temp dir to final dest
var movedAudioFile = moveFilesToBooksDir(libraryBook, entries);
// decrypt failed
if (!movedAudioFile)
return new StatusHandler { "Cannot find final audio file after decryption" };
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
return new StatusHandler();
}
finally
{
OnCompleted(libraryBook);
}
}
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
{
OnStreamingBegin($"Begin decrypting {libraryBook}");
try
{
var config = Configuration.Instance;
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var audiobookDlLic = BuildDownloadOptions(config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, audiobookDlLic.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic);
else
{
AaxcDownloadConvertBase converter
= config.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
outFileName, cacheDir, audiobookDlLic,
AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook))
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic);
if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames);
abDownloader = converter;
}
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
abDownloader.RetrievedTitle += OnTitleDiscovered;
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
// REAL WORK DONE HERE
var success = await Task.Run(abDownloader.Run);
return success;
}
finally
{
OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
}
}
private static DownloadOptions BuildDownloadOptions(Configuration config, AudibleApi.Common.ContentLicense contentLic)
{
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
//I also assume that if DrmType != Adrm, the file will be an mp3.
//These assumptions may be wrong, and only time and bug reports will tell.
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
OutputFormat.Mp3 : OutputFormat.M4b;
var audiobookDlLic = new DownloadOptions
(
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
Resources.USER_AGENT
)
{
AudibleKey = contentLic?.Voucher?.Key,
AudibleIV = contentLic?.Voucher?.Iv,
OutputFormat = outputFormat,
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
RetainEncryptedFile = config.RetainAaxFile && encrypted,
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
CreateCueSheet = config.CreateCueSheet
};
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
{
long startMs = audiobookDlLic.TrimOutputToChapterLength ?
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
for (int i = 0; i < contentLic.ContentMetadata.ChapterInfo.Chapters.Length; i++)
{
var chapter = contentLic.ContentMetadata.ChapterInfo.Chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= startMs;
if (config.StripAudibleBrandAudio && i == contentLic.ContentMetadata.ChapterInfo.Chapters.Length - 1)
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
audiobookDlLic.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
}
NAudio.Lame.LameConfig lameConfig = new();
lameConfig.Mode = NAudio.Lame.MPEGMode.Mono;
if (config.LameTargetBitrate)
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
var entries = new List<FilePathCache.CacheEntry>();
// these only work so minimally b/c CacheEntry is a record.
// in case of parallel decrypts, only capture the ones for this book id.
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
{
if (config.LameConstantBitrate)
lameConfig.BitRate = config.LameBitrate;
else
{
lameConfig.ABRRateKbps = config.LameBitrate;
lameConfig.VBR = NAudio.Lame.VBRMode.ABR;
lameConfig.WriteVBRTag = true;
}
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
entries.Add(e);
}
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
{
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
entries.Remove(e);
}
OnBegin(libraryBook);
try
{
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
bool success = false;
try
{
FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(libraryBook);
}
finally
{
FilePathCache.Inserted -= FilePathCache_Inserted;
FilePathCache.Removed -= FilePathCache_Removed;
}
// decrypt failed
if (!success)
{
foreach (var tmpFile in entries.Where(f => f.FileType != FileType.AAXC))
FileUtility.SaferDelete(tmpFile.Path);
return abDownloader?.IsCanceled == true ?
new StatusHandler { "Cancelled" } :
new StatusHandler { "Decrypt failed" };
}
// moves new files from temp dir to final dest.
// This could take a few seconds if moving hundreds of files.
var movedAudioFile = await Task.Run(() => moveFilesToBooksDir(libraryBook, entries));
// decrypt failed
if (!movedAudioFile)
return new StatusHandler { "Cannot find final audio file after decryption" };
if (Configuration.Instance.DownloadCoverArt)
DownloadCoverArt(libraryBook);
libraryBook.Book.UpdateBookStatus(LiberatedStatus.Liberated);
return new StatusHandler();
}
finally
{
OnCompleted(libraryBook);
}
}
private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
{
var config = Configuration.Instance;
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var dlOptions = BuildDownloadOptions(libraryBook, config, contentLic);
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (contentLic.DrmType != AudibleApi.Common.DrmType.Adrm)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
else
{
lameConfig.VBR = NAudio.Lame.VBRMode.Default;
lameConfig.VBRQuality = config.LameVBRQuality;
lameConfig.WriteVBRTag = true;
}
AaxcDownloadConvertBase converter
= config.SplitFilesByChapter ?
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
audiobookDlLic.LameConfig = lameConfig;
if (config.AllowLibationFixup)
converter.RetrievedMetadata += (_, tags) => tags.Generes = string.Join(", ", libraryBook.Book.CategoriesNames());
return audiobookDlLic;
}
abDownloader = converter;
}
private static void downloadValidation(LibraryBook libraryBook)
{
string errorString(string field)
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
abDownloader.RetrievedTitle += OnTitleDiscovered;
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
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;
};
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
if (string.IsNullOrWhiteSpace(libraryBook.Account))
throw new Exception(errorString("Account"));
return success;
}
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
throw new Exception(errorString("Locale"));
}
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
{
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
//I also assume that if DrmType != Adrm, the file will be an mp3.
//These assumptions may be wrong, and only time and bug reports will tell.
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
{
if (e is not null)
OnCoverImageDiscovered(e);
else if (Configuration.Instance.AllowLibationFixup)
OnRequestCoverArt(abDownloader.SetCoverArt);
}
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
{
// create final directory. move each file into it
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
Directory.CreateDirectory(destinationDir);
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
OutputFormat.Mp3 : OutputFormat.M4b;
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
var dlOptions = new DownloadOptions
(
libraryBook,
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
Resources.USER_AGENT
)
{
AudibleKey = contentLic?.Voucher?.Key,
AudibleIV = contentLic?.Voucher?.Iv,
OutputFormat = outputFormat,
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
RetainEncryptedFile = config.RetainAaxFile && encrypted,
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
CreateCueSheet = config.CreateCueSheet,
LameConfig = GetLameOptions(config)
};
var chapters = getChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
if (config.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
{
long startMs = dlOptions.TrimOutputToChapterLength ?
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
dlOptions.ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(startMs));
for (int i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= startMs;
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
chapLenMs -= contentLic.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
}
}
return dlOptions;
}
private List<AudibleApi.Common.Chapter> getChapters(IEnumerable<AudibleApi.Common.Chapter> chapters)
{
List<AudibleApi.Common.Chapter> chaps = new();
foreach (var c in chapters)
{
if (c.Chapters is not null)
{
var firstSub = new AudibleApi.Common.Chapter
{
Title = $"{c.Title}: {c.Chapters[0].Title}",
StartOffsetMs = c.StartOffsetMs,
StartOffsetSec = c.StartOffsetSec,
LengthMs = c.LengthMs + c.Chapters[0].LengthMs
};
chaps.Add(firstSub);
var children = getChapters(c.Chapters[1..]);
foreach (var child in children)
child.Title = string.IsNullOrEmpty(c.Title) ? child.Title : $"{c.Title}: {child.Title}";
chaps.AddRange(children);
}
else
chaps.Add(c);
}
return chaps;
}
private static void downloadValidation(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 AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
{
if (e is not null)
OnCoverImageDiscovered(e);
else if (Configuration.Instance.AllowLibationFixup)
abDownloader.SetCoverArt(OnRequestCoverArt());
}
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>True if audiobook file(s) were successfully created and can be located on disk. Else false.</returns>
private static bool moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries)
{
// create final directory. move each file into it
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
Directory.CreateDirectory(destinationDir);
FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
if (getFirstAudio() == default)
return false;
@@ -263,10 +270,10 @@ namespace FileLiberator
{
var entry = entries[i];
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)), Configuration.Instance.ReplacementCharacters);
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
// propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
entries[i] = entry with { Path = realDest };
}
@@ -278,5 +285,34 @@ namespace FileLiberator
return true;
}
private void DownloadCoverArt(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
var coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
try
{
if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath);
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
(libraryBook.Book.PictureId, PictureSize.Native) :
(libraryBook.Book.PictureLarge, PictureSize.Native);
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
if (picBytes.Length > 0)
File.WriteAllBytes(coverPath, picBytes);
}
catch (Exception ex)
{
//Failure to download cover art should not be
//considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {libraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
}
}
}
}

View File

@@ -1,31 +0,0 @@
using System;
using System.Net.Http;
using System.Threading.Tasks;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
// currently only used to download the .zip flies for upgrade
public class DownloadFile : Streamable
{
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
{
var client = new HttpClient();
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
OnStreamingBegin(proposedDownloadFilePath);
try
{
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated("Upgrade", actualDownloadedFilePath);
return actualDownloadedFilePath;
}
finally
{
OnStreamingCompleted(proposedDownloadFilePath);
}
}
}
}

View File

@@ -0,0 +1,46 @@
using AaxDecrypter;
using AAXClean;
using Dinah.Core;
using DataLayer;
using LibationFileManager;
using FileManager;
namespace FileLiberator
{
public class DownloadOptions : IDownloadOptions
{
public LibraryBook LibraryBook { get; }
public LibraryBookDto LibraryBookDto { get; }
public string DownloadUrl { get; }
public string UserAgent { get; }
public string AudibleKey { get; init; }
public string AudibleIV { get; init; }
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
public bool TrimOutputToChapterLength { get; init; }
public bool RetainEncryptedFile { get; init; }
public bool StripUnabridged { get; init; }
public bool CreateCueSheet { get; init; }
public ChapterInfo ChapterInfo { get; set; }
public NAudio.Lame.LameConfig LameConfig { get; set; }
public bool Downsample { get; set; }
public bool MatchSourceBitrate { get; set; }
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
public string GetMultipartFileName(MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
public string GetMultipartTitleName(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
{
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
LibraryBookDto = LibraryBook.ToDto();
// no null/empty check for key/iv. unencrypted files do not have them
}
}
}

View File

@@ -4,6 +4,7 @@ using System.IO;
using System.Linq;
using System.Net.Http;
using System.Threading.Tasks;
using ApplicationServices;
using DataLayer;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
@@ -14,9 +15,10 @@ namespace FileLiberator
{
public class DownloadPdf : Processable
{
public override string Name => "Download Pdf";
public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !libraryBook.Book.PDF_Exists;
&& !libraryBook.Book.PDF_Exists();
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
@@ -28,8 +30,7 @@ namespace FileLiberator
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
libraryBook.Book.UpdatePdfStatus(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated);
return result;
}
@@ -56,27 +57,18 @@ namespace FileLiberator
private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
{
OnStreamingBegin(proposedDownloadFilePath);
var api = await libraryBook.GetApiAsync();
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
try
{
var api = await libraryBook.GetApiAsync();
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged);
var client = new HttpClient();
var client = new HttpClient();
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated(libraryBook, actualDownloadedFilePath);
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated(libraryBook, actualDownloadedFilePath);
OnStatusUpdate(actualDownloadedFilePath);
return actualDownloadedFilePath;
}
finally
{
OnStreamingCompleted(proposedDownloadFilePath);
}
OnStatusUpdate(actualDownloadedFilePath);
return actualDownloadedFilePath;
}
private static StatusHandler verifyDownload(string actualDownloadedFilePath)

View File

@@ -5,16 +5,22 @@ using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using LibationFileManager;
namespace FileLiberator
{
public abstract class Processable : Streamable
public abstract class Processable
{
public abstract string Name { get; }
public event EventHandler<LibraryBook> Begin;
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
public event EventHandler<string> StatusUpdate;
/// <summary>Fired when a file is successfully saved to disk</summary>
public event EventHandler<(string id, string path)> FileCreated;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public event EventHandler<LibraryBook> Completed;
@@ -28,7 +34,7 @@ namespace FileLiberator
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
=> library.Where(libraryBook =>
Validate(libraryBook)
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes)
&& (!libraryBook.Book.IsEpisodeChild() || Configuration.Instance.DownloadEpisodes)
);
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
@@ -48,38 +54,9 @@ namespace FileLiberator
= (await ProcessAsync(libraryBook))
?? new StatusHandler { "Processable should never return a null status" };
if (status.IsSuccess)
DownloadCoverArt(libraryBook);
return status;
}
private void DownloadCoverArt(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
var coverPath = FileManager.FileUtility.GetValidFilename(System.IO.Path.Combine(destinationDir, "Cover.jpg"), "", true);
if (System.IO.File.Exists(coverPath)) return;
try
{
(string picId, PictureSize size) = libraryBook.Book.PictureLarge is null ?
(libraryBook.Book.PictureId, PictureSize.Native) :
(libraryBook.Book.PictureLarge, PictureSize.Native);
var picBytes = PictureStorage.GetPictureSynchronously(new PictureDefinition(picId, size));
if (picBytes.Length > 0)
System.IO.File.WriteAllBytes(coverPath, picBytes);
}
catch (Exception ex)
{
//Failure to download cover art should not be
//considered a failure to download the book
Serilog.Log.Logger.Error(ex.Message);
}
}
public async Task<StatusHandler> TryProcessAsync(LibraryBook libraryBook)
=> Validate(libraryBook)
? await ProcessAsync(libraryBook)
@@ -97,6 +74,23 @@ namespace FileLiberator
StatusUpdate?.Invoke(this, statusUpdate);
}
protected void OnFileCreated(LibraryBook libraryBook, string path)
{
Serilog.Log.Logger.Information("File created {@DebugInfo}", new { Name = nameof(FileCreated), libraryBook.Book.AudibleProductId, path });
FilePathCache.Insert(libraryBook.Book.AudibleProductId, path);
FileCreated?.Invoke(this, (libraryBook.Book.AudibleProductId, path));
}
protected void OnStreamingProgressChanged(DownloadProgress progress)
=> OnStreamingProgressChanged(null, progress);
protected void OnStreamingProgressChanged(object _, DownloadProgress progress)
=> StreamingProgressChanged?.Invoke(this, progress);
protected void OnStreamingTimeRemaining(TimeSpan timeRemaining)
=> OnStreamingTimeRemaining(null, timeRemaining);
protected void OnStreamingTimeRemaining(object _, TimeSpan timeRemaining)
=> StreamingTimeRemaining?.Invoke(this, timeRemaining);
protected void OnCompleted(LibraryBook libraryBook)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });

View File

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

View File

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

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="4.2.0.1" />
<PackageReference Include="Dinah.Core" Version="4.4.1.1" />
<PackageReference Include="Polly" Version="7.2.3" />
</ItemGroup>

View File

@@ -1,64 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core;
using System.Text;
namespace FileManager
{
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
public class FileNamingTemplate
{
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: &lt;name&gt;</summary>
public string Template { get; }
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
public class FileNamingTemplate : NamingTemplate
{
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public FileNamingTemplate(string template) : base(template) { }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public FileNamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
/// <summary>Generate a valid path for this file or directory</summary>
public LongPath GetFilePath(ReplacementCharacters replacements, bool returnFirstExisting = false)
{
string fileName =
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
FileUtility.RemoveLastCharacter(Template) :
Template;
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
List<string> pathParts = new();
/// <summary>Convenience method</summary>
public void AddParameterReplacement(string key, object value)
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
=> ParameterReplacements.Add(key, value);
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
public int? ParameterMaxSize { get; set; } = 50;
while (!string.IsNullOrEmpty(fileName))
{
var file = Path.GetFileName(fileName);
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
public string IllegalCharacterReplacements { get; set; }
if (Path.IsPathRooted(Template) && file == string.Empty)
{
pathParts.Add(fileName);
break;
}
else
{
file = replaceFileName(file, paramReplacements);
fileName = Path.GetDirectoryName(fileName);
pathParts.Add(file);
}
}
/// <summary>Generate a valid path for this file or directory</summary>
public string GetFilePath(bool returnFirstExisting = false)
{
var filename = Template;
pathParts.Reverse();
foreach (var r in ParameterReplacements)
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
return FileUtility.GetValidFilename(Path.Join(pathParts.ToArray()), replacements, returnFirstExisting);
}
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements, returnFirstExisting);
}
private string replaceFileName(string filename, Dictionary<string,string> paramReplacements)
{
List<StringBuilder> filenameParts = new();
//Build the filename in parts, replacing replacement parameters with
//their values, and storing the parts in a list.
while (!string.IsNullOrEmpty(filename))
{
int openIndex = filename.IndexOf('<');
int closeIndex = filename.IndexOf('>');
private static string formatKey(string key)
=> key
.Replace("<", "")
.Replace(">", "");
if (openIndex == 0 && closeIndex > 0)
{
var key = filename[..(closeIndex + 1)];
private string formatValue(object value)
{
if (value is null)
return "";
if (paramReplacements.ContainsKey(key))
filenameParts.Add(new StringBuilder(paramReplacements[key]));
else
filenameParts.Add(new StringBuilder(key));
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
// Esp important for file templates.
var val = value
.ToString()
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
return
ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
? val.Truncate(ParameterMaxSize.Value)
: val;
}
}
filename = filename[(closeIndex + 1)..];
}
else if (openIndex > 0 && closeIndex > openIndex)
{
var other = filename[..openIndex];
filenameParts.Add(new StringBuilder(other));
filename = filename[openIndex..];
}
else
{
filenameParts.Add(new StringBuilder(filename));
filename = string.Empty;
}
}
//Remove 1 character from the end of the longest filename part until
//the total filename is less than max filename length
while (filenameParts.Sum(p => p.Length) > LongPath.MaxFilenameLength)
{
int maxLength = filenameParts.Max(p => p.Length);
var maxEntry = filenameParts.First(p => p.Length == maxLength);
maxEntry.Remove(maxLength - 1, 1);
}
return string.Join("", filenameParts);
}
private string formatValue(object value, ReplacementCharacters replacements)
{
if (value is null)
return "";
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
// Esp important for file templates.
return replacements.ReplaceInvalidFilenameChars(value.ToString());
}
}
}

View File

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

View File

@@ -0,0 +1,120 @@
using Newtonsoft.Json;
using System;
using System.Linq;
using System.Runtime.InteropServices;
using System.Text;
namespace FileManager
{
public class LongPath
{
//https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd
public const int MaxDirectoryLength = MaxPathLength - 13;
public const int MaxPathLength = short.MaxValue;
public const int MaxFilenameLength = 255;
private const int MAX_PATH = 260;
private const string LONG_PATH_PREFIX = "\\\\?\\";
public string Path { get; init; }
public override string ToString() => Path;
public static implicit operator LongPath(string path)
{
if (path is null) return null;
//File I/O functions in the Windows API convert "/" to "\" as part of converting
//the name to an NT-style name, except when using the "\\?\" prefix
path = path.Replace(System.IO.Path.AltDirectorySeparatorChar, System.IO.Path.DirectorySeparatorChar);
if (path.StartsWith(LONG_PATH_PREFIX))
return new LongPath { Path = path };
else if ((path.Length > 2 && path[1] == ':') || path.StartsWith("UNC\\"))
return new LongPath { Path = LONG_PATH_PREFIX + path };
else if (path.StartsWith("\\\\"))
//The "\\?\" prefix can also be used with paths constructed according to the
//universal naming convention (UNC). To specify such a path using UNC, use
//the "\\?\UNC\" prefix.
return new LongPath { Path = LONG_PATH_PREFIX + "UNC\\" + path.Substring(2) };
else
{
//These prefixes are not used as part of the path itself. They indicate that
//the path should be passed to the system with minimal modification, which
//means that you cannot use forward slashes to represent path separators, or
//a period to represent the current directory, or double dots to represent the
//parent directory. Because you cannot use the "\\?\" prefix with a relative
//path, relative paths are always limited to a total of MAX_PATH characters.
if (path.Length > MAX_PATH)
throw new System.IO.PathTooLongException();
return new LongPath { Path = path };
}
}
public static implicit operator string(LongPath path) => path?.Path;
[JsonIgnore]
public string ShortPathName
{
get
{
//Short Path names are useful for navigating to the file in windows explorer,
//which will not recognize paths longer than MAX_PATH. Short path names are not
//always enabled on every volume. So to check if a volume enables short path
//names (aka 8dot3 names), run the following command from an elevated command
//prompt:
//
// fsutil 8dot3name query c:
//
//It will say:
//
// "Based on the above settings, 8dot3 name creation is [enabled/disabled] on c:"
//
//To enable short names on a volume on the system, run the following command
//from an elevated command prompt:
//
// fsutil 8dot3name set c: 0
//
//or for all volumes on the system:
//
// fsutil 8dot3name set 0
//
//Note that after enabling 8dot3 names on a volume, they will only be available
//for newly-created entries in ther file system. Existing entries made while
//8dot3 names were disabled will not be reachable by short paths.
if (Path is null) return null;
StringBuilder shortPathBuffer = new(MaxPathLength);
GetShortPathName(Path, shortPathBuffer, MaxPathLength);
return shortPathBuffer.ToString();
}
}
[JsonIgnore]
public string LongPathName
{
get
{
if (Path is null) return null;
StringBuilder longPathBuffer = new(MaxPathLength);
GetLongPathName(Path, longPathBuffer, MaxPathLength);
return longPathBuffer.ToString();
}
}
[JsonIgnore]
public string PathWithoutPrefix
=> Path?.StartsWith(LONG_PATH_PREFIX) == true ?
Path.Remove(0, LONG_PATH_PREFIX.Length) :
Path;
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int GetShortPathName([MarshalAs(UnmanagedType.LPWStr)] string path, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder shortPath, int shortPathLength);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode)]
private static extern int GetLongPathName([MarshalAs(UnmanagedType.LPWStr)] string lpszShortPath, [MarshalAs(UnmanagedType.LPWStr)] StringBuilder lpszLongPath, int cchBuffer);
}
}

View File

@@ -0,0 +1,20 @@
using System;
using System.Linq;
namespace FileManager
{
public class MetadataNamingTemplate : NamingTemplate
{
public MetadataNamingTemplate(string template) : base(template) { }
public string GetTagContents()
{
var tagValue = Template;
foreach (var r in ParameterReplacements)
tagValue = tagValue.Replace($"<{formatKey(r.Key)}>", r.Value?.ToString() ?? "");
return tagValue;
}
}
}

View File

@@ -0,0 +1,28 @@
using Dinah.Core;
using System;
using System.Collections.Generic;
namespace FileManager
{
public class NamingTemplate
{
/// <summary>Proposed full name. May contain optional html-styled template tags. Eg: &lt;name&gt;</summary>
public string Template { get; }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public NamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
/// <summary>Convenience method</summary>
public void AddParameterReplacement(string key, object value)
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
=> ParameterReplacements.Add(key, value);
protected static string formatKey(string key)
=> key
.Replace("<", "")
.Replace(">", "");
}
}

View File

@@ -0,0 +1,271 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
namespace FileManager
{
public class Replacement
{
public const int FIXED_COUNT = 6;
internal const char QUOTE_MARK = '"';
[JsonIgnore] public bool Mandatory { get; internal set; }
[JsonProperty] public char CharacterToReplace { get; private set; }
[JsonProperty] public string ReplacementString { get; private set; }
[JsonProperty] public string Description { get; private set; }
public override string ToString() => $"{CharacterToReplace} → {ReplacementString} ({Description})";
public Replacement(char charToReplace, string replacementString, string description)
{
CharacterToReplace = charToReplace;
ReplacementString = replacementString;
Description = description;
}
private Replacement(char charToReplace, string replacementString, string description, bool mandatory)
: this(charToReplace, replacementString, description)
{
Mandatory = mandatory;
}
public void Update(char charToReplace, string replacementString, string description)
{
ReplacementString = replacementString;
if (!Mandatory)
{
CharacterToReplace = charToReplace;
Description = description;
}
}
public static Replacement OtherInvalid(string replacement) => new(default, replacement, "All other invalid characters", true);
public static Replacement FilenameForwardSlash(string replacement) => new('/', replacement, "Forward Slash (Filename Only)", true);
public static Replacement FilenameBackSlash(string replacement) => new('\\', replacement, "Back Slash (Filename Only)", true);
public static Replacement OpenQuote(string replacement) => new('"', replacement, "Open Quote", true);
public static Replacement CloseQuote(string replacement) => new('"', replacement, "Close Quote", true);
public static Replacement OtherQuote(string replacement) => new('"', replacement, "Other Quote", true);
public static Replacement Colon(string replacement) => new(':', replacement, "Colon");
public static Replacement Asterisk(string replacement) => new('*', replacement, "Asterisk");
public static Replacement QuestionMark(string replacement) => new('?', replacement, "Question Mark");
public static Replacement OpenAngleBracket(string replacement) => new('<', replacement, "Open Angle Bracket");
public static Replacement CloseAngleBracket(string replacement) => new('>', replacement, "Close Angle Bracket");
public static Replacement Pipe(string replacement) => new('|', replacement, "Vertical Line");
}
[JsonConverter(typeof(ReplacementCharactersConverter))]
public class ReplacementCharacters
{
public static readonly ReplacementCharacters Default = new()
{
Replacements = new List<Replacement>()
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash(""),
Replacement.FilenameBackSlash(""),
Replacement.OpenQuote("“"),
Replacement.CloseQuote("”"),
Replacement.OtherQuote(""),
Replacement.OpenAngleBracket(""),
Replacement.CloseAngleBracket(""),
Replacement.Colon(""),
Replacement.Asterisk("✱"),
Replacement.QuestionMark(""),
Replacement.Pipe("⏐"),
}
};
public static readonly ReplacementCharacters LoFiDefault = new()
{
Replacements = new List<Replacement>()
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("'"),
Replacement.CloseQuote("'"),
Replacement.OtherQuote("'"),
Replacement.OpenAngleBracket("{"),
Replacement.CloseAngleBracket("}"),
Replacement.Colon("-"),
}
};
public static readonly ReplacementCharacters Barebones = new()
{
Replacements = new List<Replacement>()
{
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("_"),
Replacement.CloseQuote("_"),
Replacement.OtherQuote("_"),
}
};
private static readonly char[] invalidChars = Path.GetInvalidPathChars().Union(new[] {
'*', '?', ':',
// these are weird. If you run Path.GetInvalidPathChars() in Visual Studio's "C# Interactive", then these characters are included.
// In live code, Path.GetInvalidPathChars() does not include them
'"', '<', '>'
}).ToArray();
public IReadOnlyList<Replacement> Replacements { get; init; }
private string DefaultReplacement => Replacements[0].ReplacementString;
private Replacement ForwardSlash => Replacements[1];
private Replacement BackSlash => Replacements[2];
private string OpenQuote => Replacements[3].ReplacementString;
private string CloseQuote => Replacements[4].ReplacementString;
private string OtherQuote => Replacements[5].ReplacementString;
private string GetFilenameCharReplacement(char toReplace, char preceding, char succeding)
{
if (toReplace == ForwardSlash.CharacterToReplace)
return ForwardSlash.ReplacementString;
else if (toReplace == BackSlash.CharacterToReplace)
return BackSlash.ReplacementString;
else return GetPathCharReplacement(toReplace, preceding, succeding);
}
private string GetPathCharReplacement(char toReplace, char preceding, char succeding)
{
if (toReplace == Replacement.QUOTE_MARK)
{
if (preceding == default ||
(preceding != default
&& !char.IsLetter(preceding)
&& !char.IsNumber(preceding)
&& (char.IsLetter(succeding) || char.IsNumber(succeding))
)
)
return OpenQuote;
else if (succeding == default ||
(succeding != default
&& !char.IsLetter(succeding)
&& !char.IsNumber(succeding)
&& (char.IsLetter(preceding) || char.IsNumber(preceding))
)
)
return CloseQuote;
else
return OtherQuote;
}
for (int i = Replacement.FIXED_COUNT; i < Replacements.Count; i++)
{
var r = Replacements[i];
if (r.CharacterToReplace == toReplace)
return r.ReplacementString;
}
return DefaultReplacement;
}
public static bool ContainsInvalid(string path)
=> path.Any(c => invalidChars.Contains(c));
public string ReplaceInvalidFilenameChars(string fileName)
{
if (string.IsNullOrEmpty(fileName)) return string.Empty;
var builder = new System.Text.StringBuilder();
for (var i = 0; i < fileName.Length; i++)
{
var c = fileName[i];
if (invalidChars.Contains(c) || c == ForwardSlash.CharacterToReplace || c == BackSlash.CharacterToReplace)
{
char preceding = i > 0 ? fileName[i - 1] : default;
char succeeding = i < fileName.Length - 1 ? fileName[i + 1] : default;
builder.Append(GetFilenameCharReplacement(c, preceding, succeeding));
}
else
builder.Append(c);
}
return builder.ToString();
}
public string ReplaceInvalidPathChars(string pathStr)
{
if (string.IsNullOrEmpty(pathStr)) return string.Empty;
// replace all colons except within the first 2 chars
var builder = new System.Text.StringBuilder();
for (var i = 0; i < pathStr.Length; i++)
{
var c = pathStr[i];
if (!invalidChars.Contains(c) || (c == ':' && i == 1 && Path.IsPathRooted(pathStr)))
builder.Append(c);
else
{
char preceding = i > 0 ? pathStr[i - 1] : default;
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
}
}
return builder.ToString();
}
}
#region JSON Converter
internal class ReplacementCharactersConverter : JsonConverter
{
public override bool CanConvert(Type objectType)
=> objectType == typeof(ReplacementCharacters);
public override object ReadJson(JsonReader reader, Type objectType, object existingValue, JsonSerializer serializer)
{
var jObj = JObject.Load(reader);
var replaceArr = jObj[nameof(Replacement)];
IReadOnlyList<Replacement> dict = replaceArr
.ToObject<Replacement[]>().ToList();
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
//If not, reset to default.
var default0 = Replacement.OtherInvalid("");
var default1 = Replacement.FilenameForwardSlash("");
var default2 = Replacement.FilenameBackSlash("");
var default3 = Replacement.OpenQuote("");
var default4 = Replacement.CloseQuote("");
var default5 = Replacement.OtherQuote("");
if (dict.Count < Replacement.FIXED_COUNT ||
dict[0].CharacterToReplace != default0.CharacterToReplace || dict[0].Description != default0.Description ||
dict[1].CharacterToReplace != default1.CharacterToReplace || dict[1].Description != default1.Description ||
dict[2].CharacterToReplace != default2.CharacterToReplace || dict[2].Description != default2.Description ||
dict[3].CharacterToReplace != default3.CharacterToReplace || dict[3].Description != default3.Description ||
dict[4].CharacterToReplace != default4.CharacterToReplace || dict[4].Description != default4.Description ||
dict[5].CharacterToReplace != default5.CharacterToReplace || dict[5].Description != default5.Description ||
dict.Any(r => ReplacementCharacters.ContainsInvalid(r.ReplacementString))
)
{
dict = ReplacementCharacters.Default.Replacements;
}
//First FIXED_COUNT are mandatory
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
dict[i].Mandatory = true;
return new ReplacementCharacters { Replacements = dict };
}
public override void WriteJson(JsonWriter writer, object value, JsonSerializer serializer)
{
ReplacementCharacters replacements = (ReplacementCharacters)value;
var propertyNames = replacements.Replacements
.Select(c => JObject.FromObject(c)).ToList();
var prop = new JProperty(nameof(Replacement), new JArray(propertyNames));
var obj = new JObject();
obj.AddFirst(prop);
obj.WriteTo(writer);
}
}
#endregion
}

View File

@@ -0,0 +1,18 @@
using AppScaffolding;
namespace Hangover
{
public partial class Form1
{
private void Load_cliTab()
{
}
private void cliTab_VisibleChanged(object sender, EventArgs e)
{
if (!databaseTab.Visible)
return;
}
}
}

View File

@@ -0,0 +1,140 @@
using ApplicationServices;
using AppScaffolding;
using Microsoft.EntityFrameworkCore;
namespace Hangover
{
public partial class Form1
{
private string dbFile;
private void Load_databaseTab()
{
dbFile = UNSAFE_MigrationHelper.DatabaseFile;
if (dbFile is null)
{
databaseFileLbl.Text = $"Database file not found";
return;
}
databaseFileLbl.Text = $"Database file: {UNSAFE_MigrationHelper.DatabaseFile ?? "not found"}";
}
private void databaseTab_VisibleChanged(object sender, EventArgs e)
{
if (!databaseTab.Visible)
return;
}
private void sqlExecuteBtn_Click(object sender, EventArgs e)
{
ensureBackup();
sqlResultsTb.Clear();
try
{
var sql = sqlTb.Text.Trim();
#region // explanation
// Routing statements to non-query is a convenience.
// I went down the rabbit hole of full parsing and it's more trouble than it's worth. The parsing is easy due to available libraries. The edge cases of what to do next got too complex for slight gains.
// It's also not useful to take the extra effort to separate non-queries which don't return a row count. Eg: alter table, drop table
// My half-assed solution here won't even catch simple mistakes like this -- and that's ok
// -- line 1 is a comment
// delete from foo
#endregion
var lower = sql.ToLower();
if (lower.StartsWith("update") || lower.StartsWith("insert") || lower.StartsWith("delete"))
nonQuery(sql);
else
query(sql);
}
catch (Exception ex)
{
sqlResultsTb.Text = $"{ex.Message}\r\n{ex.StackTrace}";
}
finally
{
deleteUnneededBackups();
}
}
private string dbBackup;
private DateTime dbFileLastModified;
private void ensureBackup()
{
if (dbBackup is not null)
return;
dbFileLastModified = File.GetLastWriteTimeUtc(dbFile);
dbBackup
= Path.ChangeExtension(dbFile, "").TrimEnd('.')
+ $"_backup_{DateTime.UtcNow:O}".Replace(':', '-').Replace('.', '-')
+ Path.GetExtension(dbFile);
File.Copy(dbFile, dbBackup);
}
private void deleteUnneededBackups()
{
var newLastModified = File.GetLastWriteTimeUtc(dbFile);
if (dbFileLastModified == newLastModified)
{
File.Delete(dbBackup);
dbBackup = null;
}
}
void query(string sql)
{
// ef doesn't support truly generic queries. have to drop down to ado.net
using var context = DbContexts.GetContext();
using var conn = context.Database.GetDbConnection();
conn.Open();
using var cmd = conn.CreateCommand();
cmd.CommandText = sql;
var reader = cmd.ExecuteReader();
var results = 0;
var builder = new System.Text.StringBuilder();
var lines = 0;
while (reader.Read())
{
results++;
for (var i = 0; i < reader.FieldCount; i++)
builder.Append(reader.GetValue(i) + "\t");
builder.AppendLine();
lines++;
if (lines % 10 == 0)
{
sqlResultsTb.AppendText(builder.ToString());
builder.Clear();
}
}
sqlResultsTb.AppendText(builder.ToString());
builder.Clear();
if (results == 0)
sqlResultsTb.Text = "[no results]";
else
{
sqlResultsTb.AppendText($"\r\n{results} result");
if (results != 1) sqlResultsTb.AppendText("s");
}
}
void nonQuery(string sql)
{
using var context = DbContexts.GetContext();
var results = context.Database.ExecuteSqlRaw(sql);
sqlResultsTb.AppendText($"{results} record");
if (results != 1) sqlResultsTb.AppendText("s");
sqlResultsTb.AppendText(" affected");
}
}
}

158
Source/Hangover/Form1.Designer.cs generated Normal file
View File

@@ -0,0 +1,158 @@
namespace Hangover
{
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
this.tabControl1 = new System.Windows.Forms.TabControl();
this.databaseTab = new System.Windows.Forms.TabPage();
this.sqlExecuteBtn = new System.Windows.Forms.Button();
this.sqlResultsTb = new System.Windows.Forms.TextBox();
this.sqlTb = new System.Windows.Forms.TextBox();
this.sqlLbl = new System.Windows.Forms.Label();
this.databaseFileLbl = new System.Windows.Forms.Label();
this.cliTab = new System.Windows.Forms.TabPage();
this.tabControl1.SuspendLayout();
this.databaseTab.SuspendLayout();
this.SuspendLayout();
//
// tabControl1
//
this.tabControl1.Controls.Add(this.databaseTab);
this.tabControl1.Controls.Add(this.cliTab);
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
this.tabControl1.Location = new System.Drawing.Point(0, 0);
this.tabControl1.Name = "tabControl1";
this.tabControl1.SelectedIndex = 0;
this.tabControl1.Size = new System.Drawing.Size(800, 450);
this.tabControl1.TabIndex = 0;
//
// databaseTab
//
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
this.databaseTab.Controls.Add(this.sqlResultsTb);
this.databaseTab.Controls.Add(this.sqlTb);
this.databaseTab.Controls.Add(this.sqlLbl);
this.databaseTab.Controls.Add(this.databaseFileLbl);
this.databaseTab.Location = new System.Drawing.Point(4, 24);
this.databaseTab.Name = "databaseTab";
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
this.databaseTab.Size = new System.Drawing.Size(792, 422);
this.databaseTab.TabIndex = 0;
this.databaseTab.Text = "Database";
this.databaseTab.UseVisualStyleBackColor = true;
//
// sqlExecuteBtn
//
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
this.sqlExecuteBtn.TabIndex = 3;
this.sqlExecuteBtn.Text = "Execute";
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
//
// sqlResultsTb
//
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
this.sqlResultsTb.Multiline = true;
this.sqlResultsTb.Name = "sqlResultsTb";
this.sqlResultsTb.ReadOnly = true;
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
this.sqlResultsTb.TabIndex = 4;
//
// sqlTb
//
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.sqlTb.Location = new System.Drawing.Point(8, 48);
this.sqlTb.Multiline = true;
this.sqlTb.Name = "sqlTb";
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.sqlTb.Size = new System.Drawing.Size(778, 99);
this.sqlTb.TabIndex = 2;
//
// sqlLbl
//
this.sqlLbl.AutoSize = true;
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
this.sqlLbl.Name = "sqlLbl";
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
this.sqlLbl.TabIndex = 1;
this.sqlLbl.Text = "SQL (database command)";
//
// databaseFileLbl
//
this.databaseFileLbl.AutoSize = true;
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
this.databaseFileLbl.Name = "databaseFileLbl";
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
this.databaseFileLbl.TabIndex = 0;
this.databaseFileLbl.Text = "Database file: ";
//
// cliTab
//
this.cliTab.Location = new System.Drawing.Point(4, 24);
this.cliTab.Name = "cliTab";
this.cliTab.Size = new System.Drawing.Size(792, 422);
this.cliTab.TabIndex = 1;
this.cliTab.Text = "Command Line Interface";
this.cliTab.UseVisualStyleBackColor = true;
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Controls.Add(this.tabControl1);
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
this.Name = "Form1";
this.Text = "Hangover: Libation debug and recovery tool";
this.tabControl1.ResumeLayout(false);
this.databaseTab.ResumeLayout(false);
this.databaseTab.PerformLayout();
this.ResumeLayout(false);
}
#endregion
private TabControl tabControl1;
private TabPage databaseTab;
private Label databaseFileLbl;
private TextBox sqlResultsTb;
private TextBox sqlTb;
private Label sqlLbl;
private Button sqlExecuteBtn;
private TabPage cliTab;
}
}

16
Source/Hangover/Form1.cs Normal file
View File

@@ -0,0 +1,16 @@
namespace Hangover
{
public partial class Form1 : Form
{
public Form1()
{
InitializeComponent();
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
cliTab.VisibleChanged += cliTab_VisibleChanged;
Load_databaseTab();
Load_cliTab();
}
}
}

2328
Source/Hangover/Form1.resx Normal file
View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,46 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>WinExe</OutputType>
<TargetFramework>net6.0-windows</TargetFramework>
<UseWindowsForms>true</UseWindowsForms>
<ApplicationIcon>hangover.ico</ApplicationIcon>
<ImplicitUsings>enable</ImplicitUsings>
<PublishReadyToRun>true</PublishReadyToRun>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<!--
When LibationWinForms and Hangover output to the same dir, Hangover must build before LibationWinForms
VS > rt-clk solution > Properties
left: Project Dependencies
top: Projects: LibationWinForms
bottom: manually check Hangover
edit debug and release output paths
-->
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
<ItemGroup>
<Compile Update="Form1.*.cs">
<DependentUpon>Form1.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,17 @@
namespace Hangover
{
internal static class Program
{
/// <summary>
/// The main entry point for the application.
/// </summary>
[STAThread]
static void Main()
{
// To customize application configuration such as set high DPI settings or default font,
// see https://aka.ms/applicationconfiguration.
ApplicationConfiguration.Initialize();
Application.Run(new Form1());
}
}
}

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 498 B

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 50 KiB

View File

Binary file not shown.

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.9 KiB

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 133 KiB

View File

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

View File

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

View File

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

View File

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

View File

@@ -5,10 +5,11 @@ VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
ProjectSection(SolutionItems) = preProject
REFERENCE.txt = REFERENCE.txt
_ARCHITECTURE NOTES.txt = _ARCHITECTURE NOTES.txt
_DB_NOTES.txt = _DB_NOTES.txt
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
__TODO.txt = __TODO.txt
_DB_NOTES.txt = _DB_NOTES.txt
REFERENCE.txt = REFERENCE.txt
EndProjectSection
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "5 Domain Utilities (db aware)", "5 Domain Utilities (db aware)", "{41CDCC73-9B81-49DD-9570-C54406E852AF}"
@@ -37,6 +38,7 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "Lib
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
ProjectSection(ProjectDependencies) = postProject
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {40C67036-C1A7-4FDF-AA83-8EC902E257F3}
{428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
EndProjectSection
EndProject
@@ -48,8 +50,6 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
@@ -64,6 +64,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hangover", "Hangover\Hangover.csproj", "{40C67036-C1A7-4FDF-AA83-8EC902E257F3}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@@ -110,10 +112,6 @@ Global
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
@@ -142,6 +140,10 @@ Global
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.Build.0 = Debug|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.ActiveCfg = Release|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Release|Any CPU.Build.0 = Release|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Debug|Any CPU.Build.0 = Debug|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Release|Any CPU.ActiveCfg = Release|Any CPU
{40C67036-C1A7-4FDF-AA83-8EC902E257F3}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@@ -157,7 +159,6 @@ Global
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{428163C3-D558-4914-B570-A92069521877} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{595E7C4D-506D-486D-98B7-5FDDF398D033} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
@@ -165,6 +166,7 @@ Global
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{F2E04270-4551-41C4-99FF-E7125BED708C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{EB781571-8548-477E-82AD-FB9FAB548D2F} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{40C67036-C1A7-4FDF-AA83-8EC902E257F3} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}

View File

@@ -14,11 +14,13 @@
<!--
When LibationWinForms and LibationCli output to the same dir, LibationCli must build before LibationWinForms
VS > rt-clik solution > Project Build Order...
Dependencies [tab]
Projects: LibationWinForms
manually check LibationCli
VS > rt-clk solution > Properties
left: Project Dependencies
top: Projects: LibationWinForms
bottom: manually check LibationCli
edit debug and release output paths
-->
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
@@ -29,11 +31,10 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
<PackageReference Include="CommandLineParser" Version="2.9.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>

View File

@@ -9,7 +9,6 @@ using FileLiberator;
namespace LibationCli
{
// streamlined, non-Forms copy of ProcessorAutomationController
public abstract class ProcessableOptionsBase : OptionsBase
{
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)

View File

@@ -27,7 +27,6 @@ namespace LibationCli
// //
//***********************************************//
Setup.Initialize();
Setup.SubscribeToDatabaseEvents();
var types = Setup.LoadVerbs();

View File

@@ -34,15 +34,15 @@ namespace LibationCli
private static void checkForUpdate()
{
var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease();
if (!hasUpgrade)
var upgradeProperties = LibationScaffolding.GetLatestRelease();
if (upgradeProperties is null)
return;
var origColor = Console.ForegroundColor;
try
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}");
Console.WriteLine($"UPDATE AVAILABLE @ {upgradeProperties.ZipUrl}");
}
finally
{
@@ -50,11 +50,6 @@ namespace LibationCli
}
}
public static void SubscribeToDatabaseEvents()
{
DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book);
}
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)

View File

@@ -9,18 +9,19 @@ namespace LibationFileManager
{
public abstract class AudibleFileStorage
{
protected abstract string GetFilePathCustom(string productId);
protected abstract LongPath GetFilePathCustom(string productId);
protected abstract List<LongPath> GetFilePathsCustom(string productId);
#region static
public static string DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
public static string DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
public static LongPath DownloadsInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
public static LongPath DecryptInProgressDirectory => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
private static AaxcFileStorage AAXC { get; } = new AaxcFileStorage();
public static bool AaxcExists(string productId) => AAXC.Exists(productId);
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static string BooksDirectory
public static LongPath BooksDirectory
{
get
{
@@ -43,7 +44,7 @@ namespace LibationFileManager
regexTemplate = $@"{{0}}.*?\.({extAggr})$";
}
protected string GetFilePath(string productId)
protected LongPath GetFilePath(string productId)
{
// primary lookup
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
@@ -58,6 +59,9 @@ namespace LibationFileManager
return firstOrNull;
}
public List<LongPath> GetPaths(string productId)
=> GetFilePathsCustom(productId);
protected Regex GetBookSearchRegex(string productId)
{
var pattern = string.Format(regexTemplate, productId);
@@ -70,12 +74,15 @@ namespace LibationFileManager
{
internal AaxcFileStorage() : base(FileType.AAXC) { }
protected override string GetFilePathCustom(string productId)
protected override LongPath GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
{
var regex = GetBookSearchRegex(productId);
return FileUtility
.SaferEnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => regex.IsMatch(s));
.Where(s => regex.IsMatch(s)).ToList();
}
public bool Exists(string productId) => GetFilePath(productId) is not null;
@@ -88,7 +95,11 @@ namespace LibationFileManager
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
private static object bookDirectoryFilesLocker { get; } = new();
protected override string GetFilePathCustom(string productId)
protected override LongPath GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
protected override List<LongPath> GetFilePathsCustom(string productId)
{
// If user changed the BooksDirectory: reinitialize
lock (bookDirectoryFilesLocker)
@@ -96,11 +107,12 @@ namespace LibationFileManager
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
var regex = GetBookSearchRegex(productId);
return BookDirectoryFiles.FindFile(regex);
return BookDirectoryFiles.FindFiles(regex);
}
public void Refresh() => BookDirectoryFiles.RefreshFiles();
public string GetPath(string productId) => GetFilePath(productId);
}
public LongPath GetPath(string productId) => GetFilePath(productId);
}
}

View File

@@ -14,485 +14,528 @@ using Serilog.Events;
namespace LibationFileManager
{
public class Configuration
{
public bool LibationSettingsAreValid
=> File.Exists(APPSETTINGS_JSON)
&& SettingsFileIsValid(SettingsFilePath);
public class Configuration
{
public bool LibationSettingsAreValid
=> File.Exists(APPSETTINGS_JSON)
&& SettingsFileIsValid(SettingsFilePath);
public static bool SettingsFileIsValid(string settingsFile)
{
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
public static bool SettingsFileIsValid(string settingsFile)
{
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
var pDic = new PersistentDictionary(settingsFile, isReadOnly: true);
var pDic = new PersistentDictionary(settingsFile, isReadOnly: true);
var booksDir = pDic.GetString(nameof(Books));
if (booksDir is null || !Directory.Exists(booksDir))
return false;
var booksDir = pDic.GetString(nameof(Books));
if (booksDir is null || !Directory.Exists(booksDir))
return false;
if (string.IsNullOrWhiteSpace(pDic.GetString(nameof(InProgress))))
return false;
if (string.IsNullOrWhiteSpace(pDic.GetString(nameof(InProgress))))
return false;
return true;
}
#region persistent configuration settings/values
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
// default setting and directory creation occur in class responsible for files.
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
private PersistentDictionary persistentDictionary;
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public static string GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
.SingleOrDefault()
as DescriptionAttribute;
return attribute?.Description;
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books
{
get => persistentDictionary.GetString(nameof(Books));
set => persistentDictionary.SetString(nameof(Books), value);
}
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
public string InProgress
{
get => persistentDictionary.GetString(nameof(InProgress));
set => persistentDictionary.SetString(nameof(InProgress), value);
}
[Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup
{
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
set => persistentDictionary.SetNonString(nameof(AllowLibationFixup), value);
}
[Description("Create a cue sheet (.cue)")]
public bool CreateCueSheet
{
get => persistentDictionary.GetNonString<bool>(nameof(CreateCueSheet));
set => persistentDictionary.SetNonString(nameof(CreateCueSheet), value);
}
[Description("Retain the Aax file after successfully decrypting")]
public bool RetainAaxFile
{
get => persistentDictionary.GetNonString<bool>(nameof(RetainAaxFile));
set => persistentDictionary.SetNonString(nameof(RetainAaxFile), value);
}
[Description("Split my books into multiple files by chapter")]
public bool SplitFilesByChapter
{
get => persistentDictionary.GetNonString<bool>(nameof(SplitFilesByChapter));
set => persistentDictionary.SetNonString(nameof(SplitFilesByChapter), value);
}
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
public bool StripUnabridged
{
get => persistentDictionary.GetNonString<bool>(nameof(StripUnabridged));
set => persistentDictionary.SetNonString(nameof(StripUnabridged), value);
}
[Description("Allow Libation to remove audible branding from the start\r\nand end of audiobooks. (e.g. \"This is Audible\")")]
public bool StripAudibleBrandAudio
{
get => persistentDictionary.GetNonString<bool>(nameof(StripAudibleBrandAudio));
set => persistentDictionary.SetNonString(nameof(StripAudibleBrandAudio), value);
}
[Description("Decrypt to lossy format?")]
public bool DecryptToLossy
{
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
}
[Description("Lame encoder target. true = Bitrate, false = Quality")]
public bool LameTargetBitrate
{
get => persistentDictionary.GetNonString<bool>(nameof(LameTargetBitrate));
set => persistentDictionary.SetNonString(nameof(LameTargetBitrate), value);
}
[Description("Lame encoder downsamples to mono")]
public bool LameDownsampleMono
{
get => persistentDictionary.GetNonString<bool>(nameof(LameDownsampleMono));
set => persistentDictionary.SetNonString(nameof(LameDownsampleMono), value);
}
[Description("Lame target bitrate [16,320]")]
public int LameBitrate
{
get => persistentDictionary.GetNonString<int>(nameof(LameBitrate));
set => persistentDictionary.SetNonString(nameof(LameBitrate), value);
}
[Description("Restrict encoder to constant bitrate?")]
public bool LameConstantBitrate
{
get => persistentDictionary.GetNonString<bool>(nameof(LameConstantBitrate));
set => persistentDictionary.SetNonString(nameof(LameConstantBitrate), value);
}
[Description("Match the source bitrate?")]
public bool LameMatchSourceBR
{
get => persistentDictionary.GetNonString<bool>(nameof(LameMatchSourceBR));
set => persistentDictionary.SetNonString(nameof(LameMatchSourceBR), value);
}
[Description("Lame target VBR quality [10,100]")]
public int LameVBRQuality
{
get => persistentDictionary.GetNonString<int>(nameof(LameVBRQuality));
set => persistentDictionary.SetNonString(nameof(LameVBRQuality), value);
}
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities
{
get => persistentDictionary.GetNonString<Dictionary<string, bool>>(nameof(GridColumnsVisibilities));
set => persistentDictionary.SetNonString(nameof(GridColumnsVisibilities), value);
}
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
public Dictionary<string, int> GridColumnsDisplayIndices
{
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsDisplayIndices));
set => persistentDictionary.SetNonString(nameof(GridColumnsDisplayIndices), value);
}
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
public Dictionary<string, int> GridColumnsWidths
{
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsWidths));
set => persistentDictionary.SetNonString(nameof(GridColumnsWidths), value);
}
public enum BadBookAction
{
[Description("Ask each time what action to take.")]
Ask = 0,
[Description("Stop processing books.")]
Abort = 1,
[Description("Retry book later. Skip for now. Continue processing books.")]
Retry = 2,
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
Ignore = 3
}
[Description("When liberating books and there is an error, Libation should:")]
public BadBookAction BadBook
{
get
{
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
}
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
}
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
public bool ShowImportedStats
{
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
}
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
public bool ImportEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(ImportEpisodes));
set => persistentDictionary.SetNonString(nameof(ImportEpisodes), value);
}
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
public bool DownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value);
return true;
}
public event EventHandler AutoScanChanged;
#region persistent configuration settings/values
[Description("Automatically run periodic scans in the background?")]
public bool AutoScan
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoScan));
set
{
if (AutoScan != value)
{
persistentDictionary.SetNonString(nameof(AutoScan), value);
AutoScanChanged?.Invoke(null, null);
}
}
}
// note: any potential file manager static ctors can't compensate if storage dir is changed at run time via settings. this is partly bad architecture. but the side effect is desirable. if changing LibationFiles location: restart app
#region templates: custom file naming
// default setting and directory creation occur in class responsible for files.
// config class is only responsible for path. not responsible for setting defaults, dir validation, or dir creation
// exceptions: appsettings.json, LibationFiles dir, Settings.json
[Description("How to format the folders in which files will be saved")]
private PersistentDictionary persistentDictionary;
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
{
var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
public static string GetDescription(string propertyName)
{
var attribute = typeof(Configuration)
.GetProperty(propertyName)
?.GetCustomAttributes(typeof(DescriptionAttribute), true)
.SingleOrDefault()
as DescriptionAttribute;
return attribute?.Description;
}
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
[Description("Location for book storage. Includes destination of newly liberated books")]
public string Books
{
get => persistentDictionary.GetString(nameof(Books));
set => persistentDictionary.SetString(nameof(Books), value);
}
// temp/working dir(s) should be outside of dropbox
[Description("Temporary location of files while they're in process of being downloaded and decrypted.\r\nWhen decryption is complete, the final file will be in Books location\r\nRecommend not using a folder which is backed up real time. Eg: Dropbox, iCloud, Google Drive")]
public string InProgress
{
get => persistentDictionary.GetString(nameof(InProgress));
set => persistentDictionary.SetString(nameof(InProgress), value);
}
[Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup
{
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
set => persistentDictionary.SetNonString(nameof(AllowLibationFixup), value);
}
[Description("Create a cue sheet (.cue)")]
public bool CreateCueSheet
{
get => persistentDictionary.GetNonString<bool>(nameof(CreateCueSheet));
set => persistentDictionary.SetNonString(nameof(CreateCueSheet), value);
}
[Description("Retain the Aax file after successfully decrypting")]
public bool RetainAaxFile
{
get => persistentDictionary.GetNonString<bool>(nameof(RetainAaxFile));
set => persistentDictionary.SetNonString(nameof(RetainAaxFile), value);
}
[Description("Split my books into multiple files by chapter")]
public bool SplitFilesByChapter
{
get => persistentDictionary.GetNonString<bool>(nameof(SplitFilesByChapter));
set => persistentDictionary.SetNonString(nameof(SplitFilesByChapter), value);
}
[Description("Strip \"(Unabridged)\" from audiobook metadata tags")]
public bool StripUnabridged
{
get => persistentDictionary.GetNonString<bool>(nameof(StripUnabridged));
set => persistentDictionary.SetNonString(nameof(StripUnabridged), value);
}
[Description("Strip audible branding from the start and end of audiobooks.\r\n(e.g. \"This is Audible\")")]
public bool StripAudibleBrandAudio
{
get => persistentDictionary.GetNonString<bool>(nameof(StripAudibleBrandAudio));
set => persistentDictionary.SetNonString(nameof(StripAudibleBrandAudio), value);
}
[Description("Decrypt to lossy format?")]
public bool DecryptToLossy
{
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value);
}
[Description("Lame encoder target. true = Bitrate, false = Quality")]
public bool LameTargetBitrate
{
get => persistentDictionary.GetNonString<bool>(nameof(LameTargetBitrate));
set => persistentDictionary.SetNonString(nameof(LameTargetBitrate), value);
}
[Description("Lame encoder downsamples to mono")]
public bool LameDownsampleMono
{
get => persistentDictionary.GetNonString<bool>(nameof(LameDownsampleMono));
set => persistentDictionary.SetNonString(nameof(LameDownsampleMono), value);
}
[Description("Lame target bitrate [16,320]")]
public int LameBitrate
{
get => persistentDictionary.GetNonString<int>(nameof(LameBitrate));
set => persistentDictionary.SetNonString(nameof(LameBitrate), value);
}
[Description("Restrict encoder to constant bitrate?")]
public bool LameConstantBitrate
{
get => persistentDictionary.GetNonString<bool>(nameof(LameConstantBitrate));
set => persistentDictionary.SetNonString(nameof(LameConstantBitrate), value);
}
[Description("Match the source bitrate?")]
public bool LameMatchSourceBR
{
get => persistentDictionary.GetNonString<bool>(nameof(LameMatchSourceBR));
set => persistentDictionary.SetNonString(nameof(LameMatchSourceBR), value);
}
[Description("Lame target VBR quality [10,100]")]
public int LameVBRQuality
{
get => persistentDictionary.GetNonString<int>(nameof(LameVBRQuality));
set => persistentDictionary.SetNonString(nameof(LameVBRQuality), value);
}
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities
{
get => persistentDictionary.GetNonString<Dictionary<string, bool>>(nameof(GridColumnsVisibilities));
set => persistentDictionary.SetNonString(nameof(GridColumnsVisibilities), value);
}
[Description("A Dictionary of GridView data property names and int indicating its column's display index in ProductsGrid")]
public Dictionary<string, int> GridColumnsDisplayIndices
{
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsDisplayIndices));
set => persistentDictionary.SetNonString(nameof(GridColumnsDisplayIndices), value);
}
[Description("A Dictionary of GridView data property names and int indicating its column's width in ProductsGrid")]
public Dictionary<string, int> GridColumnsWidths
{
get => persistentDictionary.GetNonString<Dictionary<string,int>>(nameof(GridColumnsWidths));
set => persistentDictionary.SetNonString(nameof(GridColumnsWidths), value);
}
[Description("Save cover image alongside audiobook?")]
public bool DownloadCoverArt
{
get => persistentDictionary.GetNonString<bool>(nameof(DownloadCoverArt));
set => persistentDictionary.SetNonString(nameof(DownloadCoverArt), value);
}
public enum BadBookAction
{
[Description("Ask each time what action to take.")]
Ask = 0,
[Description("Stop processing books.")]
Abort = 1,
[Description("Retry book later. Skip for now. Continue processing books.")]
Retry = 2,
[Description("Permanently ignore book. Continue processing books. Do not try book again.")]
Ignore = 3
}
[Description("When liberating books and there is an error, Libation should:")]
public BadBookAction BadBook
{
get
{
var badBookStr = persistentDictionary.GetString(nameof(BadBook));
return Enum.TryParse<BadBookAction>(badBookStr, out var badBookEnum) ? badBookEnum : BadBookAction.Ask;
}
set => persistentDictionary.SetString(nameof(BadBook), value.ToString());
}
[Description("Show number of newly imported titles? When unchecked, no pop-up will appear after library scan.")]
public bool ShowImportedStats
{
get => persistentDictionary.GetNonString<bool>(nameof(ShowImportedStats));
set => persistentDictionary.SetNonString(nameof(ShowImportedStats), value);
}
[Description("Import episodes? (eg: podcasts) When unchecked, episodes will not be imported into Libation.")]
public bool ImportEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(ImportEpisodes));
set => persistentDictionary.SetNonString(nameof(ImportEpisodes), value);
}
[Description("Download episodes? (eg: podcasts). When unchecked, episodes already in Libation will not be downloaded.")]
public bool DownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(DownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(DownloadEpisodes), value);
}
public event EventHandler AutoScanChanged;
[Description("Automatically run periodic scans in the background?")]
public bool AutoScan
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoScan));
set
{
if (AutoScan != value)
{
persistentDictionary.SetNonString(nameof(AutoScan), value);
AutoScanChanged?.Invoke(null, null);
}
}
}
[Description("Auto download episodes? After scan, download new books in 'checked' accounts.")]
public bool AutoDownloadEpisodes
{
get => persistentDictionary.GetNonString<bool>(nameof(AutoDownloadEpisodes));
set => persistentDictionary.SetNonString(nameof(AutoDownloadEpisodes), value);
}
[Description("Save all podcast episodes in a series to the series parent folder?")]
public bool SavePodcastsToParentFolder
{
get => persistentDictionary.GetNonString<bool>(nameof(SavePodcastsToParentFolder));
set => persistentDictionary.SetNonString(nameof(SavePodcastsToParentFolder), value);
}
#region templates: custom file naming
[Description("Edit how illegal filename characters are replaced")]
public ReplacementCharacters ReplacementCharacters
{
get => persistentDictionary.GetNonString<ReplacementCharacters>(nameof(ReplacementCharacters));
set => persistentDictionary.SetNonString(nameof(ReplacementCharacters), value);
}
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate
{
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
}
{
get => getTemplate(nameof(FolderTemplate), Templates.Folder);
set => setTemplate(nameof(FolderTemplate), Templates.Folder, value);
}
[Description("How to format the saved pdf and audio files")]
[Description("How to format the saved pdf and audio files")]
public string FileTemplate
{
get => getTemplate(nameof(FileTemplate), Templates.File);
set => setTemplate(nameof(FileTemplate), Templates.File, value);
}
{
get => getTemplate(nameof(FileTemplate), Templates.File);
set => setTemplate(nameof(FileTemplate), Templates.File, value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
}
[Description("How to format the saved audio files when split by chapters")]
public string ChapterFileTemplate
{
get => getTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile);
set => setTemplate(nameof(ChapterFileTemplate), Templates.ChapterFile, value);
}
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
private void setTemplate(string settingName, Templates templ, string newValue)
{
var template = newValue?.Trim();
if (templ.IsValid(template))
persistentDictionary.SetString(settingName, template);
}
#endregion
[Description("How to format the file's Tile stored in metadata")]
public string ChapterTitleTemplate
{
get => getTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle);
set => setTemplate(nameof(ChapterTitleTemplate), Templates.ChapterTitle, value);
}
#endregion
private string getTemplate(string settingName, Templates templ) => templ.GetValid(persistentDictionary.GetString(settingName));
private void setTemplate(string settingName, Templates templ, string newValue)
{
var template = newValue?.Trim();
if (templ.IsValid(template))
persistentDictionary.SetString(settingName, template);
}
#endregion
#region known directories
public static string AppDir_Relative => $@".\{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
#endregion
public enum KnownDirectories
{
None = 0,
#region known directories
public static string AppDir_Relative => $@".\{LIBATION_FILES_KEY}";
public static string AppDir_Absolute => Path.GetFullPath(Path.Combine(Path.GetDirectoryName(Exe.FileLocationOnDisk), LIBATION_FILES_KEY));
public static string MyDocs => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "Libation"));
public static string WinTemp => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation"));
public static string UserProfile => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"));
[Description("My Users folder")]
UserProfile = 1,
public enum KnownDirectories
{
None = 0,
[Description("The same folder that Libation is running from")]
AppDir = 2,
[Description("My Users folder")]
UserProfile = 1,
[Description("Windows temporary folder")]
WinTemp = 3,
[Description("The same folder that Libation is running from")]
AppDir = 2,
[Description("My Documents")]
MyDocs = 4,
[Description("Windows temporary folder")]
WinTemp = 3,
[Description("Your settings folder (aka: Libation Files)")]
LibationFiles = 5
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
{
(KnownDirectories.None, () => null),
(KnownDirectories.UserProfile, () => UserProfile),
(KnownDirectories.AppDir, () => AppDir_Relative),
(KnownDirectories.WinTemp, () => WinTemp),
(KnownDirectories.MyDocs, () => MyDocs),
// this is important to not let very early calls try to accidentally load LibationFiles too early.
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
};
public static string GetKnownDirectoryPath(KnownDirectories directory)
{
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
return dirFunc == default ? null : dirFunc.getPathFunc();
}
public static KnownDirectories GetKnownDirectory(string directory)
{
// especially important so a very early call doesn't match null => LibationFiles
if (string.IsNullOrWhiteSpace(directory))
return KnownDirectories.None;
[Description("My Documents")]
MyDocs = 4,
// 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile.
// since it's a list, order matters and non-LibationFiles will be returned first
var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory);
return dirFunc == default ? KnownDirectories.None : dirFunc.directory;
}
#endregion
[Description("Your settings folder (aka: Libation Files)")]
LibationFiles = 5
}
// use func calls so we always get the latest value of LibationFiles
private static List<(KnownDirectories directory, Func<string> getPathFunc)> directoryOptionsPaths { get; } = new()
{
(KnownDirectories.None, () => null),
(KnownDirectories.UserProfile, () => UserProfile),
(KnownDirectories.AppDir, () => AppDir_Relative),
(KnownDirectories.WinTemp, () => WinTemp),
(KnownDirectories.MyDocs, () => MyDocs),
// this is important to not let very early calls try to accidentally load LibationFiles too early.
// also, keep this at bottom of this list
(KnownDirectories.LibationFiles, () => libationFilesPathCache)
};
public static string GetKnownDirectoryPath(KnownDirectories directory)
{
var dirFunc = directoryOptionsPaths.SingleOrDefault(dirFunc => dirFunc.directory == directory);
return dirFunc == default ? null : dirFunc.getPathFunc();
}
public static KnownDirectories GetKnownDirectory(string directory)
{
// especially important so a very early call doesn't match null => LibationFiles
if (string.IsNullOrWhiteSpace(directory))
return KnownDirectories.None;
#region logging
private IConfigurationRoot configuration;
// 'First' instead of 'Single' because LibationFiles could match other directories. eg: default value of LibationFiles == UserProfile.
// since it's a list, order matters and non-LibationFiles will be returned first
var dirFunc = directoryOptionsPaths.FirstOrDefault(dirFunc => dirFunc.getPathFunc() == directory);
return dirFunc == default ? KnownDirectories.None : dirFunc.directory;
}
#endregion
public void ConfigureLogging()
{
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
}
#region logging
private IConfigurationRoot configuration;
[Description("The importance of a log event")]
public LogEventLevel LogLevel
{
get
{
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
}
set
{
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Debug("LogLevel.set attempt. No change");
return;
}
public void ConfigureLogging()
{
configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build();
Log.Logger = new LoggerConfiguration()
.ReadFrom.Configuration(configuration)
.CreateLogger();
}
configuration.Reload();
[Description("The importance of a log event")]
public LogEventLevel LogLevel
{
get
{
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
}
set
{
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged)
{
Log.Logger.Debug("LogLevel.set attempt. No change");
return;
}
Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new
{
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled()
});
}
}
configuration.Reload();
Log.Logger.Information("Updated LogLevel MinimumLevel. {@DebugInfo}", new
{
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled()
});
}
}
#endregion
#region singleton stuff
public static Configuration Instance { get; } = new Configuration();
private Configuration() { }
#endregion
private Configuration() { }
#endregion
#region LibationFiles
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
private const string LIBATION_FILES_KEY = "LibationFiles";
#region LibationFiles
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
private const string LIBATION_FILES_KEY = "LibationFiles";
[Description("Location for storage of program-created files")]
public string LibationFiles
{
get
{
if (libationFilesPathCache is not null)
return libationFilesPathCache;
[Description("Location for storage of program-created files")]
public string LibationFiles
{
get
{
if (libationFilesPathCache is not null)
return libationFilesPathCache;
// FIRST: must write here before SettingsFilePath in next step reads cache
libationFilesPathCache = getLiberationFilesSettingFromJson();
// FIRST: must write here before SettingsFilePath in next step reads cache
libationFilesPathCache = getLibationFilesSettingFromJson();
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath);
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
var logPath = Path.Combine(LibationFiles, "Log.log");
// Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
var logPath = Path.Combine(LibationFiles, "Log.log");
// BAD: Serilog.WriteTo[1].Args
// "[1]" assumes ordinal position
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
// BAD: Serilog.WriteTo[1].Args
// "[1]" assumes ordinal position
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
SetWithJsonPath(jsonpath, "path", logPath, true);
SetWithJsonPath(jsonpath, "path", logPath, true);
return libationFilesPathCache;
}
}
return libationFilesPathCache;
}
}
private static string libationFilesPathCache;
private static string libationFilesPathCache { get; set; }
private string getLiberationFilesSettingFromJson()
{
string startingContents = null;
try
{
if (File.Exists(APPSETTINGS_JSON))
{
startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingJObj = JObject.Parse(startingContents);
private string getLibationFilesSettingFromJson()
{
string startingContents = null;
try
{
if (File.Exists(APPSETTINGS_JSON))
{
startingContents = File.ReadAllText(APPSETTINGS_JSON);
var startingJObj = JObject.Parse(startingContents);
if (startingJObj.ContainsKey(LIBATION_FILES_KEY))
{
var startingValue = startingJObj[LIBATION_FILES_KEY].Value<string>();
if (!string.IsNullOrWhiteSpace(startingValue))
return startingValue;
}
}
}
catch { }
if (startingJObj.ContainsKey(LIBATION_FILES_KEY))
{
var startingValue = startingJObj[LIBATION_FILES_KEY].Value<string>();
if (!string.IsNullOrWhiteSpace(startingValue))
return startingValue;
}
}
}
catch { }
// not found. write to file. read from file
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile } }.ToString(Formatting.Indented);
if (startingContents != endingContents)
{
File.WriteAllText(APPSETTINGS_JSON, endingContents);
System.Threading.Thread.Sleep(100);
}
// not found. write to file. read from file
var endingContents = new JObject { { LIBATION_FILES_KEY, UserProfile.ToString() } }.ToString(Formatting.Indented);
if (startingContents != endingContents)
{
File.WriteAllText(APPSETTINGS_JSON, endingContents);
System.Threading.Thread.Sleep(100);
}
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
// verify from live file. no try/catch. want failures to be visible
var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON));
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
return valueFinal;
}
// do not check whether directory exists. special/meta directory (eg: AppDir) is valid
// verify from live file. no try/catch. want failures to be visible
var jObjFinal = JObject.Parse(File.ReadAllText(APPSETTINGS_JSON));
var valueFinal = jObjFinal[LIBATION_FILES_KEY].Value<string>();
return valueFinal;
}
public void SetLibationFiles(string directory)
{
libationFilesPathCache = null;
public void SetLibationFiles(string directory)
{
libationFilesPathCache = null;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
// ensure exists
if (!File.Exists(APPSETTINGS_JSON))
{
// getter creates new file, loads PersistentDictionary
var _ = LibationFiles;
System.Threading.Thread.Sleep(100);
}
jObj[LIBATION_FILES_KEY] = directory;
var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
jObj[LIBATION_FILES_KEY] = directory;
// now it's set in the file again but no settings have moved yet
File.WriteAllText(APPSETTINGS_JSON, endingContents);
var endingContents = JsonConvert.SerializeObject(jObj, Formatting.Indented);
if (startingContents == endingContents)
return;
try
{
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
}
catch { }
}
#endregion
}
// now it's set in the file again but no settings have moved yet
File.WriteAllText(APPSETTINGS_JSON, endingContents);
try
{
Log.Logger.Information("Libation files changed {@DebugInfo}", new { APPSETTINGS_JSON, LIBATION_FILES_KEY, directory });
}
catch { }
}
#endregion
}
}

View File

@@ -3,13 +3,14 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core.Collections.Immutable;
using FileManager;
using Newtonsoft.Json;
namespace LibationFileManager
{
public static class FilePathCache
{
public record CacheEntry(string Id, FileType FileType, string Path);
public record CacheEntry(string Id, FileType FileType, LongPath Path);
private const string FILENAME = "FileLocations.json";
@@ -18,7 +19,7 @@ namespace LibationFileManager
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
private static LongPath jsonFile => Path.Combine(Configuration.Instance.LibationFiles, FILENAME);
static FilePathCache()
{
@@ -44,12 +45,12 @@ namespace LibationFileManager
public static bool Exists(string id, FileType type) => GetFirstPath(id, type) is not null;
public static List<(FileType fileType, string path)> GetFiles(string id)
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
=> getEntries(entry => entry.Id == id)
.Select(entry => (entry.FileType, entry.Path))
.ToList();
public static string GetFirstPath(string id, FileType type)
public static LongPath GetFirstPath(string id, FileType type)
=> getEntries(entry => entry.Id == id && entry.FileType == type)
?.FirstOrDefault()
?.Path;
@@ -62,7 +63,7 @@ namespace LibationFileManager
remove(entries.Where(e => !File.Exists(e.Path)).ToList());
return entries;
return cache.Where(predicate).ToList();
}
private static void remove(List<CacheEntry> entries)

View File

@@ -6,7 +6,7 @@
<ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="6.0.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.1.0" />
<PackageReference Include="Serilog.Exceptions" Version="8.2.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -28,16 +28,22 @@ namespace LibationFileManager
inMemoryState = JsonConvert.DeserializeObject<FilterState>(File.ReadAllText(JsonFile));
}
public static event EventHandler UseDefaultChanged;
public static bool UseDefault
{
get => inMemoryState.UseDefault;
set
{
if (UseDefault == value)
return;
lock (locker)
{
inMemoryState.UseDefault = value;
save(false);
}
UseDefaultChanged?.Invoke(null, null);
}
}

View File

@@ -8,277 +8,332 @@ using FileManager;
namespace LibationFileManager
{
public abstract class Templates
{
protected static string[] Valid => Array.Empty<string>();
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
public abstract class Templates
{
protected static string[] Valid => Array.Empty<string>();
public const string ERROR_NULL_IS_INVALID = "Null template is invalid.";
public const string ERROR_FULL_PATH_IS_INVALID = @"No colons or full paths allowed. Eg: should not start with C:\";
public const string ERROR_INVALID_FILE_NAME_CHAR = @"Only file name friendly characters allowed. Eg: no colons or slashes";
public const string WARNING_EMPTY = "Template is empty.";
public const string WARNING_WHITE_SPACE = "Template is white space.";
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
public const string WARNING_EMPTY = "Template is empty.";
public const string WARNING_WHITE_SPACE = "Template is white space.";
public const string WARNING_NO_TAGS = "Should use tags. Eg: <title>";
public const string WARNING_HAS_CHAPTER_TAGS = "Chapter tags should only be used in the template used for naming files which are split by chapter. Eg: <ch title>";
public const string WARNING_NO_CHAPTER_NUMBER_TAG = "Should include chapter number tag in template used for naming files which are split by chapter. Ie: <ch#> or <ch# 0>";
public static FolderTemplate Folder { get; } = new FolderTemplate();
public static FileTemplate File { get; } = new FileTemplate();
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
public static FolderTemplate Folder { get; } = new FolderTemplate();
public static FileTemplate File { get; } = new FileTemplate();
public static ChapterFileTemplate ChapterFile { get; } = new ChapterFileTemplate();
public static ChapterTitleTemplate ChapterTitle { get; } = new ChapterTitleTemplate();
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string DefaultTemplate { get; }
protected abstract bool IsChapterized { get; }
public abstract string Name { get; }
public abstract string Description { get; }
public abstract string DefaultTemplate { get; }
protected abstract bool IsChapterized { get; }
protected Templates() { }
protected Templates() { }
#region validation
internal string GetValid(string configValue)
{
var value = configValue?.Trim();
return IsValid(value) ? value : DefaultTemplate;
}
{
var value = configValue?.Trim();
return IsValid(value) ? value : DefaultTemplate;
}
public abstract IEnumerable<string> GetErrors(string template);
public bool IsValid(string template) => !GetErrors(template).Any();
public abstract IEnumerable<string> GetErrors(string template);
public bool IsValid(string template) => !GetErrors(template).Any();
public abstract IEnumerable<string> GetWarnings(string template);
public bool HasWarnings(string template) => GetWarnings(template).Any();
public abstract IEnumerable<string> GetWarnings(string template);
public bool HasWarnings(string template) => GetWarnings(template).Any();
protected static string[] GetFileErrors(string template)
{
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
protected static string[] GetFileErrors(string template)
{
// File name only; not path. all other path chars are valid enough to pass this check and will be handled on final save.
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
if (template.Contains(':')
|| template.Contains(Path.DirectorySeparatorChar)
|| template.Contains(Path.AltDirectorySeparatorChar)
)
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
if (ReplacementCharacters.ContainsInvalid(template.Replace("<","").Replace(">","")))
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
return Valid;
}
return Valid;
}
protected IEnumerable<string> GetStandardWarnings(string template)
{
var warnings = GetErrors(template).ToList();
if (template is null)
return warnings;
protected IEnumerable<string> GetStandardWarnings(string template)
{
var warnings = GetErrors(template).ToList();
if (template is null)
return warnings;
if (string.IsNullOrEmpty(template))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(template))
warnings.Add(WARNING_WHITE_SPACE);
if (string.IsNullOrEmpty(template))
warnings.Add(WARNING_EMPTY);
else if (string.IsNullOrWhiteSpace(template))
warnings.Add(WARNING_WHITE_SPACE);
if (TagCount(template) == 0)
warnings.Add(WARNING_NO_TAGS);
if (TagCount(template) == 0)
warnings.Add(WARNING_NO_TAGS);
if (!IsChapterized && ContainsChapterOnlyTags(template))
warnings.Add(WARNING_HAS_CHAPTER_TAGS);
if (!IsChapterized && ContainsChapterOnlyTags(template))
warnings.Add(WARNING_HAS_CHAPTER_TAGS);
return warnings;
}
return warnings;
}
internal int TagCount(string template)
=> GetTemplateTags()
// for <id><id> == 1, use:
// .Count(t => template.Contains($"<{t.TagName}>"))
// .Sum() impl: <id><id> == 2
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
internal int TagCount(string template)
=> GetTemplateTags()
// for <id><id> == 1, use:
// .Count(t => template.Contains($"<{t.TagName}>"))
// .Sum() impl: <id><id> == 2
.Sum(t => template.Split($"<{t.TagName}>").Length - 1);
internal static bool ContainsChapterOnlyTags(string template)
=> TemplateTags.GetAll()
.Where(t => t.IsChapterOnly)
.Any(t => ContainsTag(template, t.TagName));
internal static bool ContainsChapterOnlyTags(string template)
=> TemplateTags.GetAll()
.Where(t => t.IsChapterOnly)
.Any(t => ContainsTag(template, t.TagName));
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
#endregion
internal static bool ContainsTag(string template, string tag) => template.Contains($"<{tag}>");
#endregion
#region to file name
/// <summary>
/// EditTemplateDialog: Get template generated filename for portion of path
/// </summary>
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template)
=> string.IsNullOrWhiteSpace(template)
? ""
: getFileNamingTemplate(libraryBookDto, template, null, null)
.GetFilePath();
#region to file name
/// <summary>
/// EditTemplateDialog: Get template generated filename for portion of path
/// </summary>
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template)
=> string.IsNullOrWhiteSpace(template)
? ""
: getFileNamingTemplate(libraryBookDto, template, null, null)
.GetFilePath(Configuration.Instance.ReplacementCharacters).PathWithoutPrefix;
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
dirFullPath = dirFullPath?.Trim() ?? "";
dirFullPath = dirFullPath?.Trim() ?? "";
// for non-series, remove <if series-> and <-if series> tags and everything in between
// for series, remove <if series-> and <-if series> tags, what's in between will remain
template = ifSeriesRegex.Replace(
template,
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
// for non-series, remove <if series-> and <-if series> tags and everything in between
// for series, remove <if series-> and <-if series> tags, what's in between will remain
template = ifSeriesRegex.Replace(
template,
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
var t = template + FileUtility.GetStandardizedExtension(extension);
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
var t = template + FileUtility.GetStandardizedExtension(extension);
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
var fileNamingTemplate = new FileNamingTemplate(fullfilename) { IllegalCharacterReplacements = "_" };
var fileNamingTemplate = new FileNamingTemplate(fullfilename);
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Id, libraryBookDto.AudibleProductId);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Author, libraryBookDto.AuthorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstAuthor, libraryBookDto.FirstAuthor);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Narrator, libraryBookDto.NarratorNames);
fileNamingTemplate.AddParameterReplacement(TemplateTags.FirstNarrator, libraryBookDto.FirstNarrator);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.SeriesNumber, libraryBookDto.SeriesNumber);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
return fileNamingTemplate;
}
#endregion
return fileNamingTemplate;
}
#endregion
public IEnumerable<TemplateTags> GetTemplateTags()
=> TemplateTags.GetAll()
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
.Where(t => IsChapterized || !t.IsChapterOnly);
public virtual IEnumerable<TemplateTags> GetTemplateTags()
=> TemplateTags.GetAll()
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
.Where(t => IsChapterized || !t.IsChapterOnly);
public string Sanitize(string template)
{
var value = template ?? "";
public string Sanitize(string template)
{
var value = template ?? "";
// don't use alt slash
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
// don't use alt slash
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
// don't allow double slashes
var sing = $"{Path.DirectorySeparatorChar}";
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (value.Contains(dbl))
value = value.Replace(dbl, sing);
// don't allow double slashes
var sing = $"{Path.DirectorySeparatorChar}";
var dbl = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (value.Contains(dbl))
value = value.Replace(dbl, sing);
// trim. don't start or end with slash
while (true)
{
var start = value.Length;
value = value
.Trim()
.Trim(Path.DirectorySeparatorChar);
var end = value.Length;
if (start == end)
break;
}
// trim. don't start or end with slash
while (true)
{
var start = value.Length;
value = value
.Trim()
.Trim(Path.DirectorySeparatorChar);
var end = value.Length;
if (start == end)
break;
}
return value;
}
return value;
}
public class FolderTemplate : Templates
{
public class FolderTemplate : Templates
{
public override string Name => "Folder Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public override string Description => Configuration.GetDescription(nameof(Configuration.FolderTemplate));
public override string DefaultTemplate { get; } = "<title short> [<id>]";
protected override bool IsChapterized { get; } = false;
protected override bool IsChapterized { get; } = false;
internal FolderTemplate() : base() { }
internal FolderTemplate() : base() { }
#region validation
public override IEnumerable<string> GetErrors(string template)
{
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
#region validation
public override IEnumerable<string> GetErrors(string template)
{
// null is invalid. whitespace is valid but not recommended
if (template is null)
return new[] { ERROR_NULL_IS_INVALID };
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (template.Contains(':'))
return new[] { ERROR_FULL_PATH_IS_INVALID };
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (template.Contains(':'))
return new[] { ERROR_FULL_PATH_IS_INVALID };
return Valid;
}
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
if (ReplacementCharacters.ContainsInvalid(template.Replace("<", "").Replace(">", "")))
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, AudibleFileStorage.BooksDirectory, null)
.GetFilePath();
#endregion
}
return Valid;
}
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
public class FileTemplate : Templates
{
public override string Name => "File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>]";
protected override bool IsChapterized { get; } = false;
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string baseDir = null)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null)
.GetFilePath(Configuration.Instance.ReplacementCharacters);
#endregion
}
internal FileTemplate() : base() { }
public class FileTemplate : Templates
{
public override string Name => "File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.FileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>]";
protected override bool IsChapterized { get; } = false;
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
internal FileTemplate() : base() { }
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension, bool returnFirstExisting = false)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension)
.GetFilePath(returnFirstExisting);
#endregion
}
public override IEnumerable<string> GetWarnings(string template) => GetStandardWarnings(template);
#endregion
public class ChapterFileTemplate : Templates
{
public override string Name => "Chapter File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
protected override bool IsChapterized { get; } = true;
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension, bool returnFirstExisting = false)
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension)
.GetFilePath(Configuration.Instance.ReplacementCharacters, returnFirstExisting);
#endregion
}
internal ChapterFileTemplate() : base() { }
public class ChapterFileTemplate : Templates
{
public override string Name => "Chapter File Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterFileTemplate));
public override string DefaultTemplate { get; } = "<title> [<id>] - <ch# 0> - <ch title>";
protected override bool IsChapterized { get; } = true;
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
internal ChapterFileTemplate() : base() { }
public override IEnumerable<string> GetWarnings(string template)
{
var warnings = GetStandardWarnings(template).ToList();
if (template is null)
return warnings;
#region validation
public override IEnumerable<string> GetErrors(string template) => GetFileErrors(template);
// recommended to incl. <ch#> or <ch# 0>
if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName))
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
public override IEnumerable<string> GetWarnings(string template)
{
var warnings = GetStandardWarnings(template).ToList();
if (template is null)
return warnings;
return warnings;
}
#endregion
// recommended to incl. <ch#> or <ch# 0>
if (!ContainsTag(template, TemplateTags.ChNumber.TagName) && !ContainsTag(template, TemplateTags.ChNumber0.TagName))
warnings.Add(WARNING_NO_CHAPTER_NUMBER_TAG);
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
return warnings;
}
#endregion
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath)
{
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
#region to file name
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
public string GetFilename(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionFilename(libraryBookDto, Configuration.Instance.ChapterFileTemplate, props, AudibleFileStorage.DecryptInProgressDirectory);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props, string fullDirPath, ReplacementCharacters replacements = null)
{
replacements ??= Configuration.Instance.ReplacementCharacters;
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, Path.GetExtension(props.OutputFileName));
return fileNamingTemplate.GetFilePath();
}
#endregion
}
}
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
return fileNamingTemplate.GetFilePath(replacements).PathWithoutPrefix;
}
#endregion
}
public class ChapterTitleTemplate : Templates
{
private List<TemplateTags> _templateTags { get; } = new()
{
TemplateTags.Title,
TemplateTags.TitleShort,
TemplateTags.Series,
TemplateTags.ChCount,
TemplateTags.ChNumber,
TemplateTags.ChNumber0,
TemplateTags.ChTitle,
};
public override string Name => "Chapter Title Template";
public override string Description => Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
public override string DefaultTemplate => "<ch#> - <title short>: <ch title>";
protected override bool IsChapterized => true;
public override IEnumerable<string> GetErrors(string template)
=> new List<string>();
public override IEnumerable<string> GetWarnings(string template)
=> GetStandardWarnings(template).ToList();
public string GetTitle(LibraryBookDto libraryBookDto, AaxDecrypter.MultiConvertFileProperties props)
=> GetPortionTitle(libraryBookDto, Configuration.Instance.ChapterTitleTemplate, props);
public string GetPortionTitle(LibraryBookDto libraryBookDto, string template, AaxDecrypter.MultiConvertFileProperties props)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
var fileNamingTemplate = new MetadataNamingTemplate(template);
var title = libraryBookDto.Title ?? "";
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
fileNamingTemplate.AddParameterReplacement(TemplateTags.Title, title);
fileNamingTemplate.AddParameterReplacement(TemplateTags.TitleShort, titleShort);
fileNamingTemplate.AddParameterReplacement(TemplateTags.Series, libraryBookDto.SeriesName);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
return fileNamingTemplate.GetTagContents();
}
public override IEnumerable<TemplateTags> GetTemplateTags() => _templateTags;
}
}
}

View File

@@ -7,7 +7,7 @@ namespace LibationFileManager
{
public static class UtilityExtensions
{
public static void AddParameterReplacement(this FileNamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
public static void AddParameterReplacement(this NamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
=> fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value);
}
}

View File

@@ -18,20 +18,20 @@ namespace LibationSearchEngine
{
public const Lucene.Net.Util.Version Version = Lucene.Net.Util.Version.LUCENE_30;
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
public const string _ID_ = "_ID_";
public const string TAGS = "tags";
// special field for each book which includes all major parts of the book's metadata. enables non-targetting searching
public const string ALL = "all";
// the workaround which allows displaying all books when query is empty
public const string ALL_QUERY = "*:*";
#region index rules
// common fields used in the "all" default search field
public const string ALL_AUDIBLE_PRODUCT_ID = nameof(Book.AudibleProductId);
public const string ALL_TITLE = nameof(Book.Title);
public const string ALL_AUTHOR_NAMES = "AuthorNames";
public const string ALL_NARRATOR_NAMES = "NarratorNames";
public const string ALL_SERIES_NAMES = "SeriesNames";
#region index rules
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
private static ReadOnlyDictionary<string, Func<LibraryBook, string>> idIndexRules { get; }
= new ReadOnlyDictionary<string, Func<LibraryBook, string>>(
new Dictionary<string, Func<LibraryBook, string>>
{
@@ -50,31 +50,23 @@ namespace LibationSearchEngine
[nameof(Book.DatePublished)] = lb => lb.Book.DatePublished?.ToLuceneString(),
[nameof(Book.Title)] = lb => lb.Book.Title,
[nameof(Book.AuthorNames)] = lb => lb.Book.AuthorNames,
["Author"] = lb => lb.Book.AuthorNames,
["Authors"] = lb => lb.Book.AuthorNames,
[nameof(Book.NarratorNames)] = lb => lb.Book.NarratorNames,
["Narrator"] = lb => lb.Book.NarratorNames,
["Narrators"] = lb => lb.Book.NarratorNames,
[ALL_AUTHOR_NAMES] = lb => lb.Book.AuthorNames(),
["Author"] = lb => lb.Book.AuthorNames(),
["Authors"] = lb => lb.Book.AuthorNames(),
[ALL_NARRATOR_NAMES] = lb => lb.Book.NarratorNames(),
["Narrator"] = lb => lb.Book.NarratorNames(),
["Narrators"] = lb => lb.Book.NarratorNames(),
[nameof(Book.Publisher)] = lb => lb.Book.Publisher,
[nameof(Book.SeriesNames)] = lb => string.Join(
", ",
lb.Book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)),
["Series"] = lb => string.Join(
", ",
lb.Book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)),
[ALL_SERIES_NAMES] = lb => lb.Book.SeriesNames(),
["Series"] = lb => lb.Book.SeriesNames(),
["SeriesId"] = lb => string.Join(", ", lb.Book.SeriesLink.Select(s => s.Series.AudibleSeriesId)),
[nameof(Book.CategoriesNames)] = lb => lb.Book.CategoriesIds is null ? null : string.Join(", ", lb.Book.CategoriesIds),
[nameof(Book.Category)] = lb => lb.Book.CategoriesIds is null ? null : string.Join(", ", lb.Book.CategoriesIds),
["Categories"] = lb => lb.Book.CategoriesIds is null ? null : string.Join(", ", lb.Book.CategoriesIds),
["CategoriesId"] = lb => lb.Book.CategoriesIds is null ? null : string.Join(", ", lb.Book.CategoriesIds),
["CategoryId"] = lb => lb.Book.CategoriesIds is null ? null : string.Join(", ", lb.Book.CategoriesIds),
["CategoriesNames"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
[nameof(Book.Category)] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
["Categories"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
["CategoriesId"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
["CategoryId"] = lb => lb.Book.CategoriesIds() is null ? null : string.Join(", ", lb.Book.CategoriesIds()),
[TAGS.FirstCharToUpper()] = lb => lb.Book.UserDefinedItem.Tags,
@@ -107,14 +99,14 @@ namespace LibationSearchEngine
= new ReadOnlyDictionary<string, Func<LibraryBook, bool>>(
new Dictionary<string, Func<LibraryBook, bool>>
{
["HasDownloads"] = lb => lb.Book.HasPdf,
["HasDownload"] = lb => lb.Book.HasPdf,
["Downloads"] = lb => lb.Book.HasPdf,
["Download"] = lb => lb.Book.HasPdf,
["HasPDFs"] = lb => lb.Book.HasPdf,
["HasPDF"] = lb => lb.Book.HasPdf,
["PDFs"] = lb => lb.Book.HasPdf,
["PDF"] = lb => lb.Book.HasPdf,
["HasDownloads"] = lb => lb.Book.HasPdf(),
["HasDownload"] = lb => lb.Book.HasPdf(),
["Downloads"] = lb => lb.Book.HasPdf(),
["Download"] = lb => lb.Book.HasPdf(),
["HasPDFs"] = lb => lb.Book.HasPdf(),
["HasPDF"] = lb => lb.Book.HasPdf(),
["PDFs"] = lb => lb.Book.HasPdf(),
["PDF"] = lb => lb.Book.HasPdf(),
["IsRated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f,
["Rated"] = lb => lb.Book.UserDefinedItem.Rating.OverallRating > 0f,
@@ -125,15 +117,16 @@ namespace LibationSearchEngine
[nameof(Book.IsAbridged)] = lb => lb.Book.IsAbridged,
["Abridged"] = lb => lb.Book.IsAbridged,
// this will only be evaluated at time of re-index. ie: state of files moved later will be out of sync until next re-index
["IsLiberated"] = lb => isLiberated(lb.Book),
["Liberated"] = lb => isLiberated(lb.Book),
["LiberatedError"] = lb => liberatedError(lb.Book),
["Podcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsPodcast"] = lb => lb.Book.ContentType == ContentType.Episode,
["Episode"] = lb => lb.Book.ContentType == ContentType.Episode,
["IsEpisode"] = lb => lb.Book.ContentType == ContentType.Episode,
["Podcast"] = lb => lb.Book.IsEpisodeChild(),
["Podcasts"] = lb => lb.Book.IsEpisodeChild(),
["IsPodcast"] = lb => lb.Book.IsEpisodeChild(),
["Episode"] = lb => lb.Book.IsEpisodeChild(),
["Episodes"] = lb => lb.Book.IsEpisodeChild(),
["IsEpisode"] = lb => lb.Book.IsEpisodeChild(),
}
);
@@ -151,10 +144,11 @@ namespace LibationSearchEngine
private static IEnumerable<Func<LibraryBook, string>> allFieldIndexRules { get; }
= new List<Func<LibraryBook, string>>
{
idIndexRules[nameof(Book.AudibleProductId)],
stringIndexRules[nameof(Book.Title)],
stringIndexRules[nameof(Book.AuthorNames)],
stringIndexRules[nameof(Book.NarratorNames)]
idIndexRules[ALL_AUDIBLE_PRODUCT_ID],
stringIndexRules[ALL_TITLE],
stringIndexRules[ALL_AUTHOR_NAMES],
stringIndexRules[ALL_NARRATOR_NAMES],
stringIndexRules[ALL_SERIES_NAMES]
};
#endregion
@@ -182,18 +176,6 @@ namespace LibationSearchEngine
foreach (var key in numberIndexRules.Keys)
yield return key;
}
public static IEnumerable<string> GetSearchFields()
{
foreach (var key in idIndexRules.Keys)
yield return key;
foreach (var key in stringIndexRules.Keys)
yield return key;
foreach (var key in boolIndexRules.Keys)
yield return key;
foreach (var key in numberIndexRules.Keys)
yield return key;
}
#endregion
#region create and update index
@@ -290,6 +272,10 @@ namespace LibationSearchEngine
book.AudibleProductId,
d =>
{
//
// TODO: better synonym handling. This is too easy to mess up
//
// fields are key value pairs. MULTIPLE FIELDS CAN POTENTIALLY HAVE THE SAME KEY.
// ie: must remove old before adding new else will create unwanted duplicates.
var v1 = isLiberated(book);
@@ -331,6 +317,9 @@ namespace LibationSearchEngine
}
#endregion
// the workaround which allows displaying all books when query is empty
public const string ALL_QUERY = "*:*";
#region search
public SearchResultSet Search(string searchString)
{
@@ -345,7 +334,7 @@ namespace LibationSearchEngine
return results;
}
public static string FormatSearchQuery(string searchString)
internal static string FormatSearchQuery(string searchString)
{
if (string.IsNullOrWhiteSpace(searchString))
return ALL_QUERY;
@@ -491,5 +480,9 @@ namespace LibationSearchEngine
#endregion
private static Directory getIndex() => FSDirectory.Open(SearchEngineDirectory);
// not customizable. don't move to config
private static string SearchEngineDirectory { get; }
= new System.IO.DirectoryInfo(Configuration.Instance.LibationFiles).CreateSubdirectory("SearchEngine").FullName;
}
}

View File

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

View File

@@ -1,104 +0,0 @@
namespace LibationWinForms.BookLiberation
{
partial class AudioDecodeForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.pictureBox1 = new System.Windows.Forms.PictureBox();
this.bookInfoLbl = new System.Windows.Forms.Label();
this.progressBar1 = new System.Windows.Forms.ProgressBar();
this.remainingTimeLbl = new System.Windows.Forms.Label();
((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).BeginInit();
this.SuspendLayout();
//
// pictureBox1
//
this.pictureBox1.Location = new System.Drawing.Point(14, 14);
this.pictureBox1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.pictureBox1.Name = "pictureBox1";
this.pictureBox1.Size = new System.Drawing.Size(117, 115);
this.pictureBox1.SizeMode = System.Windows.Forms.PictureBoxSizeMode.StretchImage;
this.pictureBox1.TabIndex = 0;
this.pictureBox1.TabStop = false;
//
// bookInfoLbl
//
this.bookInfoLbl.AutoSize = true;
this.bookInfoLbl.Location = new System.Drawing.Point(138, 14);
this.bookInfoLbl.Margin = new System.Windows.Forms.Padding(4, 0, 4, 0);
this.bookInfoLbl.Name = "bookInfoLbl";
this.bookInfoLbl.Size = new System.Drawing.Size(121, 15);
this.bookInfoLbl.TabIndex = 0;
this.bookInfoLbl.Text = "[multi-line book info]";
//
// progressBar1
//
this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.progressBar1.Location = new System.Drawing.Point(14, 143);
this.progressBar1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.progressBar1.Name = "progressBar1";
this.progressBar1.Size = new System.Drawing.Size(611, 27);
this.progressBar1.TabIndex = 2;
//
// remainingTimeLbl
//
this.remainingTimeLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.remainingTimeLbl.Location = new System.Drawing.Point(632, 143);
this.remainingTimeLbl.Name = "remainingTimeLbl";
this.remainingTimeLbl.Size = new System.Drawing.Size(60, 31);
this.remainingTimeLbl.TabIndex = 3;
this.remainingTimeLbl.Text = "ETA:\r\n";
this.remainingTimeLbl.TextAlign = System.Drawing.ContentAlignment.TopRight;
//
// DecryptForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(707, 183);
this.Controls.Add(this.remainingTimeLbl);
this.Controls.Add(this.progressBar1);
this.Controls.Add(this.bookInfoLbl);
this.Controls.Add(this.pictureBox1);
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.Name = "DecryptForm";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "DecryptForm";
((System.ComponentModel.ISupportInitialize)(this.pictureBox1)).EndInit();
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.PictureBox pictureBox1;
private System.Windows.Forms.Label bookInfoLbl;
private System.Windows.Forms.ProgressBar progressBar1;
private System.Windows.Forms.Label remainingTimeLbl;
}
}

View File

@@ -1,114 +0,0 @@
using System;
using DataLayer;
using Dinah.Core.Net.Http;
using Dinah.Core.Threading;
using LibationFileManager;
using LibationWinForms.BookLiberation.BaseForms;
namespace LibationWinForms.BookLiberation
{
public partial class AudioDecodeForm : LiberationBaseForm
{
public virtual string DecodeActionName { get; } = "Decoding";
public AudioDecodeForm() => InitializeComponent();
private Func<byte[]> GetCoverArtDelegate;
#region Processable event handler overrides
public override void Processable_Begin(object sender, LibraryBook libraryBook)
{
base.Processable_Begin(sender, libraryBook);
GetCoverArtDelegate = () => PictureStorage.GetPictureSynchronously(
new PictureDefinition(
libraryBook.Book.PictureId,
PictureSize._500x500));
//Set default values from library
AudioDecodable_TitleDiscovered(sender, libraryBook.Book.Title);
AudioDecodable_AuthorsDiscovered(sender, libraryBook.Book.AuthorNames);
AudioDecodable_NarratorsDiscovered(sender, libraryBook.Book.NarratorNames);
AudioDecodable_CoverImageDiscovered(sender,
PictureStorage.GetPicture(
new PictureDefinition(
libraryBook.Book.PictureId,
PictureSize._80x80)).bytes);
}
#endregion
#region Streamable event handler overrides
public override void Streamable_StreamingProgressChanged(object sender, DownloadProgress downloadProgress)
{
base.Streamable_StreamingProgressChanged(sender, downloadProgress);
if (!downloadProgress.ProgressPercentage.HasValue)
return;
if (downloadProgress.ProgressPercentage == 0)
updateRemainingTime(0);
else
progressBar1.UIThreadAsync(() => progressBar1.Value = (int)downloadProgress.ProgressPercentage);
}
public override void Streamable_StreamingTimeRemaining(object sender, TimeSpan timeRemaining)
{
base.Streamable_StreamingTimeRemaining(sender, timeRemaining);
updateRemainingTime((int)timeRemaining.TotalSeconds);
}
private void updateRemainingTime(int remaining)
=> remainingTimeLbl.UIThreadAsync(() => remainingTimeLbl.Text = $"ETA:\r\n{formatTime(remaining)}");
private string formatTime(int seconds)
{
var timeSpan = new TimeSpan(0, 0, seconds);
return
timeSpan.TotalHours >= 1 ? $"{timeSpan:%h}h {timeSpan:mm}m {timeSpan:ss}s"
: timeSpan.TotalMinutes >= 1 ? $"{timeSpan:%m}m {timeSpan:ss}s"
: $"{seconds} sec";
}
#endregion
#region AudioDecodable event handlers
private string title;
private string authorNames;
private string narratorNames;
public override void AudioDecodable_TitleDiscovered(object sender, string title)
{
base.AudioDecodable_TitleDiscovered(sender, title);
this.UIThreadAsync(() => this.Text = DecodeActionName + " " + title);
this.title = title;
updateBookInfo();
}
public override void AudioDecodable_AuthorsDiscovered(object sender, string authors)
{
base.AudioDecodable_AuthorsDiscovered(sender, authors);
authorNames = authors;
updateBookInfo();
}
public override void AudioDecodable_NarratorsDiscovered(object sender, string narrators)
{
base.AudioDecodable_NarratorsDiscovered(sender, narrators);
narratorNames = narrators;
updateBookInfo();
}
private void updateBookInfo()
=> bookInfoLbl.UIThreadAsync(() => bookInfoLbl.Text = $"{title}\r\nBy {authorNames}\r\nNarrated by {narratorNames}");
public override void AudioDecodable_RequestCoverArt(object sender, Action<byte[]> setCoverArtDelegate)
{
base.AudioDecodable_RequestCoverArt(sender, setCoverArtDelegate);
setCoverArtDelegate(GetCoverArtDelegate?.Invoke());
}
public override void AudioDecodable_CoverImageDiscovered(object sender, byte[] coverArt)
{
base.AudioDecodable_CoverImageDiscovered(sender, coverArt);
pictureBox1.UIThreadAsync(() => pictureBox1.Image = Dinah.Core.Drawing.ImageReader.ToImage(coverArt));
}
#endregion
}
}

View File

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

View File

@@ -1,93 +0,0 @@
namespace LibationWinForms.BookLiberation
{
partial class AutomatedBackupsForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.keepGoingCb = new System.Windows.Forms.CheckBox();
this.logTb = new System.Windows.Forms.TextBox();
this.label1 = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// keepGoingCb
//
this.keepGoingCb.AutoSize = true;
this.keepGoingCb.Checked = true;
this.keepGoingCb.CheckState = System.Windows.Forms.CheckState.Checked;
this.keepGoingCb.Location = new System.Drawing.Point(12, 12);
this.keepGoingCb.Name = "keepGoingCb";
this.keepGoingCb.Size = new System.Drawing.Size(325, 17);
this.keepGoingCb.TabIndex = 0;
this.keepGoingCb.Text = "Keep going. Uncheck to stop when current backup is complete";
this.keepGoingCb.UseVisualStyleBackColor = true;
//
// logTb
//
this.logTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.logTb.Location = new System.Drawing.Point(12, 48);
this.logTb.Multiline = true;
this.logTb.Name = "logTb";
this.logTb.ReadOnly = true;
this.logTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.logTb.Size = new System.Drawing.Size(960, 202);
this.logTb.TabIndex = 1;
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(9, 32);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(501, 13);
this.label1.TabIndex = 2;
this.label1.Text = "NOTE: if the working directories are inside of Dropbox, some book liberation acti" +
"ons may hang indefinitely";
//
// AutomatedBackupsForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(984, 262);
this.Controls.Add(this.label1);
this.Controls.Add(this.logTb);
this.Controls.Add(this.keepGoingCb);
this.Name = "AutomatedBackupsForm";
this.Text = "Automated Backups";
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.AutomatedBackupsForm_FormClosing);
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.CheckBox keepGoingCb;
private System.Windows.Forms.TextBox logTb;
private System.Windows.Forms.Label label1;
}
}

View File

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

View File

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

View File

@@ -1,109 +0,0 @@
using DataLayer;
using System;
namespace LibationWinForms.BookLiberation
{
partial class DownloadForm
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
this.filenameLbl = new System.Windows.Forms.Label();
this.progressBar1 = new System.Windows.Forms.ProgressBar();
this.progressLbl = new System.Windows.Forms.Label();
this.lastUpdateLbl = new System.Windows.Forms.Label();
this.SuspendLayout();
//
// filenameLbl
//
this.filenameLbl.AutoSize = true;
this.filenameLbl.Location = new System.Drawing.Point(12, 9);
this.filenameLbl.Name = "filenameLbl";
this.filenameLbl.Size = new System.Drawing.Size(52, 13);
this.filenameLbl.TabIndex = 0;
this.filenameLbl.Text = "[filename]";
//
// progressBar1
//
this.progressBar1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.progressBar1.Location = new System.Drawing.Point(15, 67);
this.progressBar1.Name = "progressBar1";
this.progressBar1.Size = new System.Drawing.Size(877, 23);
this.progressBar1.TabIndex = 4;
//
// progressLbl
//
this.progressLbl.Location = new System.Drawing.Point(12, 36);
this.progressLbl.Name = "progressLbl";
this.progressLbl.Size = new System.Drawing.Size(173, 13);
this.progressLbl.TabIndex = 5;
this.progressLbl.Text = "[2,999,999,999] of [2,999,999,999]";
this.progressLbl.TextAlign = System.Drawing.ContentAlignment.TopRight;
//
// lastUpdateLbl
//
this.lastUpdateLbl.AutoSize = true;
this.lastUpdateLbl.Font = new System.Drawing.Font("Microsoft Sans Serif", 8.25F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point, ((byte)(0)));
this.lastUpdateLbl.ForeColor = System.Drawing.Color.DarkRed;
this.lastUpdateLbl.Location = new System.Drawing.Point(361, 36);
this.lastUpdateLbl.Name = "lastUpdateLbl";
this.lastUpdateLbl.Size = new System.Drawing.Size(81, 13);
this.lastUpdateLbl.TabIndex = 6;
this.lastUpdateLbl.Text = "Last updated";
this.lastUpdateLbl.Visible = false;
//
// DownloadForm
//
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.ClientSize = new System.Drawing.Size(904, 102);
this.Controls.Add(this.lastUpdateLbl);
this.Controls.Add(this.progressLbl);
this.Controls.Add(this.progressBar1);
this.Controls.Add(this.filenameLbl);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "DownloadForm";
this.ShowIcon = false;
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Downloading";
this.FormClosing += new System.Windows.Forms.FormClosingEventHandler(this.DownloadForm_FormClosing);
this.Load += new System.EventHandler(this.DownloadForm_Load);
this.ResumeLayout(false);
this.PerformLayout();
}
#endregion
private System.Windows.Forms.Label filenameLbl;
private System.Windows.Forms.ProgressBar progressBar1;
private System.Windows.Forms.Label progressLbl;
private System.Windows.Forms.Label lastUpdateLbl;
}
}

View File

@@ -1,67 +0,0 @@
using System;
using System.Windows.Forms;
using Dinah.Core.Net.Http;
using Dinah.Core.Threading;
using LibationWinForms.BookLiberation.BaseForms;
namespace LibationWinForms.BookLiberation
{
public partial class DownloadForm : LiberationBaseForm
{
public DownloadForm()
{
InitializeComponent();
progressLbl.Text = "";
filenameLbl.Text = "";
}
#region Streamable event handler overrides
public override void Streamable_StreamingBegin(object sender, string beginString)
{
base.Streamable_StreamingBegin(sender, beginString);
filenameLbl.UIThreadAsync(() => filenameLbl.Text = beginString);
}
public override void Streamable_StreamingProgressChanged(object sender, DownloadProgress downloadProgress)
{
base.Streamable_StreamingProgressChanged(sender, downloadProgress);
// this won't happen with download file. it will happen with download string
if (!downloadProgress.TotalBytesToReceive.HasValue || downloadProgress.TotalBytesToReceive.Value <= 0)
return;
progressLbl.UIThreadAsync(() => progressLbl.Text = $"{downloadProgress.BytesReceived:#,##0} of {downloadProgress.TotalBytesToReceive.Value:#,##0}");
var d = double.Parse(downloadProgress.BytesReceived.ToString()) / double.Parse(downloadProgress.TotalBytesToReceive.Value.ToString()) * 100.0;
var i = int.Parse(Math.Truncate(d).ToString());
progressBar1.UIThreadAsync(() => progressBar1.Value = i);
lastDownloadProgress = DateTime.Now;
}
#endregion
#region timer
private Timer timer { get; } = new Timer { Interval = 1000 };
private void DownloadForm_Load(object sender, EventArgs e)
{
timer.Tick += new EventHandler(timer_Tick);
timer.Start();
}
private DateTime lastDownloadProgress = DateTime.Now;
private void timer_Tick(object sender, EventArgs e)
{
// if no update in the last 30 seconds, display frozen label
lastUpdateLbl.UIThreadAsync(() => lastUpdateLbl.Visible = lastDownloadProgress.AddSeconds(30) < DateTime.Now);
if (lastUpdateLbl.Visible)
{
var diff = DateTime.Now - lastDownloadProgress;
var min = (int)diff.TotalMinutes;
var minText = min > 0 ? $"{min}min " : "";
lastUpdateLbl.UIThreadAsync(() => lastUpdateLbl.Text = $"Frozen? Last download activity: {minText}{diff.Seconds}sec ago");
}
}
private void DownloadForm_FormClosing(object sender, FormClosingEventArgs e) => timer.Stop();
#endregion
}
}

View File

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

View File

@@ -1,18 +0,0 @@
using DataLayer;
namespace LibationWinForms.BookLiberation
{
internal class PdfDownloadForm : DownloadForm
{
public override void Processable_Begin(object sender, LibraryBook libraryBook)
{
base.Processable_Begin(sender, libraryBook);
LogMe.Info($"PDF Step, Begin: {libraryBook.Book}");
}
public override void Processable_Completed(object sender, LibraryBook libraryBook)
{
base.Processable_Completed(sender, libraryBook);
LogMe.Info($"PDF Step, Completed: {libraryBook.Book}");
}
}
}

View File

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

View File

@@ -1,343 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Forms;
using DataLayer;
using Dinah.Core;
using FileLiberator;
using LibationFileManager;
using LibationWinForms.BookLiberation.BaseForms;
namespace LibationWinForms.BookLiberation
{
// decouple serilog and form. include convenience factory method
public class LogMe
{
public event EventHandler<string> LogInfo;
public event EventHandler<string> LogErrorString;
public event EventHandler<(Exception, string)> LogError;
private LogMe()
{
LogInfo += (_, text) => Serilog.Log.Logger.Information($"Automated backup: {text}");
LogErrorString += (_, text) => Serilog.Log.Logger.Error(text);
LogError += (_, tuple) => Serilog.Log.Logger.Error(tuple.Item1, tuple.Item2 ?? "Automated backup: error");
}
public static LogMe RegisterForm(AutomatedBackupsForm form = null)
{
var logMe = new LogMe();
if (form is null)
return logMe;
logMe.LogInfo += (_, text) => form?.WriteLine(text);
logMe.LogErrorString += (_, text) => form?.WriteLine(text);
logMe.LogError += (_, tuple) =>
{
form?.WriteLine(tuple.Item2 ?? "Automated backup: error");
form?.WriteLine("ERROR: " + tuple.Item1.Message);
};
return logMe;
}
public void Info(string text) => LogInfo?.Invoke(this, text);
public void Error(string text) => LogErrorString?.Invoke(this, text);
public void Error(Exception ex, string text = null) => LogError?.Invoke(this, (ex, text));
}
public static class ProcessorAutomationController
{
public static async Task BackupSingleBookAsync(LibraryBook libraryBook)
{
Serilog.Log.Logger.Information($"Begin {nameof(BackupSingleBookAsync)} {{@DebugInfo}}", new { libraryBook?.Book?.AudibleProductId });
var logMe = LogMe.RegisterForm();
var backupBook = CreateBackupBook(logMe);
// continue even if libraryBook is null. we'll display even that in the processing box
await new BackupSingle(logMe, backupBook, libraryBook).RunBackupAsync();
}
public static async Task BackupAllBooksAsync(List<LibraryBook> libraryBooks = null)
{
Serilog.Log.Logger.Information("Begin " + nameof(BackupAllBooksAsync));
var automatedBackupsForm = new AutomatedBackupsForm();
var logMe = LogMe.RegisterForm(automatedBackupsForm);
var backupBook = CreateBackupBook(logMe);
await new BackupLoop(logMe, backupBook, automatedBackupsForm, libraryBooks).RunBackupAsync();
}
public static async Task ConvertAllBooksAsync()
{
Serilog.Log.Logger.Information("Begin " + nameof(ConvertAllBooksAsync));
var automatedBackupsForm = new AutomatedBackupsForm();
var logMe = LogMe.RegisterForm(automatedBackupsForm);
var convertBook = CreateProcessable<ConvertToMp3, AudioConvertForm>(logMe);
await new BackupLoop(logMe, convertBook, automatedBackupsForm).RunBackupAsync();
}
public static async Task BackupAllPdfsAsync()
{
Serilog.Log.Logger.Information("Begin " + nameof(BackupAllPdfsAsync));
var automatedBackupsForm = new AutomatedBackupsForm();
var logMe = LogMe.RegisterForm(automatedBackupsForm);
var downloadPdf = CreateProcessable<DownloadPdf, PdfDownloadForm>(logMe);
await new BackupLoop(logMe, downloadPdf, automatedBackupsForm).RunBackupAsync();
}
private static Processable CreateBackupBook(LogMe logMe)
{
var downloadPdf = CreateProcessable<DownloadPdf, PdfDownloadForm>(logMe);
//Chain pdf download on DownloadDecryptBook.Completed
async void onDownloadDecryptBookCompleted(object sender, LibraryBook e)
{
await downloadPdf.TryProcessAsync(e);
}
var downloadDecryptBook = CreateProcessable<DownloadDecryptBook, AudioDecryptForm>(logMe, onDownloadDecryptBookCompleted);
return downloadDecryptBook;
}
public static void DownloadFile(string url, string destination, bool showDownloadCompletedDialog = false)
{
Serilog.Log.Logger.Information($"Begin {nameof(DownloadFile)} for {url}");
void onDownloadFileStreamingCompleted(object sender, string savedFile)
{
Serilog.Log.Logger.Information($"Completed {nameof(DownloadFile)} for {url}. Saved to {savedFile}");
if (showDownloadCompletedDialog)
MessageBox.Show($"File downloaded to:{Environment.NewLine}{Environment.NewLine}{savedFile}");
}
var downloadFile = new DownloadFile();
var downloadForm = new DownloadForm();
downloadForm.RegisterFileLiberator(downloadFile);
downloadFile.StreamingCompleted += onDownloadFileStreamingCompleted;
async void runDownload() => await downloadFile.PerformDownloadFileAsync(url, destination);
new Task(runDownload).Start();
}
/// <summary>
/// Create a new <see cref="Processable"/> and links it to a new <see cref="LiberationBaseForm"/>.
/// </summary>
/// <typeparam name="TProcessable">The <see cref="Processable"/> derived type to create.</typeparam>
/// <typeparam name="TForm">The <see cref="LiberationBaseForm"/> derived Form to create on <see cref="Processable.Begin"/>, Show on <see cref="Streamable.StreamingBegin"/>, Close on <see cref="Streamable.StreamingCompleted"/>, and Dispose on <see cref="Processable.Completed"/> </typeparam>
/// <param name="logMe">The logger</param>
/// <param name="completedAction">An additional event handler to handle <see cref="Processable.Completed"/></param>
/// <returns>A new <see cref="Processable"/> of type <typeparamref name="TProcessable"/></returns>
private static TProcessable CreateProcessable<TProcessable, TForm>(LogMe logMe, EventHandler<LibraryBook> completedAction = null)
where TForm : LiberationBaseForm, new()
where TProcessable : Processable, new()
{
var strProc = new TProcessable();
strProc.Begin += (sender, libraryBook) =>
{
var processForm = new TForm();
processForm.RegisterFileLiberator(strProc, logMe);
processForm.Processable_Begin(sender, libraryBook);
};
strProc.Completed += completedAction;
return strProc;
}
}
internal abstract class BackupRunner
{
protected LogMe LogMe { get; }
protected Processable Processable { get; }
protected AutomatedBackupsForm AutomatedBackupsForm { get; }
protected BackupRunner(LogMe logMe, Processable processable, AutomatedBackupsForm automatedBackupsForm = null)
{
LogMe = logMe;
Processable = processable;
AutomatedBackupsForm = automatedBackupsForm;
}
protected abstract Task RunAsync();
protected abstract string SkipDialogText { get; }
protected abstract MessageBoxButtons SkipDialogButtons { get; }
protected abstract MessageBoxDefaultButton SkipDialogDefaultButton { get; }
protected abstract DialogResult SkipResult { get; }
public async Task RunBackupAsync()
{
AutomatedBackupsForm?.Show();
try
{
await RunAsync();
}
catch (Exception ex)
{
LogMe.Error(ex);
}
AutomatedBackupsForm?.FinalizeUI();
LogMe.Info("DONE");
}
protected async Task<bool> ProcessOneAsync(LibraryBook libraryBook, bool validate)
{
try
{
var statusHandler = await Processable.ProcessSingleAsync(libraryBook, validate);
if (statusHandler.IsSuccess)
return true;
foreach (var errorMessage in statusHandler.Errors)
LogMe.Error(errorMessage);
}
catch (Exception ex)
{
LogMe.Error(ex);
}
return showRetry(libraryBook);
}
private bool showRetry(LibraryBook libraryBook)
{
LogMe.Error("ERROR. All books have not been processed. Most recent book: processing failed");
DialogResult? dialogResult = Configuration.Instance.BadBook switch
{
Configuration.BadBookAction.Abort => DialogResult.Abort,
Configuration.BadBookAction.Retry => DialogResult.Retry,
Configuration.BadBookAction.Ignore => DialogResult.Ignore,
Configuration.BadBookAction.Ask => null,
_ => null
};
string details;
try
{
static string trunc(string str)
=> string.IsNullOrWhiteSpace(str) ? "[empty]"
: (str.Length > 50) ? $"{str.Truncate(47)}..."
: str;
details =
$@" Title: {libraryBook.Book.Title}
ID: {libraryBook.Book.AudibleProductId}
Author: {trunc(libraryBook.Book.AuthorNames)}
Narr: {trunc(libraryBook.Book.NarratorNames)}";
}
catch
{
details = "[Error retrieving details]";
}
// if null then ask user
dialogResult ??= MessageBox.Show(string.Format(SkipDialogText + "\r\n\r\nSee Settings to avoid this box in the future.", details), "Skip importing this book?", SkipDialogButtons, MessageBoxIcon.Question, SkipDialogDefaultButton);
if (dialogResult == DialogResult.Abort)
return false;
if (dialogResult == SkipResult)
{
libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Error;
ApplicationServices.LibraryCommands.UpdateUserDefinedItem(libraryBook.Book);
LogMe.Info($"Error. Skip: [{libraryBook.Book.AudibleProductId}] {libraryBook.Book.Title}");
}
return true;
}
}
internal class BackupSingle : BackupRunner
{
private LibraryBook _libraryBook { get; }
protected override string SkipDialogText => @"
An error occurred while trying to process this book. Skip this book permanently?
{0}
- Click YES to skip this book permanently.
- Click NO to skip the book this time only. We'll try again later.
".Trim();
protected override MessageBoxButtons SkipDialogButtons => MessageBoxButtons.YesNo;
protected override MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button2;
protected override DialogResult SkipResult => DialogResult.Yes;
public BackupSingle(LogMe logMe, Processable processable, LibraryBook libraryBook)
: base(logMe, processable)
{
_libraryBook = libraryBook;
}
protected override async Task RunAsync()
{
if (_libraryBook is not null)
await ProcessOneAsync(_libraryBook, validate: true);
}
}
internal class BackupLoop : BackupRunner
{
protected override string SkipDialogText => @"
An error occurred while trying to process this book.
{0}
- ABORT: Stop processing books.
- RETRY: retry this book later. Just skip it for now. Continue processing books. (Will try this book again later.)
- IGNORE: Permanently ignore this book. Continue processing books. (Will not try this book again later.)
".Trim();
protected override MessageBoxButtons SkipDialogButtons => MessageBoxButtons.AbortRetryIgnore;
protected override MessageBoxDefaultButton SkipDialogDefaultButton => MessageBoxDefaultButton.Button1;
protected override DialogResult SkipResult => DialogResult.Ignore;
private List<LibraryBook> libraryBooks { get; }
public BackupLoop(LogMe logMe, Processable processable, AutomatedBackupsForm automatedBackupsForm, List<LibraryBook> libraryBooks = null)
: base(logMe, processable, automatedBackupsForm)
=> this.libraryBooks = libraryBooks ?? ApplicationServices.DbContexts.GetLibrary_Flat_NoTracking();
protected override async Task RunAsync()
{
// support for 'skip this time only' requires state. iterators provide this state for free. therefore: use foreach/iterator here
foreach (var libraryBook in Processable.GetValidLibraryBooks(libraryBooks))
{
var keepGoing = await ProcessOneAsync(libraryBook, validate: false);
if (!keepGoing)
return;
if (AutomatedBackupsForm.IsDisposed)
break;
if (!AutomatedBackupsForm.KeepGoing)
{
if (!AutomatedBackupsForm.KeepGoingChecked)
LogMe.Info("'Keep going' is unchecked");
return;
}
}
LogMe.Info("Done. All books have been processed");
}
}
}

View File

@@ -31,7 +31,9 @@
this.cancelBtn = new System.Windows.Forms.Button();
this.saveBtn = new System.Windows.Forms.Button();
this.dataGridView1 = new System.Windows.Forms.DataGridView();
this.importBtn = new System.Windows.Forms.Button();
this.DeleteAccount = new System.Windows.Forms.DataGridViewButtonColumn();
this.ExportAccount = new System.Windows.Forms.DataGridViewButtonColumn();
this.LibraryScan = new System.Windows.Forms.DataGridViewCheckBoxColumn();
this.AccountId = new System.Windows.Forms.DataGridViewTextBoxColumn();
this.Locale = new System.Windows.Forms.DataGridViewComboBoxColumn();
@@ -43,9 +45,10 @@
//
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.cancelBtn.Location = new System.Drawing.Point(713, 415);
this.cancelBtn.Location = new System.Drawing.Point(832, 479);
this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.cancelBtn.Name = "cancelBtn";
this.cancelBtn.Size = new System.Drawing.Size(75, 23);
this.cancelBtn.Size = new System.Drawing.Size(88, 27);
this.cancelBtn.TabIndex = 2;
this.cancelBtn.Text = "Cancel";
this.cancelBtn.UseVisualStyleBackColor = true;
@@ -54,9 +57,10 @@
// saveBtn
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(612, 415);
this.saveBtn.Location = new System.Drawing.Point(714, 479);
this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.Size = new System.Drawing.Size(88, 27);
this.saveBtn.TabIndex = 1;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
@@ -71,60 +75,83 @@
this.dataGridView1.ColumnHeadersHeightSizeMode = System.Windows.Forms.DataGridViewColumnHeadersHeightSizeMode.AutoSize;
this.dataGridView1.Columns.AddRange(new System.Windows.Forms.DataGridViewColumn[] {
this.DeleteAccount,
this.ExportAccount,
this.LibraryScan,
this.AccountId,
this.Locale,
this.AccountName});
this.dataGridView1.Location = new System.Drawing.Point(12, 12);
this.dataGridView1.Location = new System.Drawing.Point(14, 14);
this.dataGridView1.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.dataGridView1.MultiSelect = false;
this.dataGridView1.Name = "dataGridView1";
this.dataGridView1.Size = new System.Drawing.Size(776, 397);
this.dataGridView1.Size = new System.Drawing.Size(905, 458);
this.dataGridView1.TabIndex = 0;
this.dataGridView1.CellContentClick += new System.Windows.Forms.DataGridViewCellEventHandler(this.DataGridView1_CellContentClick);
this.dataGridView1.DefaultValuesNeeded += new System.Windows.Forms.DataGridViewRowEventHandler(this.dataGridView1_DefaultValuesNeeded);
//
// importBtn
//
this.importBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.importBtn.Location = new System.Drawing.Point(14, 480);
this.importBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.importBtn.Name = "importBtn";
this.importBtn.Size = new System.Drawing.Size(156, 27);
this.importBtn.TabIndex = 1;
this.importBtn.Text = "Import from audible-cli";
this.importBtn.UseVisualStyleBackColor = true;
this.importBtn.Click += new System.EventHandler(this.importBtn_Click);
//
// DeleteAccount
//
this.DeleteAccount.HeaderText = "Delete";
this.DeleteAccount.Name = "DeleteAccount";
this.DeleteAccount.ReadOnly = true;
this.DeleteAccount.Text = "x";
this.DeleteAccount.Width = 44;
this.DeleteAccount.Width = 46;
//
// ExportAccount
//
this.ExportAccount.HeaderText = "Export";
this.ExportAccount.Name = "ExportAccount";
this.ExportAccount.Text = "Export to audible-cli";
this.ExportAccount.Width = 47;
//
// LibraryScan
//
this.LibraryScan.HeaderText = "Include in library scan?";
this.LibraryScan.Name = "LibraryScan";
this.LibraryScan.Width = 83;
this.LibraryScan.Width = 94;
//
// AccountId
//
this.AccountId.HeaderText = "Audible email/login";
this.AccountId.Name = "AccountId";
this.AccountId.Width = 111;
this.AccountId.Width = 125;
//
// Locale
//
this.Locale.HeaderText = "Locale";
this.Locale.Name = "Locale";
this.Locale.Width = 45;
this.Locale.Width = 47;
//
// AccountName
//
this.AccountName.HeaderText = "Account nickname (optional)";
this.AccountName.Name = "AccountName";
this.AccountName.Width = 152;
this.AccountName.Width = 170;
//
// AccountsDialog
//
this.AcceptButton = this.saveBtn;
this.AutoScaleDimensions = new System.Drawing.SizeF(6F, 13F);
this.AutoScaleDimensions = new System.Drawing.SizeF(7F, 15F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Font;
this.CancelButton = this.cancelBtn;
this.ClientSize = new System.Drawing.Size(800, 450);
this.ClientSize = new System.Drawing.Size(933, 519);
this.Controls.Add(this.dataGridView1);
this.Controls.Add(this.importBtn);
this.Controls.Add(this.saveBtn);
this.Controls.Add(this.cancelBtn);
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.Name = "AccountsDialog";
this.Text = "Audible Accounts";
((System.ComponentModel.ISupportInitialize)(this.dataGridView1)).EndInit();
@@ -137,7 +164,9 @@
private System.Windows.Forms.Button cancelBtn;
private System.Windows.Forms.Button saveBtn;
private System.Windows.Forms.DataGridView dataGridView1;
private System.Windows.Forms.Button importBtn;
private System.Windows.Forms.DataGridViewButtonColumn DeleteAccount;
private System.Windows.Forms.DataGridViewButtonColumn ExportAccount;
private System.Windows.Forms.DataGridViewCheckBoxColumn LibraryScan;
private System.Windows.Forms.DataGridViewTextBoxColumn AccountId;
private System.Windows.Forms.DataGridViewComboBoxColumn Locale;

View File

@@ -1,5 +1,6 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Windows.Forms;
using AudibleApi;
@@ -10,17 +11,14 @@ namespace LibationWinForms.Dialogs
public partial class AccountsDialog : Form
{
private const string COL_Delete = nameof(DeleteAccount);
private const string COL_Export = nameof(ExportAccount);
private const string COL_LibraryScan = nameof(LibraryScan);
private const string COL_AccountId = nameof(AccountId);
private const string COL_AccountName = nameof(AccountName);
private const string COL_Locale = nameof(Locale);
private Form1 _parent { get; }
public AccountsDialog(Form1 parent)
public AccountsDialog()
{
_parent = parent;
InitializeComponent();
dataGridView1.Columns[COL_AccountName].AutoSizeMode = DataGridViewAutoSizeColumnMode.Fill;
@@ -48,12 +46,20 @@ namespace LibationWinForms.Dialogs
return;
foreach (var account in accounts)
dataGridView1.Rows.Add(
AddAccountToGrid(account);
}
private void AddAccountToGrid(Account account)
{
int row = dataGridView1.Rows.Add(
"X",
"Export",
account.LibraryScan,
account.AccountId,
account.Locale.Name,
account.AccountName);
dataGridView1[COL_Export, row].ToolTipText = "Export account authorization to audible-cli";
}
private void dataGridView1_DefaultValuesNeeded(object sender, DataGridViewRowEventArgs e)
@@ -77,6 +83,11 @@ namespace LibationWinForms.Dialogs
if (e.RowIndex < dgv.RowCount - 1)
dgv.Rows.Remove(row);
break;
case COL_Export:
// if final/edit row: do nothing
if (e.RowIndex < dgv.RowCount - 1)
Export((string)row.Cells[COL_AccountId].Value, (string)row.Cells[COL_Locale].Value);
break;
//case COL_MoveUp:
// // if top: do nothing
// if (e.RowIndex < 1)
@@ -128,7 +139,7 @@ namespace LibationWinForms.Dialogs
}
catch (Exception ex)
{
MessageBoxAlertAdmin.Show("Error attempting to save accounts", "Error saving accounts", ex);
MessageBoxLib.ShowAdminAlert(this, "Error attempting to save accounts", "Error saving accounts", ex);
}
}
@@ -140,13 +151,13 @@ namespace LibationWinForms.Dialogs
{
if (string.IsNullOrWhiteSpace(dto.AccountId))
{
MessageBox.Show("Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show(this, "Account id cannot be blank. Please enter an account id for all accounts.", "Blank account", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
if (string.IsNullOrWhiteSpace(dto.LocaleName))
{
MessageBox.Show("Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
MessageBox.Show(this, "Please select a locale (i.e.: country or region) for all accounts.", "Blank region", MessageBoxButtons.OK, MessageBoxIcon.Error);
return false;
}
}
@@ -198,5 +209,95 @@ namespace LibationWinForms.Dialogs
LibraryScan = (bool)r.Cells[COL_LibraryScan].Value
})
.ToList();
private string GetAudibleCliAppDataPath()
=> Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Audible");
private void Export(string accountId, string locale)
{
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var account = persister.AccountsSettings.Accounts.FirstOrDefault(a => a.AccountId == accountId && a.Locale.Name == locale);
if (account is null)
return;
if (account.IdentityTokens?.IsValid != true)
{
MessageBox.Show(this, "This account hasn't been authenticated yet. First scan your library to log into your account, then try exporting again.", "Account Not Authenticated");
return;
}
SaveFileDialog sfd = new();
sfd.Filter = "JSON File|*.json";
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
sfd.InitialDirectory = audibleAppDataDir;
if (sfd.ShowDialog() != DialogResult.OK) return;
try
{
var mkbAuth = Mkb79Auth.FromAccount(account);
var jsonText = mkbAuth.ToJson();
File.WriteAllText(sfd.FileName, jsonText);
MessageBox.Show(this, $"Successfully exported {account.AccountName} to\r\n\r\n{sfd.FileName}", "Success!");
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
$"An error occurred while exporting account:\r\n{account.AccountName}",
"Error Exporting Account",
ex);
}
}
private async void importBtn_Click(object sender, EventArgs e)
{
OpenFileDialog ofd = new();
ofd.Filter = "JSON File|*.json";
ofd.InitialDirectory = Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData);
string audibleAppDataDir = GetAudibleCliAppDataPath();
if (Directory.Exists(audibleAppDataDir))
ofd.InitialDirectory = audibleAppDataDir;
if (ofd.ShowDialog() != DialogResult.OK) return;
try
{
var jsonText = File.ReadAllText(ofd.FileName);
var mkbAuth = Mkb79Auth.FromJson(jsonText);
var account = await mkbAuth.ToAccountAsync();
// without transaction, accounts persister will write ANY EDIT immediately to file
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
if (persister.AccountsSettings.Accounts.Any(a => a.AccountId == account.AccountId && a.IdentityTokens.Locale.Name == account.Locale.Name))
{
MessageBox.Show(this, $"An account with that account id and country already exists.\r\n\r\nAccount ID: {account.AccountId}\r\nCountry: {account.Locale.Name}", "Cannot Add Duplicate Account");
return;
}
persister.AccountsSettings.Add(account);
AddAccountToGrid(account);
}
catch (Exception ex)
{
MessageBoxLib.ShowAdminAlert(
this,
$"An error occurred while importing an account from:\r\n{ofd.FileName}\r\n\r\nIs the file encrypted?",
"Error Importing Account",
ex);
}
}
}
}

View File

@@ -1,5 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<root>
<root>
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">
@@ -58,10 +57,10 @@
<resheader name="writer">
<value>System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089</value>
</resheader>
<metadata name="Original.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<metadata name="DeleteAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="DeleteAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<metadata name="ExportAccount.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="LibraryScan.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
@@ -70,10 +69,10 @@
<metadata name="AccountId.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="AccountName.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="Locale.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
<metadata name="AccountName.UserAddedColumn" type="System.Boolean, mscorlib, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089">
<value>True</value>
</metadata>
</root>

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