Compare commits

..
263 Commits
Author SHA1 Message Date
Robert McRackan 0a0f60192b Better exception logging with Serilog.Exceptions library 2022-03-03 16:54:43 -05:00
Robert McRackan 424d939c15 Update dependencies 2022-03-03 13:02:15 -05:00
Robert McRackan 87f13ff8ed Incr. ver. 2022-02-21 10:26:58 -05:00
Robert McRackan 1e24df626a Add error recovery around FileLocations.json to handle file corruption 2022-02-21 10:24:56 -05:00
Robert McRackan 0312786721 Add description to exports #197 2022-02-14 13:44:54 -05:00
Robert McRackan 1f8a5b256e Bug fix: Defensive FirstOrDefault. #194 2022-02-03 08:53:41 -05:00
rmcrackan 426391f01c Update README.md
Forgot to remove .nfo from readme
2022-01-30 09:55:34 -05:00
Robert McRackan c296bff47f Bug fix #181 : if audible gives a null picture id then once that image is searched for, no further cover art was downloaded 2022-01-12 22:20:58 -05:00
Robert McRackan 6b649cf4ca Rare duplicates are making their way into the db. Defensive FirstOrDefault added to address bug #184 2022-01-11 07:34:42 -05:00
Robert McRackan 5103240a76 Bugfix: Latest AudibleAPI addresses #175 . Thank you again @mkb79 2022-01-04 11:25:49 -05:00
rmcrackan c2418b10f6 Update README.md 2022-01-04 10:53:53 -05:00
Robert McRackan d705c23472 Bug fix #158 : troublesome check is unnecessary anyway 2021-12-07 16:06:15 -05:00
Robert McRackan de45d008c7 Bug fix #167 : folders with leading or trailing whitespace will break file saving. Including paths created from templates 2021-12-07 09:24:36 -05:00
Robert McRackan c267332027 update dependencies 2021-12-06 15:12:44 -05:00
Robert McRackan 4829e85faf Bug fix #163 , #171 : pre-audible uk logins were failing. Thanks @mkb79 ! 2021-12-06 13:14:06 -05:00
Robert McRackan 2acb9ca7e5 Bug fix: Spent hours hunting down why database files weren't closing correctly. New to EF Core 6 "SQLite: Connections are pooled" " This results in database files being kept open by the process even after the ADO.NET connection object is closed." wtf microsoft?! 2021-12-06 12:00:12 -05:00
Robert McRackan b260554a2a Bug fix #173 : error when importing the same book from multiple accounts during the same import 2021-12-06 11:05:46 -05:00
Robert McRackan 41a4055cd9 init default settings 2021-12-03 14:47:21 -05:00
Robert McRackan c6e9ba9bf9 new user: init settings 2021-12-02 16:47:35 -05:00
Robert McRackan 5059333b38 Bug fix: First click on Liberated icon shows 'File not found: <temppath>'. Second click opens correct final path. #164 2021-11-30 09:54:32 -05:00
Robert McRackan b4015030cf tl;dr text on alt login. Issue #160 2021-11-29 11:12:26 -05:00
Robert McRackan 7f5cf8f018 New config setting: ShowImportedStats -- "Show number of newly imported titles? When unchecked, no pop-up will appear after library scan." 2021-11-29 11:06:23 -05:00
rmcrackan 2c9ccd9c78 Update README.md
TOC
2021-11-29 09:05:52 -05:00
rmcrackan cebf218db4 Update README.md
Add 'Installation'
2021-11-29 09:03:51 -05:00
Robert McRackan 530b44a0e6 Bug fix: in paths, double slashes are not allowed *except* at beginning. eg: \\192.168.0.1 (issue #157 ) 2021-11-24 13:42:11 -05:00
Robert McRackan b3dc5a7054 Upgrade to .net6 2021-11-24 12:59:02 -05:00
Robert McRackan 2567ccb44c Enhancement: add if-series conditional logic to custom file naming. Issue #151 2021-11-11 16:43:44 -05:00
Robert McRackan e67eac92fd Bug fix for issue #152 : individual episodes were ignored when targeted directly (as opposed to targeting the parent series) 2021-11-09 21:27:44 -05:00
Robert McRackan 6e84fd97f1 Accounts dialog: more user-friendly validation 2021-11-08 08:55:07 -05:00
Robert McRackan 9a458bf3dc Protect against blank settings.json 2021-11-05 15:25:31 -04:00
Robert McRackan 283a46e1e2 Add debugging for issue #149 2021-11-05 13:42:47 -04:00
Robert McRackan 6ff2859c39 Update dependencies 2021-11-04 16:13:43 -04:00
Robert McRackan e8df4952fc update dependencies 2021-11-03 16:51:40 -04:00
rmcrackan b19e1e8a30 Update README for Custom File Naming 2021-11-02 22:20:51 -04:00
Robert McRackan a3cf6ac40d Custom file naming: manual testing complete 2021-11-02 21:53:58 -04:00
Robert McRackan ab450c37c4 Custom File Naming complete. Final testing remains 2021-11-02 17:05:29 -04:00
Robert McRackan c837fefbdd template file naming: code complete. Clean up and testing remain 2021-11-02 14:26:11 -04:00
Robert McRackan 46b120ee41 Bug fix: slashes in template values (eg: title) breaks file management #145 2021-11-01 11:42:05 -04:00
Robert McRackan cae8ca7ef3 Template error and warning checks return specific errors/warnings in addition to bools 2021-10-29 17:05:57 -04:00
Robert McRackan 904665da7f Bug fix: #143 2021-10-28 20:58:32 -04:00
Robert McRackan 2478c61df6 Bugfix: template validation was opposite #142 2021-10-28 14:38:01 -04:00
Robert McRackan 288ed75b5d increm ver 2021-10-27 20:46:02 -04:00
Robert McRackan ad5efbd9a9 Bug fix for #141 2021-10-27 20:45:21 -04:00
Robert McRackan 7eb7b2a0f9 better formatting for download/decrypt ETA 2021-10-27 17:05:17 -04:00
Robert McRackan d0051c0f02 Add templates to settings dialog incl load validate save. Edit buttons are in place but currently do nothing 2021-10-27 16:51:31 -04:00
Robert McRackan d20517063e Settings: single screen => tabs 2021-10-27 15:50:41 -04:00
Robert McRackan bcca69a102 Bug fix. Wrong template referenced 2021-10-26 16:35:08 -04:00
Robert McRackan 35f8c05106 File naming is Configuration driven: Configuration, AudioFileStorageExt, Templates, TemplateTags, 2021-10-26 16:18:27 -04:00
Robert McRackan a3d38e082d Path.GetInvalidPathChars() acts differently in C# interactive vs live code 2021-10-26 13:54:37 -04:00
Robert McRackan b2e956e70b Update dependencies 2021-10-26 13:06:24 -04:00
Robert McRackan e5119357b2 File naming is fully template driven 2021-10-22 17:06:42 -04:00
Robert McRackan b42ff827d5 GetStandardizedExtension unit tests 2021-10-22 13:09:05 -04:00
Robert McRackan 68da9779da Expose internal to Test projects 2021-10-22 11:07:18 -04:00
Robert McRackan 8e358d8f04 expose library book to multipart decrypter for file naming 2021-10-21 16:43:49 -04:00
Robert McRackan 0a986238bc Defensive FirstOrDefault 2021-10-21 15:39:53 -04:00
Robert McRackan d636ceed8e File naming stuff is (finally) centralized under AudioFileStorageExt 2021-10-21 14:38:59 -04:00
Robert McRackan e4fc104afe Naming logic for all new files can now originate from domian logic 2021-10-20 10:56:07 -04:00
Robert McRackan 87e3075fb3 Rename InternalUtilities to AudibleUtilities 2021-10-19 10:22:42 -04:00
Robert McRackan ab44823c05 Bug fix: update book series. Defensive FirstOrDefault 2021-10-18 20:40:56 -04:00
Robert McRackan 2767f04621 split AaxcDownload single and multi 2021-10-18 14:41:57 -04:00
Robert McRackan 0f1ff0aa10 minor refactor 2021-10-18 13:56:12 -04:00
Robert McRackan c1af253300 fix partial rollback 2021-10-18 13:44:40 -04:00
Robert McRackan d08962cffa Refactor valid path/filename. Centralize validaion. Universal templating is one step closer 2021-10-18 13:36:55 -04:00
Robert McRackan 7720110460 Bug fix: tag filters stopped working on 8/22 2021-10-15 14:45:35 -04:00
Robert McRackan dfa5829cbd Safe(r)Delete, Safe(r)Move : could have infinite loop of exceptions. Fixed. Limit 3 2021-10-12 17:05:01 -04:00
Robert McRackan 648b84ee55 All audible-related file naming terminates at FileUtility
File extensions: Dinah.Core => Libation FileUtility
2021-10-12 14:48:32 -04:00
Robert McRackan 6a81b9b02d more LibationFileManager rename. and bug fix 2021-10-11 17:10:37 -04:00
Robert McRackan c43e03b228 FileManager: separate generic from Libation-specific 2021-10-11 16:06:50 -04:00
Robert McRackan 1de7edd9df Chapter splitting: file names need more leading zeros when qty >100 2021-10-09 14:20:21 -04:00
Robert McRackan df90094884 Replaced another id dependency with cache. Now safe for multi-file audiobooks. Also safe for current session not trying to move files created in a previous session or a parallel session of a different title 2021-10-08 21:34:42 -04:00
Robert McRackan c9a6c8fd35 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-10-08 11:48:05 -04:00
Robert McRackan d0b78cc501 New and moved files Upsert themselves in FileManager.FilePathCache 2021-10-08 11:47:54 -04:00
rmcrackan 0b7bc4d938 Update README.md
List all `libationcli export` options
2021-10-07 08:59:13 -04:00
Robert McRackan 18cca53968 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-10-07 08:45:19 -04:00
Robert McRackan ef9c60cc4f File splitting: omit tiny chapters 2021-10-07 08:45:02 -04:00
rmcrackan fa24831693 Merge pull request #135 from Mbucari/master
Fix chapter splitting.
2021-10-06 21:20:04 -04:00
Michael Bucari-Tovo 24370e9804 Merge branch 'master' of https://github.com/Mbucari/Libation 2021-10-06 16:02:30 -06:00
Michael Bucari-Tovo d3f82b162e Fix chapter splitting. 2021-10-06 16:01:50 -06:00
rmcrackan 5a40c7370f Merge pull request #134 from Mbucari/master
Fix splitting audiobooks on chapters
2021-10-06 15:55:02 -04:00
Mbucari 2d22855b93 Merge branch 'rmcrackan:master' into master 2021-10-06 13:47:02 -06:00
Michael Bucari-Tovo b870d562ff Only split chapters at least 15 seconds long. 2021-10-06 13:44:03 -06:00
Michael Bucari-Tovo f1c87308ea Fixed access modifier. 2021-10-06 13:43:19 -06:00
Michael Bucari-Tovo a3fac3441c Allow splitting book only if AllowLibationFixup is true. 2021-10-06 13:43:01 -06:00
Robert McRackan 5f8c672361 CLI: error when scan has new book with pdf attachment: 2021-10-06 15:35:19 -04:00
rmcrackan 40520b89d1 Merge pull request #132 from Mbucari/master
Convert IProcessable to abstract class Processable.
2021-10-06 11:06:25 -04:00
Michael Bucari-Tovo 0ac90f5a30 Discard unnused variable. 2021-10-06 08:31:37 -06:00
Michael Bucari-Tovo 4d6544d828 Revert accidental push of changes in progress. 2021-10-06 08:25:08 -06:00
Michael Bucari-Tovo 8098564926 Better naming. 2021-10-06 08:23:07 -06:00
Michael Bucari-Tovo 07c96c4994 Corrected access modifiers. 2021-10-06 08:22:50 -06:00
Michael Bucari-Tovo aa8491f205 Edited comments 2021-10-05 16:54:33 -06:00
Michael Bucari-Tovo 5c535478d1 Add note 2021-10-05 16:49:55 -06:00
Michael Bucari-Tovo f0541b498f Removed virtual 2021-10-05 16:49:06 -06:00
Michael Bucari-Tovo e466d63e76 Convert IStreamable and IAudioDecodable to abstract classes. 2021-10-05 16:41:48 -06:00
Michael Bucari-Tovo 6e66314605 Convert IProcessable to abstract class Processable. 2021-10-05 16:10:56 -06:00
Robert McRackan be5e18d977 settings descriptions 2021-10-05 11:09:36 -04:00
Robert McRackan c437a39a82 New feature: download already split into chapters 2021-10-05 10:35:41 -04:00
Robert McRackan 7b55158148 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-10-05 10:08:21 -04:00
Robert McRackan 5772d9c31e defensive FirstOrDefault 2021-10-05 10:07:58 -04:00
rmcrackan 2a1f02b095 Merge pull request #127 from seanke/feature/multi_files
To mulitple files
2021-10-05 10:01:43 -04:00
Sean Kelly 5b7cde2a9e Fixed issues 2021-10-05 17:36:37 +13:00
Sean Kelly 5e349c6662 Removed repeated code 2021-09-30 20:32:30 +13:00
Sean Kelly 4b78b757aa Move files 2021-09-30 19:44:32 +13:00
Robert McRackan 22548dc8ae bug fix: if not importing episodes, remember to remove parents from import list 2021-09-29 10:00:04 -04:00
Robert McRackan 1165f81203 bug fix in series importer 2021-09-27 07:57:24 -04:00
Sean Kelly 13294d3414 Added m4b & mp3 methods for multiple files. 2021-09-27 21:34:43 +13:00
Sean Kelly 8a74a29700 Added configuration and wired it up. 2021-09-27 20:18:50 +13:00
Sean Kelly 36f58b64d6 proof of concept 2021-09-26 23:05:17 +13:00
Robert McRackan 19369a21ef * New feature: setting to not import episodes ( #125 ) 2021-09-25 14:02:27 -04:00
Robert McRackan 611fb4d6d8 increm ver 2021-09-24 20:11:52 -04:00
Robert McRackan c77ec54035 bug fix: DownloadEpisodes logic needs parans 2021-09-24 19:59:57 -04:00
Robert McRackan c9c28c7826 oops. again 2021-09-24 16:44:49 -04:00
Robert McRackan 30e2caaff5 New feature: setting to not download episodes 2021-09-24 16:44:28 -04:00
Robert McRackan fd56017af5 dedicated lock objects for safety 2021-09-24 11:46:37 -04:00
Robert McRackan d2eaf26117 Removing books
* message box is too big when removing huge amounts of books. This scenario is typical when removing podcasts
* removing books should remove user defined entries. eg: tags and is-liberated
2021-09-24 10:00:52 -04:00
Robert McRackan 7c38e18435 Add support for unencrypted mp3 audiobooks 2021-09-24 08:26:23 -04:00
Robert McRackan bfb1dbc69a Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-09-24 08:25:16 -04:00
Robert McRackan d2ff19e309 null description causes errors 2021-09-24 08:24:16 -04:00
rmcrackan aa3a7dce06 Merge pull request #123 from Mbucari/master
Add support for unencrypted mp3 audiobooks.
2021-09-24 08:23:14 -04:00
Mbucari 71075838eb Moved event logging to LibationBaseForm 2021-09-23 19:29:25 -06:00
Michael Bucari-Tovo 803a0b7ccf Comment typo. 2021-09-23 18:14:29 -06:00
Michael Bucari-Tovo d9f3fa825c Renaming and comments. 2021-09-23 18:13:43 -06:00
Michael Bucari-Tovo df42ba584e Better naming. 2021-09-23 18:06:59 -06:00
Michael Bucari-Tovo 9f09a62a1e Ensure chapter info is downloaded for unencrypted MP3s 2021-09-23 18:04:12 -06:00
Michael Bucari-Tovo e714179c30 Add support for unencrypted mp3 audiobooks. 2021-09-23 18:01:39 -06:00
Robert McRackan db84c9a7d9 unencrypted podcast downloads (incomplete) 2021-09-23 16:50:59 -04:00
Robert McRackan 937bd56fcc debug code to incl episodes 2021-09-23 16:20:08 -04:00
Robert McRackan f29968f379 conditional re-index 2021-09-23 14:44:11 -04:00
Robert McRackan 14e14ba9bd batch book status updates
bug fixes, esp. threading
2021-09-23 14:33:04 -04:00
Robert McRackan 613c97524a User get getting "bindingsource cannot be its own data source" error. Can't repro locally. Adding SyncBindingSource for datagrid views to attempt to solve it 2021-09-23 10:41:33 -04:00
Robert McRackan 4fd16f04e0 More robust error handling, especially before logging can be initialized 2021-09-23 09:19:35 -04:00
Robert McRackan 61385f0f0b more explicit book series order 2021-09-22 11:51:06 -04:00
Robert McRackan 7647882344 update audible api dependency 2021-09-22 09:48:28 -04:00
Robert McRackan 96ffa619ec Bug fix: podcast episodes with no title cause library import failure 2021-09-21 15:06:01 -04:00
Robert McRackan de1147ac1b External login completed 2021-09-20 15:31:07 -04:00
Robert McRackan 926a7a1148 * include new external login interfaces in ApiExtended
* new ext method to simplify api access in file liberator
2021-09-17 16:09:49 -04:00
Robert McRackan 51020ef99e * upgrade api. lay the groundwork for supporting external logins. When complete, this will be v6.0
* replace AudibleApiActions with ApiExtended
2021-09-17 15:30:06 -04:00
Robert McRackan 5a1303c33a update dependency 2021-09-17 14:02:26 -04:00
Robert McRackan a0e2d78b9b * upgraded AAXClean addresses issues #104 , #110
* update dependencies
2021-09-16 07:53:34 -04:00
rmcrackan 6b711190c3 Merge pull request #116 from Mbucari/master
Correctly write mp3 Xing header.
2021-09-16 07:46:03 -04:00
Michael Bucari-Tovo b4a6342513 Revert accidental change. 2021-09-15 15:32:34 -06:00
Michael Bucari-Tovo 988b137d67 Updated AAXClean version. 2021-09-15 15:26:40 -06:00
Michael Bucari-Tovo dae9c9c9b6 Add read permission to file stream. 2021-09-15 15:16:08 -06:00
Robert McRackan 420b7529c6 Bug fix: issues #113 , #114
Forcing a new build order. Seems to fix the issue. Since the issue was already intermittent (race condition?) it's difficult to be sure
2021-09-14 09:05:56 -04:00
Robert McRackan 4cf999c84d null book description throws 2021-09-13 11:05:44 -04:00
Robert McRackan 8fe3896d76 build error bug fix 2021-09-13 10:35:41 -04:00
Robert McRackan adcba34560 'bad book' setting labels 2021-09-11 08:01:58 -04:00
Robert McRackan 8e09d7e617 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-09-10 22:59:02 -04:00
Robert McRackan 197b50e3ac issue #111 -- setting for bad book abort/retry/ignore. default: ask 2021-09-10 22:58:34 -04:00
rmcrackan ac2114e270 Add CLI documentation 2021-09-10 17:20:13 -04:00
Robert McRackan 29461701cd I meant to increm the minor version for cli 2021-09-10 17:04:47 -04:00
Robert McRackan 0f130c70f5 LibationCli and structural changes to support it incl: no LibationLauncher, just LibationWinForms. LibationWinForms builds to a different output dir so cli can be deployed easily. Versioning number is moved to scaffolding library shared by both apps 2021-09-10 16:54:32 -04:00
Robert McRackan 995637e843 add logging. helpful when viewing in console 2021-09-10 12:51:07 -04:00
Robert McRackan 9501687f86 Change build path. Necessary for possibly building a non-winforms exe to the same output dir 2021-09-09 16:04:17 -04:00
Robert McRackan 248dea3402 write to log, not console 2021-09-09 13:26:00 -04:00
Robert McRackan 1d420f5430 File Liberators should log their own progresss and not depend on controller or forms to do so. This means that LogMe will create duplicate log entries for non-form and non-controller calls but that's a refactor for a rainy day. 2021-09-09 11:27:03 -04:00
Robert McRackan 5f0a6b8526 bug fix: installer bug for users who say 'return user' but don't have valid files 2021-09-07 16:58:04 -04:00
Robert McRackan c337c0b44e library book composite key comments 2021-09-07 13:35:59 -04:00
Robert McRackan 89207866f3 make sure that __log is never empty so that .Last() can't throw 2021-09-04 18:17:29 -04:00
Robert McRackan 9e11086d49 bug fix 2021-09-04 18:09:51 -04:00
rmcrackan 58b172f816 Merge pull request #103 from Mbucari/master
Minor fix and changes for form size and location persistance.
2021-09-04 14:15:50 -04:00
Mbucari 0b8084bc03 Added form size and position persistance to audio decode forms. 2021-09-03 23:00:35 -06:00
Mbucari 37970222f3 Make SaveSizeAndLocation and RestoreSizeAndLocation a form extension. 2021-09-03 22:44:02 -06:00
Mbucari bcab2dd440 Adjust display parameters. 2021-09-03 22:43:03 -06:00
Mbucari d402128d1d GetNonString now handles values and classes. 2021-09-03 22:41:21 -06:00
Mbucari 3ae0f2daa2 Fixed FindInactiveBooks to work with new logger. 2021-09-03 22:40:35 -06:00
Robert McRackan 126919d578 update dependencies 2021-09-03 23:02:28 -04:00
Robert McRackan 437e85fd12 mp3 bugfix 2021-09-03 18:22:31 -04:00
Robert McRackan de34e5c795 import speed improvements 2021-09-03 16:35:31 -04:00
Robert McRackan 8ffcefd6ae massive speed-up for import contributors 2021-09-03 15:30:17 -04:00
Robert McRackan e59ab9b483 remove v3=>4 migration work-arounds 2021-09-03 13:42:58 -04:00
Robert McRackan 57fa1bd763 cross thread issue. add temp time logging in ImportAccountAsync 2021-09-03 11:36:55 -04:00
rmcrackan dccb2d73d6 Merge pull request #101 from Mbucari/master
Fixed cross thread bug and Enumerable change bug.
2021-09-03 08:15:37 -04:00
Mbucari 77fc865636 Fixed crossthread. 2021-09-02 21:21:36 -06:00
Mbucari 1040a347c6 Fixed cross thread bug and Enumerable change bug. 2021-09-02 21:04:16 -06:00
Robert McRackan 6ed1307443 v5.6.3.1 : support for episodes ( issue #96 ) 2021-09-02 16:05:23 -04:00
Robert McRackan c2c732b2b1 central event for library altered: books added or removed 2021-09-02 15:55:12 -04:00
Robert McRackan 9e0caf34d6 Rename db table Library => LibraryBooks 2021-09-02 15:26:20 -04:00
Robert McRackan 802763a4fb minor 2021-09-02 15:19:55 -04:00
Robert McRackan b4803c42a5 Streamline GetLibrary_Flat_NoTracking with better context dispose 2021-09-02 14:51:06 -04:00
Robert McRackan 62c98c66a3 Merge branch 'master' of https://github.com/rmcrackan/Libation 2021-09-02 14:15:10 -04:00
Robert McRackan 6b289445e2 update dependencies 2021-09-02 14:14:25 -04:00
rmcrackan 52bf91f8aa Merge pull request #99 from Mbucari/master
Fixed crash when change RemoveBooksDialog checkbox via spacebar.
2021-09-02 13:47:57 -04:00
Michael Bucari-Tovo 6d2dff1a98 Code Cleanup 2021-09-02 11:21:20 -06:00
Mbucari 7c9970c0cb Merge branch 'rmcrackan:master' into master 2021-09-02 11:18:32 -06:00
Michael Bucari-Tovo d2892f9076 Fix crash when checkbox checked via spacebar. 2021-09-02 11:11:40 -06:00
Robert McRackan 89f60a7ca3 fix wording 2021-09-02 11:35:32 -04:00
rmcrackan ea37c09081 Merge pull request #98 from Mbucari/master
Added AAXClean as nuget package.
2021-09-02 10:54:42 -04:00
Michael Bucari-Tovo 76cb280933 Added AAXClean as nuget package. 2021-09-02 08:13:42 -06:00
Robert McRackan 0a54a8104c update counts label to reflect recent workflow changes 2021-09-02 09:54:02 -04:00
Robert McRackan 7464336535 remove WinFormsDesigner 2021-09-02 09:52:41 -04:00
Robert McRackan dc0dd3474b separate the concepts of UserDefinedItem being updated in memory vs successful persistence 2021-09-02 09:51:17 -04:00
Robert McRackan 7b9c5c0f4f Add episode/podcast search engine bool 2021-09-01 16:56:09 -04:00
Robert McRackan ad87f1851e Add episodes content type to Books in db 2021-09-01 16:51:59 -04:00
Robert McRackan e8423341ef bug fix: bottom count numbers and menu options weren't updating on liberate-all 2021-09-01 14:28:01 -04:00
Robert McRackan a9d3494af1 Added support for episodic content incl podcasts. Not yet complete. Need to mark them as such in the db somehow and also add search engine bools 2021-09-01 12:47:59 -04:00
Robert McRackan 90731a8948 'Convert all M4b to Mp3': add confirmation dialog with better explanation 2021-08-31 09:32:50 -04:00
Robert McRackan e723467ca6 book liberation status Error:
* show system error icon in grid instead of stoplight
* list error count in bottom right #s
* SearchEngine bool: LiberatedError
2021-08-27 17:01:00 -04:00
Robert McRackan 722c33bf61 Add readme to help/annoy collaborators 2021-08-27 15:14:42 -04:00
Robert McRackan f080215cbb Book details dialog: tags should get initial focus 2021-08-27 14:07:06 -04:00
Robert McRackan d5c74d629f update dependencies 2021-08-27 11:16:13 -04:00
Robert McRackan d12c246f6d version increm 2021-08-26 16:09:37 -04:00
Robert McRackan 8969c216af comments 2021-08-26 16:08:26 -04:00
Robert McRackan 9a4903f0dd Bug fix: after successful pdf download, this state wasn't being saved 2021-08-26 15:53:33 -04:00
Robert McRackan 3eda498a5e new AudibleApi nuget no longer relies on external json and js files which caused issues 2021-08-26 12:51:55 -04:00
Robert McRackan 8af7f28f04 (hopefully) final nuget pkg: Dinah.Core.WindowsDesktop 2021-08-26 12:49:37 -04:00
Robert McRackan d9d7dfe1f7 update depandecies 2021-08-26 12:48:05 -04:00
Robert McRackan b9c4d11946 remove TestCommon 2021-08-25 17:07:26 -04:00
Robert McRackan 68a5d7a58d nuget. done until I can figure out how to build .net5-windows nuget from github actions 2021-08-25 16:24:02 -04:00
Robert McRackan 4d69b222c5 nuget: Dinah.EntityFrameworkCore 2021-08-25 15:55:31 -04:00
Robert McRackan 42f94e7f6c more nuget migration 2021-08-25 15:33:09 -04:00
Robert McRackan 381d52be72 Better audible api to reduce captcha occurances 2021-08-24 13:42:14 -04:00
Robert McRackan f16ad30891 bug fix from my last bug fix :( 2021-08-24 09:28:13 -04:00
Robert McRackan ef53a6a8cb Bug fix: issue #92 2021-08-23 16:29:23 -04:00
Robert McRackan 9a37d434f1 FileLiberator is not db ignorant. It doesn't make context calls but still heavily uses the classes defined in the domain. Also uses internal util.s 2021-08-23 16:27:22 -04:00
Robert McRackan d7eb190f69 reduce use of Book.Supplements 2021-08-23 16:16:08 -04:00
Robert McRackan f19c46ee45 Book: add pdf url as is, not Absolute 2021-08-23 16:07:19 -04:00
rmcrackan 343c3b62d6 Merge pull request #90 from Mbucari/master
Fully implemented the MVVM pattern
2021-08-22 21:06:00 -04:00
Michael Bucari-Tovo b1de10a71a Fix filtering. 2021-08-22 13:29:01 -06:00
Michael Bucari-Tovo 6beb5cc74a Made changes discussed. 2021-08-22 13:27:39 -06:00
Michael Bucari-Tovo 3767c3574a Merge branch 'master' of https://github.com/Mbucari/Libation 2021-08-21 22:09:27 -06:00
Michael Bucari-Tovo 4ceb4f9c03 Change back. 2021-08-21 22:09:13 -06:00
Mbucari 0f5149f7b4 Merge pull request #2 from rmcrackan/master
bug fix. race condition. can't check for book-exists-state before set…
2021-08-21 22:08:51 -06:00
Michael Bucari-Tovo 673451dc11 Git resolve 2021-08-21 22:08:35 -06:00
Michael Bucari-Tovo e4257afc14 Version Num 2021-08-21 22:06:54 -06:00
Michael Bucari-Tovo 2a7e185dc3 Finish MVVM conversion 2021-08-21 22:03:16 -06:00
Michael Bucari-Tovo 9e06c343c1 Don't check if values have changed when updating the database. 2021-08-21 21:15:25 -06:00
Michael Bucari-Tovo 40b3a9990d FileLiberator is now DB ignorant. IProcessables update UserDaefinedData which notifies the view model. 2021-08-21 20:49:54 -06:00
Robert McRackan d66c112a1e bug fix. race condition. can't check for book-exists-state before setting this state 2021-08-21 22:32:45 -04:00
Michael Bucari-Tovo d826885728 Fix display for new LiberatedStatus values. 2021-08-21 18:37:07 -06:00
Michael Bucari-Tovo 263222d8cc Changed method signature. 2021-08-21 18:21:22 -06:00
Michael Bucari-Tovo f25734334d Add separate command for updating tags 2021-08-21 18:16:24 -06:00
Michael Bucari-Tovo ede8397f13 Needed to add check for actual file since Audio_Exists is now an application state. 2021-08-21 18:15:39 -06:00
Michael Bucari-Tovo 1369ee575a Replaced LiberatedState with LiberatedStatus and PdfState with LiberatedStatus? 2021-08-21 16:29:16 -06:00
Robert McRackan c8e2418af7 incr ver for new liberation status handling 2021-08-21 14:04:48 -04:00
rmcrackan 2da25edafd Merge pull request #87 from Mbucari/master
Make sure DataGridView updates the display immediately after Details are changed.
2021-08-21 13:37:05 -04:00
Michael Bucari-Tovo f60964f4c7 Unsubscribe IStreamable events from Disposed instead of FormClosed. 2021-08-21 08:47:24 -06:00
Michael Bucari-Tovo 3183f99153 Remove unnecessary overrides. 2021-08-21 08:45:43 -06:00
Michael Bucari-Tovo 2a22cff67c Revert "Fixed PDF download form disposed error"
This reverts commit 7fbe8ae769.
2021-08-21 08:14:10 -06:00
Michael Bucari-Tovo 7fbe8ae769 Fixed PDF download form disposed error 2021-08-21 08:03:40 -06:00
Robert McRackan f9df466ad8 retain seeded file locations 2021-08-21 09:59:06 -04:00
Michael Bucari-Tovo 0b129fcf7c Fixed NetworkFileStream not resuming from cancellation. 2021-08-20 21:05:29 -06:00
Robert McRackan 2be5fd5af3 Omit '.libhack' skip/error files 2021-08-20 22:16:46 -04:00
Robert McRackan c9727f84ab (hopefully) complete minimum viable product with stateful is-liberated status 2021-08-20 21:22:52 -04:00
Robert McRackan aa56bb74a1 refactor out most of TransitionalFileLocator. Almost done with new stateful is-liberated paradigm 2021-08-20 20:51:37 -04:00
Michael Bucari-Tovo 85a6e21dcf Make sure network file isn't left open. 2021-08-20 17:03:15 -06:00
Michael Bucari-Tovo 8c620c25ab Separate concerns. 2021-08-20 16:10:05 -06:00
Michael Bucari-Tovo 813d91dfa4 Better naming 2021-08-20 15:40:16 -06:00
Michael Bucari-Tovo d0d66c6135 Update using NotifyPropertyChanged instead of Row.Invalidate 2021-08-20 15:38:30 -06:00
Michael Bucari-Tovo a8d609676e Null check. 2021-08-20 14:57:23 -06:00
Michael Bucari-Tovo 8386da5ec6 Make gridview update the row after details changed. 2021-08-20 14:56:52 -06:00
Michael Bucari-Tovo f5089e7e29 Use local rowIndex instead of DataGridViewCell.RowIndex 2021-08-20 14:53:12 -06:00
Robert McRackan a639857ec6 Book details changes liberated status in db and search engine. Minor changes to audible api to hopefully fix the weird log-in edge cases 2021-08-20 16:06:01 -04:00
Robert McRackan 35b5d7370c book details form now has a way for user to toggle is vs is-not liberated for book and pdf. NOT yet wired up 2021-08-20 15:21:43 -04:00
Robert McRackan c9f988acf8 Book details form: quick and dirty 1st draft 2021-08-20 14:45:28 -04:00
Robert McRackan 6dfef09ea3 begin process of changing 'edit tags' => book details 2021-08-20 13:26:12 -04:00
rmcrackan 7e288c0c08 Merge pull request #86 from Mbucari/master
Use new Dinah.Core.Threading and remove unnecessary DataGridViewImageButtonColumn
2021-08-19 17:32:00 -04:00
Mbucari dbcf6f25db Merge branch 'rmcrackan:master' into master 2021-08-19 15:14:56 -06:00
Michael Bucari-Tovo 0cc55fd1e8 Widen Liberate column so sort arrow is shown. 2021-08-18 15:53:35 -06:00
Michael Bucari-Tovo e36ea70cd1 Removed unnecessary class and simplified. 2021-08-18 15:53:09 -06:00
Michael Bucari-Tovo a86185e644 Updated to use new Dinah.Core.Threading 2021-08-18 14:29:25 -06:00
Michael Bucari-Tovo 64a8f007a5 Use new Dinah.Core string extensions. 2021-08-18 14:11:25 -06:00
Michael Bucari-Tovo 215a626c92 SynchronizeInvoker => Dinah.Core.Threading.SynchronizeInvoker 2021-08-18 14:08:48 -06:00
Michael Bucari-Tovo de93047192 Use new Dinah.Core.Threading 2021-08-18 14:08:11 -06:00
205 changed files with 10184 additions and 6499 deletions

No files matched your search

+6 -3
View File
@@ -1,12 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\AAXClean\AAXClean.csproj" /> <PackageReference Include="AAXClean" Version="0.1.10" />
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" /> </ItemGroup>
<ItemGroup>
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+91
View File
@@ -0,0 +1,91 @@
using System;
using AAXClean;
using Dinah.Core.Net.Http;
namespace AaxDecrypter
{
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{
protected OutputFormat OutputFormat { get; }
protected AaxFile AaxFile;
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
: base(outFileName, cacheDirectory, dlLic)
{
OutputFormat = outputFormat;
}
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
{
base.SetCoverArt(coverArt);
if (coverArt is not null)
AaxFile?.AppleTags.SetCoverArt(coverArt);
}
protected bool Step_GetMetadata()
{
AaxFile = new AaxFile(InputFileStream);
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
return !IsCanceled;
}
protected DownloadProgress Step_DownloadAudiobook_Start()
{
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
};
OnDecryptProgressUpdate(zeroProgress);
AaxFile.SetDecryptionKey(DownloadLicense.AudibleKey, DownloadLicense.AudibleIV);
return zeroProgress;
}
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
{
AaxFile.Close();
CloseInputFileStream();
OnDecryptProgressUpdate(zeroProgress);
}
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = AaxFile.Duration;
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
}
public override void Cancel()
{
IsCanceled = true;
AaxFile?.Cancel();
AaxFile?.Dispose();
CloseInputFileStream();
}
}
}
-229
View File
@@ -1,229 +0,0 @@
using AAXClean;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using System;
using System.IO;
namespace AaxDecrypter
{
public enum OutputFormat { Mp4a, Mp3 }
public class AaxcDownloadConverter
{
public event EventHandler<AppleTags> RetrievedTags;
public event EventHandler<byte[]> RetrievedCoverArt;
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public string AppName { get; set; } = nameof(AaxcDownloadConverter);
private string outputFileName { get; }
private string cacheDir { get; }
private DownloadLicense downloadLicense { get; }
private AaxFile aaxFile;
private OutputFormat OutputFormat;
private StepSequence steps { get; }
private NetworkFileStreamPersister nfsPersister;
private bool isCanceled { get; set; }
private string jsonDownloadState => Path.Combine(cacheDir, Path.GetFileNameWithoutExtension(outputFileName) + ".json");
private string tempFile => PathLib.ReplaceExtension(jsonDownloadState, ".aaxc");
public AaxcDownloadConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
outputFileName = outFileName;
var outDir = Path.GetDirectoryName(outputFileName);
if (!Directory.Exists(outDir))
throw new ArgumentNullException(nameof(outDir), "Directory does not exist");
if (File.Exists(outputFileName))
File.Delete(outputFileName);
if (!Directory.Exists(cacheDirectory))
throw new ArgumentNullException(nameof(cacheDirectory), "Directory does not exist");
cacheDir = cacheDirectory;
downloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
OutputFormat = outputFormat;
steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + (outputFormat == OutputFormat.Mp4a ? "M4b" : "Mp3"),
["Step 1: Get Aaxc Metadata"] = Step1_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step2_DownloadAndCombine,
["Step 3: Create Cue"] = Step3_CreateCue,
["Step 4: Create Nfo"] = Step4_CreateNfo,
["Step 5: Cleanup"] = Step5_Cleanup,
};
}
/// <summary>
/// Setting cover art by this method will insert the art into the audiobook metadata
/// </summary>
public void SetCoverArt(byte[] coverArt)
{
if (coverArt is null) return;
aaxFile?.AppleTags.SetCoverArt(coverArt);
RetrievedCoverArt?.Invoke(this, coverArt);
}
public bool Run()
{
var (IsSuccess, Elapsed) = steps.Run();
if (!IsSuccess)
{
Console.WriteLine("WARNING-Conversion failed");
return false;
}
var speedup = (int)(aaxFile.Duration.TotalSeconds / (long)Elapsed.TotalSeconds);
Console.WriteLine("Speedup is " + speedup + "x realtime.");
Console.WriteLine("Done");
return true;
}
public bool Step1_GetMetadata()
{
//Get metadata from the file over http
if (File.Exists(jsonDownloadState))
{
try
{
nfsPersister = new NetworkFileStreamPersister(jsonDownloadState);
//If More than ~1 hour has elapsed since getting the download url, it will expire.
//The new url will be to the same file.
nfsPersister.NetworkFileStream.SetUriForSameFile(new Uri(downloadLicense.DownloadUrl));
}
catch
{
FileExt.SafeDelete(jsonDownloadState);
FileExt.SafeDelete(tempFile);
nfsPersister = NewNetworkFilePersister();
}
}
else
{
nfsPersister = NewNetworkFilePersister();
}
aaxFile = new AaxFile(nfsPersister.NetworkFileStream);
RetrievedTags?.Invoke(this, aaxFile.AppleTags);
RetrievedCoverArt?.Invoke(this, aaxFile.AppleTags.Cover);
return !isCanceled;
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", downloadLicense.UserAgent }
};
var networkFileStream = new NetworkFileStream(tempFile, new Uri(downloadLicense.DownloadUrl), 0, headers);
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
public bool Step2_DownloadAndCombine()
{
var zeroProgress = new DownloadProgress
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
};
DecryptProgressUpdate?.Invoke(this, zeroProgress);
if (File.Exists(outputFileName))
FileExt.SafeDelete(outputFileName);
FileStream outFile = File.OpenWrite(outputFileName);
aaxFile.SetDecryptionKey(downloadLicense.AudibleKey, downloadLicense.AudibleIV);
aaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
var decryptionResult = OutputFormat == OutputFormat.Mp4a ? aaxFile.ConvertToMp4a(outFile, downloadLicense.ChapterInfo) : aaxFile.ConvertToMp3(outFile);
aaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
aaxFile.Close();
downloadLicense.ChapterInfo = aaxFile.Chapters;
nfsPersister.Dispose();
DecryptProgressUpdate?.Invoke(this, zeroProgress);
return decryptionResult == ConversionResult.NoErrorsDetected && !isCanceled;
}
private void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
var duration = aaxFile.Duration;
double remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining))
DecryptTimeRemaining?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
DecryptProgressUpdate?.Invoke(this,
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(nfsPersister.NetworkFileStream.Length * progressPercent),
TotalBytesToReceive = nfsPersister.NetworkFileStream.Length
});
}
public bool Step3_CreateCue()
{
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".cue"), Cue.CreateContents(Path.GetFileName(outputFileName), downloadLicense.ChapterInfo));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step3_CreateCue)}. FAILED");
}
return !isCanceled;
}
public bool Step4_CreateNfo()
{
// not a critical step. its failure should not prevent future steps from running
try
{
File.WriteAllText(PathLib.ReplaceExtension(outputFileName, ".nfo"), NFO.CreateContents(AppName, aaxFile, downloadLicense.ChapterInfo));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step4_CreateNfo)}. FAILED");
}
return !isCanceled;
}
public bool Step5_Cleanup()
{
FileExt.SafeDelete(jsonDownloadState);
FileExt.SafeDelete(tempFile);
return !isCanceled;
}
public void Cancel()
{
isCanceled = true;
aaxFile?.Cancel();
aaxFile?.Dispose();
}
}
}
+142
View File
@@ -0,0 +1,142 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using AAXClean;
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 List<string> multiPartFilePaths { get; } = new List<string>();
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat,
Func<MultiConvertFileProperties, string> multipartFileNameCallback = null)
: base(outFileName, cacheDirectory, dlLic, outputFormat)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + OutputFormat,
["Step 1: Get Aaxc Metadata"] = Step_GetMetadata,
["Step 2: Download Decrypted Audiobook"] = Step_DownloadAudiobookAsMultipleFilesPerChapter,
["Step 3: Cleanup"] = Step_Cleanup,
};
this.multipartFileNameCallback = multipartFileNameCallback ?? MultiConvertFileProperties.DefaultMultipartFilename;
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
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();
var chapters = DownloadLicense.ChapterInfo.Chapters.ToList();
// Ensure split files are at least minChapterLength in duration.
var splitChapters = new ChapterInfo();
var runningTotal = TimeSpan.Zero;
string title = "";
for (int i = 0; i < chapters.Count; i++)
{
if (runningTotal == TimeSpan.Zero)
title = chapters[i].Title;
runningTotal += chapters[i].Duration;
if (runningTotal >= minChapterLength)
{
splitChapters.AddChapter(title, runningTotal);
runningTotal = TimeSpan.Zero;
}
}
// reset, just in case
multiPartFilePaths.Clear();
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
if (OutputFormat == OutputFormat.M4b)
ConvertToMultiMp4a(splitChapters);
else
ConvertToMultiMp3(splitChapters);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
Step_DownloadAudiobook_End(zeroProgress);
var success = !IsCanceled;
if (success)
foreach (var path in multiPartFilePaths)
OnFileCreated(path);
return success;
}
private void ConvertToMultiMp4a(ChapterInfo splitChapters)
{
var chapterCount = 0;
AaxFile.ConvertToMultiMp4a(splitChapters, newSplitCallback =>
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback)
);
}
private void ConvertToMultiMp3(ChapterInfo splitChapters)
{
var chapterCount = 0;
AaxFile.ConvertToMultiMp3(splitChapters, newSplitCallback =>
{
createOutputFileStream(++chapterCount, splitChapters, newSplitCallback);
newSplitCallback.LameConfig.ID3.Track = chapterCount.ToString();
});
}
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);
FileUtility.SaferDelete(fileName);
newSplitCallback.OutputFile = File.Open(fileName, FileMode.OpenOrCreate);
}
}
}
@@ -0,0 +1,53 @@
using System;
using System.IO;
using AAXClean;
using Dinah.Core.StepRunner;
using FileManager;
namespace AaxDecrypter
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
protected override StepSequence Steps { get; }
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, DownloadLicense dlLic, OutputFormat outputFormat)
: base(outFileName, cacheDirectory, dlLic, outputFormat)
{
Steps = new StepSequence
{
Name = "Download and Convert Aaxc To " + 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,
};
}
private bool Step_DownloadAudiobookAsSingleFile()
{
var zeroProgress = Step_DownloadAudiobook_Start();
FileUtility.SaferDelete(OutputFileName);
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
var decryptionResult
= OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMp4a(outputFile, DownloadLicense.ChapterInfo)
: AaxFile.ConvertToMp3(outputFile);
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
DownloadLicense.ChapterInfo = AaxFile.Chapters;
Step_DownloadAudiobook_End(zeroProgress);
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
if (success)
base.OnFileCreated(OutputFileName);
return success;
}
}
}
+149
View File
@@ -0,0 +1,149 @@
using System;
using System.IO;
using Dinah.Core;
using Dinah.Core.Net.Http;
using Dinah.Core.StepRunner;
using FileManager;
namespace AaxDecrypter
{
public enum OutputFormat { M4b, Mp3 }
public abstract class AudiobookDownloadBase
{
public event EventHandler<string> RetrievedTitle;
public event EventHandler<string> RetrievedAuthors;
public event EventHandler<string> RetrievedNarrators;
public event EventHandler<byte[]> RetrievedCoverArt;
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
protected bool IsCanceled { get; set; }
protected string OutputFileName { get; private set; }
protected DownloadLicense DownloadLicense { 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; }
private string tempFile => Path.ChangeExtension(jsonDownloadState, ".tmp");
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, DownloadLicense dlLic)
{
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
var outDir = Path.GetDirectoryName(OutputFileName);
if (!Directory.Exists(outDir))
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(outDir)}");
if (!Directory.Exists(cacheDirectory))
throw new DirectoryNotFoundException($"Directory does not exist: {nameof(cacheDirectory)}");
jsonDownloadState = Path.Combine(cacheDirectory, Path.ChangeExtension(OutputFileName, ".json"));
DownloadLicense = ArgumentValidator.EnsureNotNull(dlLic, nameof(dlLic));
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
}
public abstract void Cancel();
public virtual void SetCoverArt(byte[] coverArt)
{
if (coverArt is not null)
OnRetrievedCoverArt(coverArt);
}
public bool Run()
{
var (IsSuccess, Elapsed) = Steps.Run();
if (!IsSuccess)
Serilog.Log.Logger.Error("Conversion failed");
return IsSuccess;
}
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors)
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
protected void OnFileCreated(string path)
=> FileCreated?.Invoke(this, path);
protected void CloseInputFileStream()
{
nfsPersister?.NetworkFileStream?.Close();
nfsPersister?.Dispose();
}
protected bool Step_CreateCue()
{
// not a critical step. its failure should not prevent future steps from running
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
path = FileUtility.GetValidFilename(path);
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadLicense.ChapterInfo));
OnFileCreated(path);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
}
return !IsCanceled;
}
protected bool Step_Cleanup()
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(tempFile);
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
{
if (!File.Exists(jsonDownloadState))
return NewNetworkFilePersister();
try
{
var nfsp = new NetworkFileStreamPersister(jsonDownloadState);
// If More than ~1 hour has elapsed since getting the download url, it will expire.
// The new url will be to the same file.
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadLicense.DownloadUrl));
return nfsp;
}
catch
{
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(tempFile);
return NewNetworkFilePersister();
}
}
private NetworkFileStreamPersister NewNetworkFilePersister()
{
var headers = new System.Net.WebHeaderCollection
{
{ "User-Agent", DownloadLicense.UserAgent }
};
var networkFileStream = new NetworkFileStream(tempFile, new Uri(DownloadLicense.DownloadUrl), 0, headers);
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
}
}
}
+3 -6
View File
@@ -13,15 +13,12 @@ namespace AaxDecrypter
public DownloadLicense(string downloadUrl, string audibleKey, string audibleIV, string userAgent) public DownloadLicense(string downloadUrl, string audibleKey, string audibleIV, string userAgent)
{ {
ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl)); DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
ArgumentValidator.EnsureNotNullOrEmpty(audibleKey, nameof(audibleKey)); UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
ArgumentValidator.EnsureNotNullOrEmpty(audibleIV, nameof(audibleIV));
ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
DownloadUrl = downloadUrl; // no null/empty check. unencrypted files do not have these
AudibleKey = audibleKey; AudibleKey = audibleKey;
AudibleIV = audibleIV; AudibleIV = audibleIV;
UserAgent = userAgent;
} }
} }
} }
@@ -0,0 +1,25 @@
using System;
using System.IO;
using FileManager;
namespace AaxDecrypter
{
public class MultiConvertFileProperties
{
public string OutputFileName { get; set; }
public int PartsPosition { get; set; }
public int PartsTotal { get; set; }
public string Title { get; set; }
public static string DefaultMultipartFilename(MultiConvertFileProperties multiConvertFileProperties)
{
var template = Path.ChangeExtension(multiConvertFileProperties.OutputFileName, null) + " - <ch# 0> - <title>" + Path.GetExtension(multiConvertFileProperties.OutputFileName);
var fileNamingTemplate = new FileNamingTemplate(template) { IllegalCharacterReplacements = " " };
fileNamingTemplate.AddParameterReplacement("ch# 0", FileUtility.GetSequenceFormatted(multiConvertFileProperties.PartsPosition, multiConvertFileProperties.PartsTotal));
fileNamingTemplate.AddParameterReplacement("title", multiConvertFileProperties.Title ?? "");
return fileNamingTemplate.GetFilePath();
}
}
}
-54
View File
@@ -1,54 +0,0 @@
using AAXClean;
using Dinah.Core;
namespace AaxDecrypter
{
public static class NFO
{
public static string CreateContents(string ripper, Mp4File aaxcTagLib, ChapterInfo chapters)
{
var _hours = (int)aaxcTagLib.Duration.TotalHours;
var myDuration
= (_hours > 0 ? _hours + " hours, " : string.Empty)
+ aaxcTagLib.Duration.Minutes + " minutes, "
+ aaxcTagLib.Duration.Seconds + " seconds";
var nfoString
= "General Information\r\n"
+ "======================\r\n"
+ $" Title: {aaxcTagLib.AppleTags.TitleSansUnabridged?.UnicodeToAscii() ?? "[unknown]"}\r\n"
+ $" Author: {aaxcTagLib.AppleTags.FirstAuthor?.UnicodeToAscii() ?? "[unknown]"}\r\n"
+ $" Read By: {aaxcTagLib.AppleTags.Narrator?.UnicodeToAscii() ?? "[unknown]"}\r\n"
+ $" Release Date: {aaxcTagLib.AppleTags.ReleaseDate ?? "[unknown]"}\r\n"
+ $" Book Copyright: {aaxcTagLib.AppleTags.BookCopyright ?? "[unknown]"}\r\n"
+ $" Recording Copyright: {aaxcTagLib.AppleTags.RecordingCopyright ?? "[unknown]"}\r\n"
+ $" Genre: {aaxcTagLib.AppleTags.Generes ?? "[unknown]"}\r\n"
+ $" Publisher: {aaxcTagLib.AppleTags.Publisher ?? "[unknown]"}\r\n"
+ $" Duration: {myDuration}\r\n"
+ $" Chapters: {chapters.Count}\r\n"
+ "\r\n"
+ "\r\n"
+ "Media Information\r\n"
+ "======================\r\n"
+ " Source Format: Audible AAXC\r\n"
+ $" Source Sample Rate: {aaxcTagLib.TimeScale} Hz\r\n"
+ $" Source Channels: {aaxcTagLib.AudioChannels}\r\n"
+ $" Source Bitrate: {aaxcTagLib.AverageBitrate} Kbps\r\n"
+ "\r\n"
+ " Lossless Encode: Yes\r\n"
+ " Encoded Codec: AAC / M4B\r\n"
+ $" Encoded Sample Rate: {aaxcTagLib.TimeScale} Hz\r\n"
+ $" Encoded Channels: {aaxcTagLib.AudioChannels}\r\n"
+ $" Encoded Bitrate: {aaxcTagLib.AverageBitrate} Kbps\r\n"
+ "\r\n"
+ $" Ripper: {ripper}\r\n"
+ "\r\n"
+ "\r\n"
+ "Book Description\r\n"
+ "================\r\n"
+ (!string.IsNullOrWhiteSpace(aaxcTagLib.AppleTags.LongDescription) ? aaxcTagLib.AppleTags.LongDescription.UnicodeToAscii() : aaxcTagLib.AppleTags.Comment?.UnicodeToAscii());
return nfoString;
}
}
}
+9 -10
View File
@@ -83,7 +83,7 @@ namespace AaxDecrypter
private FileStream _readFile { get; } private FileStream _readFile { get; }
private Stream _networkStream { get; set; } private Stream _networkStream { get; set; }
private bool hasBegunDownloading { get; set; } private bool hasBegunDownloading { get; set; }
private bool isCancelled { get; set; } public bool IsCancelled { get; private set; }
private EventWaitHandle downloadEnded { get; set; } private EventWaitHandle downloadEnded { get; set; }
private EventWaitHandle downloadedPiece { get; set; } private EventWaitHandle downloadedPiece { get; set; }
@@ -193,9 +193,6 @@ namespace AaxDecrypter
if (response.StatusCode != HttpStatusCode.PartialContent) if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}."); throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
if (response.Headers.GetValues("Accept-Ranges").FirstOrDefault(r => r.EqualsInsensitive("bytes")) is null)
throw new WebException($"Server at {Uri.Host} does not support Http ranges");
//Content length is the length of the range request, and it is only equal //Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0- //to the complete file length if requesting Range: bytes=0-
if (WritePosition == 0) if (WritePosition == 0)
@@ -238,20 +235,22 @@ namespace AaxDecrypter
downloadedPiece.Set(); downloadedPiece.Set();
} }
} while (downloadPosition < ContentLength && !isCancelled); } while (downloadPosition < ContentLength && !IsCancelled);
_writeFile.Close(); _writeFile.Close();
_networkStream.Close();
WritePosition = downloadPosition; WritePosition = downloadPosition;
Update(); Update();
_networkStream.Close();
if (!isCancelled && WritePosition < ContentLength) downloadedPiece.Set();
downloadEnded.Set();
if (!IsCancelled && WritePosition < ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10})."); throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength) if (WritePosition > ContentLength)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10})."); throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
downloadEnded.Set();
} }
#endregion #endregion
@@ -419,12 +418,12 @@ namespace AaxDecrypter
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param> /// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
private void WaitToPosition(long requiredPosition) private void WaitToPosition(long requiredPosition)
{ {
while (requiredPosition > WritePosition && !isCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ; while (requiredPosition > WritePosition && !IsCancelled && hasBegunDownloading && !downloadedPiece.WaitOne(1000)) ;
} }
public override void Close() public override void Close()
{ {
isCancelled = true; IsCancelled = true;
while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ; while (downloadEnded is not null && !downloadEnded.WaitOne(1000)) ;
@@ -0,0 +1,76 @@
using System;
using System.Threading;
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, DownloadLicense dlLic)
: base(outFileName, cacheDirectory, dlLic)
{
Steps = new StepSequence
{
Name = "Download Mp3 Audiobook",
["Step 1: Get Mp3 Metadata"] = Step_GetMetadata,
["Step 2: Download Audiobook"] = Step_DownloadAudiobookAsSingleFile,
["Step 3: Create Cue"] = Step_CreateCue,
["Step 4: Cleanup"] = Step_Cleanup,
};
}
public override void Cancel()
{
IsCanceled = true;
CloseInputFileStream();
}
protected bool Step_GetMetadata()
{
OnRetrievedCoverArt(null);
return !IsCanceled;
}
private bool Step_DownloadAudiobookAsSingleFile()
{
DateTime startTime = DateTime.Now;
// MUST put InputFileStream.Length first, because it starts background downloader.
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
{
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
var estTimeRemaining = (InputFileStream.Length - InputFileStream.WritePosition) / rate;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = 100 * progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
TotalBytesToReceive = InputFileStream.Length
});
Thread.Sleep(200);
}
CloseInputFileStream();
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName);
SetOutputFileName(realOutputFileName);
OnFileCreated(realOutputFileName);
return !IsCanceled;
}
}
}
File renamed without changes.
+21
View File
@@ -0,0 +1,21 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
<Version>6.6.9.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="MSBump" Version="2.3.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Octokit" Version="0.50.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
</ItemGroup>
</Project>
+381
View File
@@ -0,0 +1,381 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using AudibleUtilities;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
using Newtonsoft.Json.Linq;
using Serilog;
namespace AppScaffolding
{
public static class LibationScaffolding
{
// AppScaffolding
private static Assembly _executingAssembly;
private static Assembly ExecutingAssembly
=> _executingAssembly ??= Assembly.GetExecutingAssembly();
// LibationWinForms or LibationCli
private static Assembly _entryAssembly;
private static Assembly EntryAssembly
=> _entryAssembly ??= Assembly.GetEntryAssembly();
// previously: System.Reflection.Assembly.GetExecutingAssembly().GetName().Version;
private static Version _buildVersion;
public static Version BuildVersion
=> _buildVersion
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
.Max(a => a.Version);
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
{
// must occur before access to Configuration instance
Migrations.migrate_to_v5_2_0__pre_config();
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
return Configuration.Instance;
}
/// <summary>most migrations go in here</summary>
public static void RunPostConfigMigrations(Configuration config)
{
AudibleApiStorage.EnsureAccountsSettingsFileExists();
PopulateMissingConfigValues(config);
//
// migrations go below here
//
Migrations.migrate_to_v6_6_9(config);
}
public static void PopulateMissingConfigValues(Configuration config)
{
config.InProgress ??= Configuration.WinTemp;
if (!config.Exists(nameof(config.AllowLibationFixup)))
config.AllowLibationFixup = true;
if (!config.Exists(nameof(config.DecryptToLossy)))
config.DecryptToLossy = false;
if (!config.Exists(nameof(config.BadBook)))
config.BadBook = Configuration.BadBookAction.Ask;
if (!config.Exists(nameof(config.DownloadEpisodes)))
config.DownloadEpisodes = true;
if (!config.Exists(nameof(config.ImportEpisodes)))
config.ImportEpisodes = true;
if (!config.Exists(nameof(config.SplitFilesByChapter)))
config.SplitFilesByChapter = false;
if (!config.Exists(nameof(config.FolderTemplate)))
config.FolderTemplate = Templates.Folder.DefaultTemplate;
if (!config.Exists(nameof(config.FileTemplate)))
config.FileTemplate = Templates.File.DefaultTemplate;
if (!config.Exists(nameof(config.ChapterFileTemplate)))
config.ChapterFileTemplate = Templates.ChapterFile.DefaultTemplate;
if (!config.Exists(nameof(config.ShowImportedStats)))
config.ShowImportedStats = true;
}
/// <summary>Initialize logging. Run after migration</summary>
public static void RunPostMigrationScaffolding(Configuration config)
{
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
}
private static void ensureSerilogConfig(Configuration config)
{
if (config.GetObject("Serilog") != null)
return;
var serilogObj = new JObject
{
{ "MinimumLevel", "Information" },
{ "WriteTo", new JArray
{
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject
{
{ "Name", "File" },
{ "Args",
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
// {Properties:j} needed for expanded exception logging
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
}
}
}
}
},
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetObject("Serilog", serilogObj);
}
// to restore original: Console.SetOut(origOut);
private static TextWriter origOut { get; } = Console.Out;
private static void configureLogging(Configuration config)
{
config.ConfigureLogging();
// capture most Console.WriteLine() and write to serilog. See below tests for details.
// Some dependencies print helpful info via Console.WriteLine. We'd like to log it.
//
// If Serilog also writes to Console, this might be asking for trouble. ie: infinite loops.
// To use that way, SerilogTextWriter needs to be more robust and tested. Esp the Write() methods.
// However, empirical testing so far has shown no issues.
Console.SetOut(new MultiTextWriter(origOut, new SerilogTextWriter()));
#region Console => Serilog tests
/*
// all below apply to "Console." and "Console.Out."
// captured
Console.WriteLine("str");
Console.WriteLine(new { a = "anon" });
Console.WriteLine("{0}", "format");
Console.WriteLine("{0}{1}", "zero|", "one");
Console.WriteLine("{0}{1}{2}", "zero|", "one|", "two");
Console.WriteLine("{0}", new object[] { "arr" });
// not captured
Console.WriteLine();
Console.WriteLine(true);
Console.WriteLine('0');
Console.WriteLine(1);
Console.WriteLine(2m);
Console.WriteLine(3f);
Console.WriteLine(4d);
Console.WriteLine(5L);
Console.WriteLine((uint)6);
Console.WriteLine((ulong)7);
Console.Write("str");
Console.Write(true);
Console.Write('0');
Console.Write(1);
Console.Write(2m);
Console.Write(3f);
Console.Write(4d);
Console.Write(5L);
Console.Write((uint)6);
Console.Write((ulong)7);
Console.Write(new { a = "anon" });
Console.Write("{0}", "format");
Console.Write("{0}{1}", "zero|", "one");
Console.Write("{0}{1}{2}", "zero|", "one|", "two");
Console.Write("{0}", new object[] { "arr" });
*/
#endregion
// .Here() captures debug info via System.Runtime.CompilerServices attributes. Warning: expensive
//var withLineNumbers_outputTemplate = "[{Timestamp:HH:mm:ss} {Level}] {SourceContext}{NewLine}{Message}{NewLine}in method {MemberName} at {FilePath}:{LineNumber}{NewLine}{Exception}{NewLine}";
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
private static void logStartupState(Configuration config)
{
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
Log.Logger.Information("Begin. {@DebugInfo}", new
{
AppName = EntryAssembly.GetName().Name,
Version = BuildVersion.ToString(),
#if DEBUG
Mode = "Debug",
#else
Mode = "Release",
#endif
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(),
LogLevel_Information_Enabled = Log.Logger.IsInformationEnabled(),
LogLevel_Warning_Enabled = Log.Logger.IsWarningEnabled(),
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.LibationFiles,
AudibleFileStorage.BooksDirectory,
config.InProgress,
AudibleFileStorage.DownloadsInProgressDirectory,
DownloadsInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = Directory.EnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
});
}
public static (bool hasUpgrade, string zipUrl, string htmlUrl, string zipName) GetLatestRelease()
{
(bool, string, string, string) isFalse = (false, null, null, null);
// timed out
var latest = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null)
return isFalse;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return isFalse;
// we're up to date
if (latestRelease <= BuildVersion)
return isFalse;
// we have an update
var zip = latest.Assets.FirstOrDefault(a => a.BrowserDownloadUrl.EndsWith(".zip"));
var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new
{
latestRelease = latestRelease.ToString(),
latest.HtmlUrl,
zipUrl
});
return (true, zipUrl, latest.HtmlUrl, zip.Name);
}
private static Octokit.Release getLatestRelease(TimeSpan timeout)
{
try
{
var task = System.Threading.Tasks.Task.Run(() => getLatestRelease());
if (task.Wait(timeout))
return task.Result;
Log.Logger.Information("Timed out");
}
catch (AggregateException aggEx)
{
Log.Logger.Error(aggEx, "Checking for new version too often");
}
return null;
}
private static Octokit.Release getLatestRelease()
{
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue("Libation"));
// https://octokitnet.readthedocs.io/en/latest/releases/
var releases = gitHubClient.Repository.Release.GetAll("rmcrackan", "Libation").GetAwaiter().GetResult();
var latest = releases.First(r => !r.Draft && !r.Prerelease);
return latest;
}
}
internal static class Migrations
{
#region migrate to v5.2.0
// get rid of meta-directories, combine DownloadsInProgressEnum and DecryptInProgressEnum => InProgress
public static void migrate_to_v5_2_0__pre_config()
{
{
var settingsKey = "DownloadsInProgressEnum";
if (UNSAFE_MigrationHelper.Settings_TryGet(settingsKey, out var value))
{
UNSAFE_MigrationHelper.Settings_Delete(settingsKey);
UNSAFE_MigrationHelper.Settings_Insert("InProgress", translatePath(value));
}
}
{
UNSAFE_MigrationHelper.Settings_Delete("DecryptInProgressEnum");
}
{ // appsettings.json
var appSettingsKey = UNSAFE_MigrationHelper.LIBATION_FILES_KEY;
if (UNSAFE_MigrationHelper.APPSETTINGS_TryGet(appSettingsKey, out var value))
UNSAFE_MigrationHelper.APPSETTINGS_Update(appSettingsKey, translatePath(value));
}
}
private static string translatePath(string path)
=> path switch
{
"AppDir" => @".\LibationFiles",
"MyDocs" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.MyDocuments), "LibationFiles")),
"UserProfile" => Path.GetFullPath(Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation")),
"WinTemp" => Path.GetFullPath(Path.Combine(Path.GetTempPath(), "Libation")),
_ => path
};
#endregion
public static void migrate_to_v6_6_9(Configuration config)
{
var writeToPath = $"Serilog.WriteTo";
// remove WriteTo[].Name == Console
{
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
{
for (var i = length1 - 1; i >= 0; i--)
{
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
if (exists && value == "Console")
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
}
}
}
// add Serilog.Exceptions -- WithExceptionDetails
{
// outputTemplate should contain "{Properties:j}"
{
// re-calculate. previous loop may have changed the length
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
{
var propertyName = "outputTemplate";
for (var i = 0; i < length2; i++)
{
var jsonPath = $"{writeToPath}[{i}].Args";
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
if (exists && value != newValue)
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
}
}
}
// Serilog.Using must include "Serilog.Exceptions"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
// Serilog.Enrich must include "WithExceptionDetails"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
}
}
@@ -6,13 +6,21 @@ using Dinah.Core;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
namespace LibationLauncher namespace AppScaffolding
{ {
/// <summary>for migrations only. directly manipulatings settings files without going through domain logic</summary> /// <summary>
///
///
/// directly manipulates settings files without going through domain logic.
///
/// for migrations only. use with caution.
///
///
/// </summary>
internal static class UNSAFE_MigrationHelper internal static class UNSAFE_MigrationHelper
{ {
#region appsettings.json #region appsettings.json
private const string APPSETTINGS_JSON = "appsettings.json"; private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON); public static bool APPSETTINGS_Json_Exists => File.Exists(APPSETTINGS_JSON);
@@ -53,7 +61,16 @@ namespace LibationLauncher
return; return;
var startingContents = File.ReadAllText(APPSETTINGS_JSON); var startingContents = File.ReadAllText(APPSETTINGS_JSON);
var jObj = JObject.Parse(startingContents);
JObject jObj;
try
{
jObj = JObject.Parse(startingContents);
}
catch
{
return;
}
action(jObj); action(jObj);
@@ -96,6 +113,108 @@ namespace LibationLauncher
return success; return success;
} }
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
{
JToken val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
if (val?.Type == JTokenType.String)
{
value = val.Value<string>();
return true;
}
else
{
value = null;
return false;
}
}
public static void Settings_SetWithJsonPath(string jsonPath, string propertyName, string newValue)
{
if (!Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out _))
return;
process_SettingsJson(jObj =>
{
var token = jObj.SelectToken(jsonPath);
if (token is null
|| token is not JObject o
|| o[propertyName] is null)
return;
var oldValue = token.Value<string>(propertyName);
if (oldValue != newValue)
token[propertyName] = newValue;
});
}
public static bool Settings_TryGetArrayLength(string jsonPath, out int length)
{
length = 0;
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
length = array.Count;
return true;
}
public static void Settings_AddToArray(string jsonPath, string newValue)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
array.Add(newValue);
});
}
/// <summary>Do not add if already exists</summary>
public static void Settings_AddUniqueToArray(string arrayPath, string newValue)
{
if (!Settings_TryGetArrayLength(arrayPath, out var qty))
return;
for (var i = 0; i < qty; i++)
{
var exists = Settings_TryGetFromJsonPath($"{arrayPath}[{i}]", out var value);
if (exists && value == newValue)
return;
}
Settings_AddToArray(arrayPath, newValue);
}
/// <summary>only remove if not exists</summary>
public static void Settings_RemoveFromArray(string jsonPath, int position)
{
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return;
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
if (position < array.Count)
array.RemoveAt(position);
});
}
/// <summary>only insert if not exists</summary> /// <summary>only insert if not exists</summary>
public static void Settings_Insert(string key, string value) public static void Settings_Insert(string key, string value)
=> process_SettingsJson(jObj => jObj.TryAdd(key, value)); => process_SettingsJson(jObj => jObj.TryAdd(key, value));
@@ -122,7 +241,16 @@ namespace LibationLauncher
return; return;
var startingContents = File.ReadAllText(SettingsJsonPath); var startingContents = File.ReadAllText(SettingsJsonPath);
var jObj = JObject.Parse(startingContents);
JObject jObj;
try
{
jObj = JObject.Parse(startingContents);
}
catch
{
return;
}
action(jObj); action(jObj);
@@ -1,19 +1,16 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="27.1.1" /> <PackageReference Include="CsvHelper" Version="27.2.1" />
<PackageReference Include="NPOI" Version="2.5.3" /> <PackageReference Include="NPOI" Version="2.5.5" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" />
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" /> <ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" /> <ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
</ItemGroup> </ItemGroup>
+10 -5
View File
@@ -1,16 +1,21 @@
using System; using System;
using System.Collections.Generic;
using DataLayer; using DataLayer;
using FileManager; using LibationFileManager;
namespace ApplicationServices namespace ApplicationServices
{ {
public static class DbContexts public static class DbContexts
{ {
//// idea for future command/query separation /// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
// public static LibationContext GetCommandContext() { }
// public static LibationContext GetQueryContext() { }
public static LibationContext GetContext() public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString); => LibationContext.Create(SqliteStorage.ConnectionString);
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking()
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking();
}
} }
} }
+121 -122
View File
@@ -3,32 +3,27 @@ using System.Collections.Generic;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
using AudibleApi; using AudibleApi;
using AudibleUtilities;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using DtoImporterService; using DtoImporterService;
using InternalUtilities; using LibationFileManager;
using Serilog; using Serilog;
using static DtoImporterService.PerfLogger;
namespace ApplicationServices namespace ApplicationServices
{ {
// subtly different from DataLayer.LiberatedStatus
// - DataLayer.LiberatedStatus: has no concept of partially downloaded
// - ApplicationServices.LiberatedState: has no concept of Error/skipped
public enum LiberatedState { NotDownloaded, PartialDownload, Liberated }
public enum PdfState { NoPdf, Downloaded, NotDownloaded }
public static class LibraryCommands public static class LibraryCommands
{ {
private static LibraryOptions.ResponseGroupOptions LibraryResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS; public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, List<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, ILoginCallback> loginCallbackFactoryFunc, List<LibraryBook> existingLibrary, params Account[] accounts)
{
//These are the minimum response groups required for the //These are the minimum response groups required for the
//library scanner to pass all validation and filtering. //library scanner to pass all validation and filtering.
LibraryResponseGroups = var libraryResponseGroups =
LibraryOptions.ResponseGroupOptions.ProductAttrs | LibraryOptions.ResponseGroupOptions.ProductAttrs |
LibraryOptions.ResponseGroupOptions.ProductDesc | LibraryOptions.ResponseGroupOptions.ProductDesc |
LibraryOptions.ResponseGroupOptions.Relationships; LibraryOptions.ResponseGroupOptions.Relationships;
if (accounts is null || accounts.Length == 0) if (accounts is null || accounts.Length == 0)
@@ -36,8 +31,12 @@ namespace ApplicationServices
try try
{ {
var libraryItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts); logTime($"pre {nameof(scanAccountsAsync)} all");
Log.Logger.Information($"GetAllLibraryItems: Total count {libraryItems.Count}"); var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryResponseGroups);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList(); var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
@@ -45,7 +44,7 @@ namespace ApplicationServices
} }
catch (AudibleApi.Authentication.LoginFailedException lfEx) catch (AudibleApi.Authentication.LoginFailedException lfEx)
{ {
lfEx.SaveFiles(FileManager.Configuration.Instance.LibationFiles); lfEx.SaveFiles(Configuration.Instance.LibationFiles);
// nuget Serilog.Exceptions would automatically log custom properties // nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement: // However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -63,38 +62,44 @@ namespace ApplicationServices
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Logger.Error(ex, "Error importing library"); Log.Logger.Error(ex, "Error scanning library");
throw; throw;
} }
finally finally
{ {
LibraryResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS; stop();
} var putBreakPointHere = logOutput;
}
} }
#region FULL LIBRARY scan and import #region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, params Account[] accounts) public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
{ {
logRestart();
if (accounts is null || accounts.Length == 0) if (accounts is null || accounts.Length == 0)
return (0, 0); return (0, 0);
try try
{ {
var importItems = await scanAccountsAsync(loginCallbackFactoryFunc, accounts); logTime($"pre {nameof(scanAccountsAsync)} all");
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count; var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}"); Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems); var newCount = await importIntoDbAsync(importItems);
Log.Logger.Information($"Import: New count {newCount}"); logTime($"post {nameof(importIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
await Task.Run(() => SearchEngineCommands.FullReIndex());
Log.Logger.Information("FullReIndex: success");
return (totalCount, newCount); return (totalCount, newCount);
} }
catch (AudibleApi.Authentication.LoginFailedException lfEx) catch (AudibleApi.Authentication.LoginFailedException lfEx)
{ {
lfEx.SaveFiles(FileManager.Configuration.Instance.LibationFiles); lfEx.SaveFiles(Configuration.Instance.LibationFiles);
// nuget Serilog.Exceptions would automatically log custom properties // nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement: // However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -115,20 +120,23 @@ namespace ApplicationServices
Log.Logger.Error(ex, "Error importing library"); Log.Logger.Error(ex, "Error importing library");
throw; throw;
} }
finally
{
stop();
var putBreakPointHere = logOutput;
}
} }
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, ILoginCallback> loginCallbackFactoryFunc, Account[] accounts) private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
{ {
var tasks = new List<Task<List<ImportItem>>>(); var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts) foreach (var account in accounts)
{ {
var callback = loginCallbackFactoryFunc(account); // get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
// get APIs in serial, esp b/c of logins
var api = await AudibleApiActions.GetApiAsync(callback, account);
// add scanAccountAsync as a TASK: do not await // add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(api, account)); tasks.Add(scanAccountAsync(apiExtended, account, libraryResponseGroups));
} }
// import library in parallel // import library in parallel
@@ -137,7 +145,7 @@ namespace ApplicationServices
return importItems; return importItems;
} }
private static async Task<List<ImportItem>> scanAccountAsync(Api api, Account account) private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions.ResponseGroupOptions libraryResponseGroups)
{ {
ArgumentValidator.EnsureNotNull(account, nameof(account)); ArgumentValidator.EnsureNotNull(account, nameof(account));
@@ -146,142 +154,133 @@ namespace ApplicationServices
Account = account?.MaskedLogEntry ?? "[null]" Account = account?.MaskedLogEntry ?? "[null]"
}); });
var dtoItems = await AudibleApiActions.GetLibraryValidatedAsync(api, LibraryResponseGroups); logTime($"pre scanAccountAsync {account.AccountName}");
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryResponseGroups, Configuration.Instance.ImportEpisodes);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList(); return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
} }
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems) private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
{ {
logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
var libraryImporter = new LibraryImporter(context); var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryImporter.Import(importItems)); var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
context.SaveChanges(); logTime("importIntoDbAsync -- post Import()");
var qtyChanges = context.SaveChanges();
logTime("importIntoDbAsync -- post SaveChanges");
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount; return newCount;
} }
#endregion #endregion
#region remove books
public static Task<List<LibraryBook>> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static List<LibraryBook> removeBooks(List<string> idsToRemove)
{
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
context.LibraryBooks.RemoveRange(removeLibraryBooks);
context.Books.RemoveRange(removeLibraryBooks.Select(lb => lb.Book));
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return removeLibraryBooks;
}
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange()
{
SearchEngineCommands.FullReIndex();
LibrarySizeChanged?.Invoke(null, null);
}
/// <summary>Occurs when books are added or removed from library</summary>
public static event EventHandler LibrarySizeChanged;
/// <summary>
/// Occurs when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/>
/// changed values are successfully persisted.
/// </summary>
public static event EventHandler<string> BookUserDefinedItemCommitted;
#region Update book details #region Update book details
public static int UpdateTags(Book book, string newTags) public static int UpdateUserDefinedItem(Book book)
{ {
try try
{ {
using var context = DbContexts.GetContext(); using var context = DbContexts.GetContext();
var udi = book.UserDefinedItem;
if (udi.Tags == newTags)
return 0;
// Attach() NoTracking entities before SaveChanges() // Attach() NoTracking entities before SaveChanges()
udi.Tags = newTags; context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges(); var qtyChanges = context.SaveChanges();
if (qtyChanges > 0) if (qtyChanges > 0)
{
SearchEngineCommands.UpdateLiberatedStatus(book);
SearchEngineCommands.UpdateBookTags(book); SearchEngineCommands.UpdateBookTags(book);
BookUserDefinedItemCommitted?.Invoke(null, book.AudibleProductId);
}
return qtyChanges; return qtyChanges;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Logger.Error(ex, "Error updating tags"); Log.Logger.Error(ex, $"Error updating {nameof(book.UserDefinedItem)}");
throw;
}
}
public static int UpdateBook(LibraryBook libraryBook, LiberatedStatus liberatedStatus, string finalAudioPath)
{
try
{
using var context = DbContexts.GetContext();
var udi = libraryBook.Book.UserDefinedItem;
if (udi.BookStatus == liberatedStatus && udi.BookLocation == finalAudioPath)
return 0;
// Attach() NoTracking entities before SaveChanges()
udi.BookStatus = liberatedStatus;
udi.BookLocation = finalAudioPath;
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
SearchEngineCommands.UpdateLiberatedStatus(libraryBook.Book);
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error updating tags");
throw;
}
}
public static int UpdatePdf(LibraryBook libraryBook, LiberatedStatus liberatedStatus)
{
try
{
using var context = DbContexts.GetContext();
var udi = libraryBook.Book.UserDefinedItem;
if (udi.PdfStatus == liberatedStatus)
return 0;
// Attach() NoTracking entities before SaveChanges()
udi.PdfStatus = liberatedStatus;
context.Attach(udi).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
var qtyChanges = context.SaveChanges();
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error updating tags");
throw; throw;
} }
} }
#endregion #endregion
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
// exists here for feature predictability. It makes sense for this to be where Liberated_Status is
public static LiberatedStatus? Pdf_Status(Book book) => book.UserDefinedItem.PdfStatus;
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those... // below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public static LiberatedState Liberated_Status(Book book) public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded) { }
=> TransitionalFileLocator.Audio_Exists(book) ? LiberatedState.Liberated
: TransitionalFileLocator.AAXC_Exists(book) ? LiberatedState.PartialDownload
: LiberatedState.NotDownloaded;
public static PdfState Pdf_Status(Book book)
=> !book.Supplements.Any() ? PdfState.NoPdf
: TransitionalFileLocator.PDF_Exists(book) ? PdfState.Downloaded
: PdfState.NotDownloaded;
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int pdfsDownloaded, int pdfsNotDownloaded) { }
public static LibraryStats GetCounts() public static LibraryStats GetCounts()
{ {
var libraryBooks = DbContexts.GetContext().GetLibrary_Flat_NoTracking(); var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks var results = libraryBooks
.AsParallel() .AsParallel()
.Select(lb => Liberated_Status(lb.Book)) .Select(lb => Liberated_Status(lb.Book))
.ToList(); .ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedState.Liberated); var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedState.PartialDownload); var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedState.NotDownloaded); var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress }); Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
var boolResults = libraryBooks var boolResults = libraryBooks
.AsParallel() .AsParallel()
.Where(lb => lb.Book.Supplements.Any()) .Where(lb => lb.Book.HasPdf)
.Select(lb => Pdf_Status(lb.Book)) .Select(lb => Pdf_Status(lb.Book))
.ToList(); .ToList();
var pdfsDownloaded = boolResults.Count(r => r == PdfState.Downloaded); var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == PdfState.NotDownloaded); var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded }); Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, pdfsDownloaded, pdfsNotDownloaded); return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
} }
} }
} }
+25 -24
View File
@@ -44,11 +44,14 @@ namespace ApplicationServices
[Name("Length In Minutes")] [Name("Length In Minutes")]
public int LengthInMinutes { get; set; } public int LengthInMinutes { get; set; }
[Name("Description")]
public string Description { get; set; }
[Name("Publisher")] [Name("Publisher")]
public string Publisher { get; set; } public string Publisher { get; set; }
[Name("Pdf url")] [Name("Has PDF")]
public string PdfUrl { get; set; } public bool HasPdf { get; set; }
[Name("Series Names")] [Name("Series Names")]
public string SeriesNames { get; set; } public string SeriesNames { get; set; }
@@ -89,14 +92,14 @@ namespace ApplicationServices
[Name("My Libation Tags")] [Name("My Libation Tags")]
public string MyLibationTags { get; set; } public string MyLibationTags { get; set; }
[Name("Book Liberation Status")] [Name("Book Liberated Status")]
public string BookStatus { get; set; } public string BookStatus { get; set; }
[Name("Book File Location")] [Name("PDF Liberated Status")]
public string BookLocation { get; set; }
[Name("PDF Liberation Status")]
public string PdfStatus { get; set; } public string PdfStatus { get; set; }
[Name("Content Type")]
public string ContentType { get; set; }
} }
public static class LibToDtos public static class LibToDtos
{ {
@@ -111,10 +114,11 @@ namespace ApplicationServices
AuthorNames = a.Book.AuthorNames, AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames, NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes, LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher, Publisher = a.Book.Publisher,
PdfUrl = a.Book.Supplements?.FirstOrDefault()?.Url, HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames, SeriesNames = a.Book.SeriesNames,
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Index} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "", 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, CommunityRatingOverall = a.Book.Rating?.OverallRating,
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating, CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
CommunityRatingStory = a.Book.Rating?.StoryRating, CommunityRatingStory = a.Book.Rating?.StoryRating,
@@ -127,17 +131,15 @@ namespace ApplicationServices
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
MyLibationTags = a.Book.UserDefinedItem.Tags, MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(), BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
BookLocation = a.Book.UserDefinedItem.BookLocation, PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString() ContentType = a.Book.ContentType.ToString()
}).ToList(); }).ToList();
} }
public static class LibraryExporter public static class LibraryExporter
{ {
public static void ToCsv(string saveFilePath) public static void ToCsv(string saveFilePath)
{ {
using var context = DbContexts.GetContext(); var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any()) if (!dtos.Any())
return; return;
@@ -151,17 +153,14 @@ namespace ApplicationServices
public static void ToJson(string saveFilePath) public static void ToJson(string saveFilePath)
{ {
using var context = DbContexts.GetContext(); var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented); var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
System.IO.File.WriteAllText(saveFilePath, json); System.IO.File.WriteAllText(saveFilePath, json);
} }
public static void ToXlsx(string saveFilePath) public static void ToXlsx(string saveFilePath)
{ {
using var context = DbContexts.GetContext(); var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var dtos = context.GetLibrary_Flat_NoTracking().ToDtos();
var workbook = new XSSFWorkbook(); var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Library"); var sheet = workbook.CreateSheet("Library");
@@ -185,8 +184,9 @@ namespace ApplicationServices
nameof (ExportDto.AuthorNames), nameof (ExportDto.AuthorNames),
nameof (ExportDto.NarratorNames), nameof (ExportDto.NarratorNames),
nameof (ExportDto.LengthInMinutes), nameof (ExportDto.LengthInMinutes),
nameof (ExportDto.Description),
nameof (ExportDto.Publisher), nameof (ExportDto.Publisher),
nameof (ExportDto.PdfUrl), nameof (ExportDto.HasPdf),
nameof (ExportDto.SeriesNames), nameof (ExportDto.SeriesNames),
nameof (ExportDto.SeriesOrder), nameof (ExportDto.SeriesOrder),
nameof (ExportDto.CommunityRatingOverall), nameof (ExportDto.CommunityRatingOverall),
@@ -201,8 +201,8 @@ namespace ApplicationServices
nameof (ExportDto.MyRatingStory), nameof (ExportDto.MyRatingStory),
nameof (ExportDto.MyLibationTags), nameof (ExportDto.MyLibationTags),
nameof (ExportDto.BookStatus), nameof (ExportDto.BookStatus),
nameof (ExportDto.BookLocation), nameof (ExportDto.PdfStatus),
nameof (ExportDto.PdfStatus) nameof (ExportDto.ContentType)
}; };
var col = 0; var col = 0;
foreach (var c in columns) foreach (var c in columns)
@@ -238,8 +238,9 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.AuthorNames); row.CreateCell(col++).SetCellValue(dto.AuthorNames);
row.CreateCell(col++).SetCellValue(dto.NarratorNames); row.CreateCell(col++).SetCellValue(dto.NarratorNames);
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes); row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
row.CreateCell(col++).SetCellValue(dto.Description);
row.CreateCell(col++).SetCellValue(dto.Publisher); row.CreateCell(col++).SetCellValue(dto.Publisher);
row.CreateCell(col++).SetCellValue(dto.PdfUrl); row.CreateCell(col++).SetCellValue(dto.HasPdf);
row.CreateCell(col++).SetCellValue(dto.SeriesNames); row.CreateCell(col++).SetCellValue(dto.SeriesNames);
row.CreateCell(col++).SetCellValue(dto.SeriesOrder); row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
@@ -265,8 +266,8 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.MyLibationTags); row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
row.CreateCell(col++).SetCellValue(dto.BookStatus); row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.BookLocation);
row.CreateCell(col++).SetCellValue(dto.PdfStatus); row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
rowIndex++; rowIndex++;
} }
+5 -4
View File
@@ -10,7 +10,8 @@ namespace ApplicationServices
public static void FullReIndex(SearchEngine engine = null) public static void FullReIndex(SearchEngine engine = null)
{ {
engine ??= new SearchEngine(); engine ??= new SearchEngine();
engine.CreateNewIndex(DbContexts.GetContext()); var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
} }
public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e => public static SearchResultSet Search(string searchString) => performSearchEngineFunc_safe(e =>
@@ -39,17 +40,17 @@ namespace ApplicationServices
} }
} }
private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> action) private static T performSearchEngineFunc_safe<T>(Func<SearchEngine, T> func)
{ {
var engine = new SearchEngine(); var engine = new SearchEngine();
try try
{ {
return action(engine); return func(engine);
} }
catch (FileNotFoundException) catch (FileNotFoundException)
{ {
FullReIndex(engine); FullReIndex(engine);
return action(engine); return func(engine);
} }
} }
} }
@@ -1,46 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using FileManager;
namespace ApplicationServices
{
public static class TransitionalFileLocator
{
public static string Audio_GetPath(Book book)
{
var loc = book?.UserDefinedItem?.BookLocation ?? "";
if (File.Exists(loc))
return loc;
return AudibleFileStorage.Audio.GetPath(book.AudibleProductId);
}
public static bool PDF_Exists(Book book)
{
var status = book?.UserDefinedItem?.PdfStatus;
if (status.HasValue && status.Value == LiberatedStatus.Liberated)
return true;
return AudibleFileStorage.PDF.Exists(book.AudibleProductId);
}
public static bool Audio_Exists(Book book)
{
var status = book?.UserDefinedItem?.BookStatus;
// true since Error == libhack
if (status.HasValue && status.Value != LiberatedStatus.NotLiberated)
return true;
return AudibleFileStorage.Audio.Exists(book.AudibleProductId);
}
public static bool AAXC_Exists(Book book)
{
// this one will actually stay the same. centralizing helps with organization in the interim though
return AudibleFileStorage.AAXC.Exists(book.AudibleProductId);
}
}
}
@@ -6,7 +6,7 @@ using AudibleApi.Authorization;
using Dinah.Core; using Dinah.Core;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace InternalUtilities namespace AudibleUtilities
{ {
public class Account : IUpdatable public class Account : IUpdatable
{ {
@@ -6,7 +6,7 @@ using AudibleApi.Authorization;
using Dinah.Core; using Dinah.Core;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace InternalUtilities namespace AudibleUtilities
{ {
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended // 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
// from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm): // from newtonsoft (https://www.newtonsoft.com/json/help/html/SerializationGuide.htm):
@@ -3,7 +3,7 @@ using AudibleApi.Authorization;
using Dinah.Core.IO; using Dinah.Core.IO;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace InternalUtilities namespace AudibleUtilities
{ {
public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings> public class AccountsSettingsPersister : JsonFilePersister<AccountsSettings>
{ {
+309
View File
@@ -0,0 +1,309 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
using Polly;
using Polly.Retry;
namespace AudibleUtilities
{
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public class ApiExtended
{
public Api Api { get; private set; }
private ApiExtended(Api api) => Api = api;
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginChoiceEager,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginCallback),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginCallback,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with external browser</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginExternal),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginExternal,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(Account account)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
AccountMaskedLogEntry = account.MaskedLogEntry
});
return await CreateAsync(account.AccountId, account.Locale.Name);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
Username = username.ToMask(),
LocaleName = localeName,
});
var api = await EzApiCreator.GetApiAsync(
Localization.Get(localeName),
AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
return new ApiExtended(api);
}
private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>()
// 2 retries == 3 total
.RetryAsync(2);
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS, bool importEpisodes = true)
{
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or not tokens are refreshed
// worse, this 1st dummy call doesn't seem to help:
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
return policy.ExecuteAsync(() => getItemsAsync(responseGroups, importEpisodes));
}
private async Task<List<Item>> getItemsAsync(LibraryOptions.ResponseGroupOptions responseGroups, 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
if (!items.Any())
items = await Api.GetAllLibraryItemsAsync(responseGroups);
await manageEpisodesAsync(items, importEpisodes);
#if DEBUG
//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)
{
var exceptions = v.Validate(items);
if (exceptions != null && exceptions.Any())
throw new AggregateException(exceptions);
}
return items;
}
#region episodes and podcasts
private async Task manageEpisodesAsync(List<Item> items, bool importEpisodes)
{
// add podcasts and episodes to list. If fail, don't let it de-rail the rest of the import
try
{
// get parents
var parents = items.Where(i => i.IsEpisodes).ToList();
#if DEBUG
//var parentsDebug = parents.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText("parents.json", parentsDebug);
#endif
if (!parents.Any())
return;
Serilog.Log.Logger.Information($"{parents.Count} series of shows/podcasts found");
// remove episode parents. even if the following stuff fails, these will still be removed from the collection
items.RemoveAll(i => i.IsEpisodes);
if (importEpisodes)
{
// add children
var children = await getEpisodesAsync(parents);
Serilog.Log.Logger.Information($"{children.Count} episodes of shows/podcasts found");
items.AddRange(children);
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding podcasts and episodes");
}
}
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())
{
results.Add(parent);
continue;
}
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
// 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(),
Title = parent.TitleWithSubtitle
}
};
// overload (read: abuse) IsEpisodes flag
child.Relationships = new Relationship[]
{
new Relationship
{
RelationshipToProduct = RelationshipToProduct.Child,
RelationshipType = RelationshipType.Episode
}
};
}
results.AddRange(children);
}
return results;
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
{
var childrenIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin)
.ToList();
// fetch children in batches
const int batchSize = 20;
var results = new List<Item>();
for (var i = 1; ; i++)
{
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
if (!idBatch.Any())
break;
List<Item> childrenBatch;
try
{
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
#if DEBUG
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
#endif
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = i,
ChildIdBatch = idBatch
});
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results");
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
results.AddRange(childrenBatch);
}
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
ChildCount = childrenIds.Count
});
if (childrenIds.Count != results.Count)
{
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
Serilog.Log.Logger.Error(ex, "Quantity of series episodes defined by parent does not match quantity returned by batch fetching.");
throw ex;
}
return results;
}
#endregion
private static List<IValidator> getValidators()
{
var type = typeof(IValidator);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
}
}
}
@@ -1,9 +1,9 @@
using System; using System;
using System.IO; using System.IO;
using FileManager; using LibationFileManager;
using Newtonsoft.Json; using Newtonsoft.Json;
namespace InternalUtilities namespace AudibleUtilities
{ {
public static class AudibleApiStorage public static class AudibleApiStorage
{ {
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AudibleApiDTOs; using AudibleApi.Common;
namespace InternalUtilities namespace AudibleUtilities
{ {
public interface IValidator public interface IValidator
{ {
@@ -33,8 +33,10 @@ namespace InternalUtilities
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId))) if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items))); exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.ProductId)}", nameof(items)));
if (items.Any(i => string.IsNullOrWhiteSpace(i.Title)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with blank {nameof(Item.Title)}", nameof(items))); // this can happen with podcast episodes
foreach (var i in items.Where(i => string.IsNullOrWhiteSpace(i.Title)))
i.Title = "[blank title]";
return exceptions; return exceptions;
} }
@@ -76,9 +78,9 @@ namespace InternalUtilities
var distinct = items.GetSeriesDistinct(); var distinct = items.GetSeriesDistinct();
if (distinct.Any(s => s.SeriesId is null)) if (distinct.Any(s => s.SeriesId is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(AudibleApiDTOs.Series.SeriesId)}", nameof(items))); exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
if (distinct.Any(s => s.SeriesName is null)) if (distinct.Any(s => s.SeriesName is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(AudibleApiDTOs.Series.SeriesName)}", nameof(items))); exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
return exceptions; return exceptions;
} }
+15
View File
@@ -0,0 +1,15 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net6.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="2.6.2.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
</ItemGroup>
</Project>
+1
View File
@@ -0,0 +1 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(AudibleUtilities) + ".Tests")]
@@ -7,6 +7,20 @@ namespace DataLayer.Configurations
{ {
public void Configure(EntityTypeBuilder<LibraryBook> entity) public void Configure(EntityTypeBuilder<LibraryBook> entity)
{ {
// to allow same book (incl region) with diff acct.s:
//
// this file:
// - composite key:
// entity.HasKey(b => new { b.BookId, b.Account });
// entity.HasIndex(b => b.BookId);
// entity.HasIndex(b => b.Account);
// - change the below relationship since Book+LibraryBook would no longer be 1:1
//
// other files:
// - change Book class since Book+LibraryBook would no longer be 1:1
// - update LibraryBook import code
// - would likely challenge assumptions throughout Libation which have been true up until now
entity.HasKey(b => b.BookId); entity.HasKey(b => b.BookId);
entity entity
+6 -6
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<PropertyGroup> <PropertyGroup>
@@ -12,20 +12,20 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="5.0.5"> <PackageReference Include="Dinah.EntityFrameworkCore" Version="4.0.0.3" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="6.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="5.0.5" /> <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="6.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="5.0.5"> <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="6.0.2">
<PrivateAssets>all</PrivateAssets> <PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference> </PackageReference>
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj" /> <ProjectReference Include="..\LibationFileManager\LibationFileManager.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+23 -12
View File
@@ -15,6 +15,10 @@ namespace DataLayer
Id = id; Id = id;
} }
} }
// enum will be easier than bool to extend later
public enum ContentType { Unknown = 0, Product = 1, Episode = 2 }
public class Book public class Book
{ {
// implementation detail. set by db only. only used by data layer // implementation detail. set by db only. only used by data layer
@@ -25,8 +29,7 @@ namespace DataLayer
public string Title { get; private set; } public string Title { get; private set; }
public string Description { get; private set; } public string Description { get; private set; }
public int LengthInMinutes { get; private set; } public int LengthInMinutes { get; private set; }
public ContentType ContentType { get; private set; }
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
public string Locale { get; private set; } public string Locale { get; private set; }
// mutable // mutable
@@ -51,6 +54,12 @@ namespace DataLayer
// is owned, not optional 1:1 // is owned, not optional 1:1
public UserDefinedItem UserDefinedItem { get; private set; } 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 // is owned, not optional 1:1
/// <summary>The product's aggregate community rating</summary> /// <summary>The product's aggregate community rating</summary>
public Rating Rating { get; private set; } = new Rating(0, 0, 0); public Rating Rating { get; private set; } = new Rating(0, 0, 0);
@@ -64,9 +73,11 @@ namespace DataLayer
string title, string title,
string description, string description,
int lengthInMinutes, int lengthInMinutes,
ContentType contentType,
IEnumerable<Contributor> authors, IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators, IEnumerable<Contributor> narrators,
Category category, string localeName) Category category,
string localeName)
{ {
// validate // validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId)); ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));
@@ -88,9 +99,10 @@ namespace DataLayer
Category = category; Category = category;
// simple assigns // simple assigns
Title = title.Trim(); Title = title.Trim() ?? "";
Description = description.Trim(); Description = description?.Trim() ?? "";
LengthInMinutes = lengthInMinutes; LengthInMinutes = lengthInMinutes;
ContentType = contentType;
// assigns with biz logic // assigns with biz logic
ReplaceAuthors(authors); ReplaceAuthors(authors);
@@ -173,6 +185,9 @@ namespace DataLayer
{ {
get get
{ {
if (_seriesLink is null)
return "";
// first: alphabetical by name // first: alphabetical by name
var withNames = _seriesLink var withNames = _seriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name)) .Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
@@ -191,7 +206,7 @@ namespace DataLayer
} }
} }
public void UpsertSeries(Series series, float? index = null, DbContext context = null) public void UpsertSeries(Series series, string order, DbContext context = null)
{ {
ArgumentValidator.EnsureNotNull(series, nameof(series)); ArgumentValidator.EnsureNotNull(series, nameof(series));
@@ -202,9 +217,9 @@ namespace DataLayer
var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series); var singleSeriesBook = _seriesLink.SingleOrDefault(sb => sb.Series == series);
if (singleSeriesBook == null) if (singleSeriesBook == null)
_seriesLink.Add(new SeriesBook(series, this, index)); _seriesLink.Add(new SeriesBook(series, this, order));
else else
singleSeriesBook.UpdateIndex(index); singleSeriesBook.UpdateOrder(order);
} }
#endregion #endregion
@@ -247,10 +262,6 @@ namespace DataLayer
Category = category; Category = category;
} }
// needed for v3 => v4 upgrade
public void UpdateLocale(string localeName)
=> Locale ??= localeName;
public override string ToString() => $"[{AudibleProductId}] {Title}"; public override string ToString() => $"[{AudibleProductId}] {Title}";
} }
} }
-6
View File
@@ -9,8 +9,6 @@ namespace DataLayer
public Book Book { get; private set; } public Book Book { get; private set; }
public DateTime DateAdded { get; private set; } public DateTime DateAdded { get; private set; }
// immutable-ish. should be immutable. mutability is necessary for v3 => v4 upgrades
public string Account { get; private set; } public string Account { get; private set; }
private LibraryBook() { } private LibraryBook() { }
@@ -24,10 +22,6 @@ namespace DataLayer
Account = account; Account = account;
} }
// needed for v3 => v4 upgrade
public void UpdateAccount(string account)
=> Account ??= account;
public override string ToString() => $"{DateAdded:d} {Book}"; public override string ToString() => $"{DateAdded:d} {Book}";
} }
} }
-19
View File
@@ -48,25 +48,6 @@ namespace DataLayer
Name = name; Name = name;
} }
public void AddBook(Book book, float? index = null, DbContext context = null)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
// our add() is conditional upon what's already included in the collection.
// therefore if not loaded, a trip is required. might as well just load it
if (_booksLink == null)
{
ArgumentValidator.EnsureNotNull(context, nameof(context));
if (!context.Entry(this).IsKeySet)
throw new InvalidOperationException("Could not add series");
context.Entry(this).Collection(s => s.BooksLink).Load();
}
if (_booksLink.SingleOrDefault(sb => sb.Book == book) == null)
_booksLink.Add(new SeriesBook(this, book, index));
}
public override string ToString() => Name; public override string ToString() => Name;
} }
} }
+7 -12
View File
@@ -7,32 +7,27 @@ namespace DataLayer
internal int SeriesId { get; private set; } internal int SeriesId { get; private set; }
internal int BookId { get; private set; } internal int BookId { get; private set; }
/// <summary> public string Order { get; private set; }
/// <para>"index" not "order". This is both for sequence and display</para> public float Index => StringLib.ExtractFirstNumber(Order);
/// <para>Float allows for in-between books. eg: 2.5</para>
/// <para>To show 2 editions as the same book in a series, give them the same index</para>
/// <para>null IS NOT the same as 0. Some series call a book "book 0"</para>
/// </summary>
public float? Index { get; private set; }
public Series Series { get; private set; } public Series Series { get; private set; }
public Book Book { get; private set; } public Book Book { get; private set; }
private SeriesBook() { } private SeriesBook() { }
internal SeriesBook(Series series, Book book, float? index = null) internal SeriesBook(Series series, Book book, string order)
{ {
ArgumentValidator.EnsureNotNull(series, nameof(series)); ArgumentValidator.EnsureNotNull(series, nameof(series));
ArgumentValidator.EnsureNotNull(book, nameof(book)); ArgumentValidator.EnsureNotNull(book, nameof(book));
Series = series; Series = series;
Book = book; Book = book;
Index = index; Order = order;
} }
public void UpdateIndex(float? index) public void UpdateOrder(string order)
{ {
if (index.HasValue) if (!string.IsNullOrWhiteSpace(order))
Index = index.Value; Order = order;
} }
public override string ToString() => $"Series={Series} Book={Book}"; public override string ToString() => $"Series={Series} Book={Book}";
+101 -7
View File
@@ -14,7 +14,10 @@ namespace DataLayer
NotLiberated = 0, NotLiberated = 0,
Liberated = 1, Liberated = 1,
/// <summary>Error occurred during liberation. Don't retry</summary> /// <summary>Error occurred during liberation. Don't retry</summary>
Error = 2 Error = 2,
/// <summary>Application-state only. Not a valid persistence state.</summary>
PartialDownload = 0x1000
} }
public class UserDefinedItem public class UserDefinedItem
@@ -30,7 +33,7 @@ namespace DataLayer
// import previously saved tags // import previously saved tags
ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId)); ArgumentValidator.EnsureNotNullOrWhiteSpace(book.AudibleProductId, nameof(book.AudibleProductId));
Tags = FileManager.TagsPersistence.GetTags(book.AudibleProductId); Tags = LibationFileManager.TagsPersistence.GetTags(book.AudibleProductId);
} }
#region Tags #region Tags
@@ -38,7 +41,15 @@ namespace DataLayer
public string Tags public string Tags
{ {
get => _tags; get => _tags;
set => _tags = sanitize(value); set
{
var newTags = sanitize(value);
if (_tags != newTags)
{
_tags = newTags;
OnItemChanged(nameof(Tags));
}
}
} }
public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries); public IEnumerable<string> TagsEnumerated => Tags == "" ? new string[0] : Tags.Split(null as char[], StringSplitOptions.RemoveEmptyEntries);
@@ -94,10 +105,93 @@ namespace DataLayer
=> Rating.Update(overallRating, performanceRating, storyRating); => Rating.Update(overallRating, performanceRating, storyRating);
#endregion #endregion
#region LiberatedStatuses and book file location #region LiberatedStatuses
public LiberatedStatus BookStatus { get; set; } /// <summary>
public string BookLocation { get; set; } /// Occurs when <see cref="Tags"/>, <see cref="BookStatus"/>, or <see cref="PdfStatus"/> values change.
public LiberatedStatus? PdfStatus { get; set; } /// This signals the change of the in-memory value; it does not ensure that the new value has been persisted.
/// </summary>
public static event EventHandler<string> ItemChanged;
private void OnItemChanged(string e)
{
// HACK
// must not fire during initial import.
//
// these checks are necessary because current architecture attaches to this instead of attaching to an event *after* fully committed to db. the attached delegate/action sometimes calls commit:
//
// desired:
// - importing new book
// - update pdf status
// - initial book commit
//
// actual without these checks [BAD]:
// - importing new book
// - update pdf status
// - invoke event
// - commit UserDefinedItem
// - initial book commit
if (BookId > 0 && Book is not null && Book.BookId > 0)
ItemChanged?.Invoke(this, e);
}
private LiberatedStatus _bookStatus;
private LiberatedStatus? _pdfStatus;
public LiberatedStatus BookStatus
{
get => _bookStatus;
set
{
if (_bookStatus != value)
{
_bookStatus = value;
OnItemChanged(nameof(BookStatus));
}
}
}
public LiberatedStatus? PdfStatus
{
get => _pdfStatus;
set
{
if (_pdfStatus != value)
{
_pdfStatus = value;
OnItemChanged(nameof(PdfStatus));
}
}
}
#endregion
#region batch changes
public static event EventHandler<string> Batch_ItemChanged;
public void BatchMode_UpdateBookStatus(LiberatedStatus value)
{
if (_bookStatus != value)
{
_bookStatus = value;
batchFlag = true;
}
}
// don't overwrite current with null. Therefore input is "LiberatedStatus" not "LiberatedStatus?"
public void BatchMode_UpdatePdfStatus(LiberatedStatus value)
{
if (_pdfStatus != value)
{
_pdfStatus = value;
batchFlag = true;
}
}
private static bool batchFlag = false;
public static void BatchMode_Finalize()
{
if (batchFlag)
Batch_ItemChanged?.Invoke(null, null);
batchFlag = false;
}
#endregion #endregion
public override string ToString() => $"{Book} {Rating} {Tags}"; public override string ToString() => $"{Book} {Rating} {Tags}";
+1 -1
View File
@@ -19,7 +19,7 @@ namespace DataLayer
// // overwrite collection // // overwrite collection
// Entry(product).Collection(x => x.Narrators).Load(); // Entry(product).Collection(x => x.Narrators).Load();
// product.Narrators = narrators; // product.Narrators = narrators;
public DbSet<LibraryBook> Library { get; private set; } public DbSet<LibraryBook> LibraryBooks { get; private set; }
public DbSet<Book> Books { get; private set; } public DbSet<Book> Books { get; private set; }
public DbSet<Contributor> Contributors { get; private set; } public DbSet<Contributor> Contributors { get; private set; }
public DbSet<Series> Series { get; private set; } public DbSet<Series> Series { get; private set; }
@@ -0,0 +1,387 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210821012137_RemoveUdiBookLocation")]
partial class RemoveUdiBookLocation
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.5");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.ToTable("Library");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,23 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class RemoveUdiBookLocation : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "BookLocation",
table: "UserDefinedItem");
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<string>(
name: "BookLocation",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
}
}
}
@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210901205042_BookIsEpisode")]
partial class BookIsEpisode
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.9");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.ToTable("Library");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class BookIsEpisode : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<int>(
name: "ContentType",
table: "Books",
type: "INTEGER",
nullable: false,
defaultValue: 0);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "ContentType",
table: "Books");
}
}
}
@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210902192153_RenameLibraryBooks")]
partial class RenameLibraryBooks
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.9");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<float?>("Index")
.HasColumnType("REAL");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,63 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class RenameLibraryBooks : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_Library_Books_BookId",
table: "Library");
migrationBuilder.DropPrimaryKey(
name: "PK_Library",
table: "Library");
migrationBuilder.RenameTable(
name: "Library",
newName: "LibraryBooks");
migrationBuilder.AddPrimaryKey(
name: "PK_LibraryBooks",
table: "LibraryBooks",
column: "BookId");
migrationBuilder.AddForeignKey(
name: "FK_LibraryBooks_Books_BookId",
table: "LibraryBooks",
column: "BookId",
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropForeignKey(
name: "FK_LibraryBooks_Books_BookId",
table: "LibraryBooks");
migrationBuilder.DropPrimaryKey(
name: "PK_LibraryBooks",
table: "LibraryBooks");
migrationBuilder.RenameTable(
name: "LibraryBooks",
newName: "Library");
migrationBuilder.AddPrimaryKey(
name: "PK_Library",
table: "Library",
column: "BookId");
migrationBuilder.AddForeignKey(
name: "FK_Library_Books_BookId",
table: "Library",
column: "BookId",
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
}
}
}
@@ -0,0 +1,390 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20210922154900_AddSeriesOrderString")]
partial class AddSeriesOrderString
{
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "5.0.10");
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("CategoryId")
.HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.HasIndex("CategoryId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.Property<int?>("ParentCategoryCategoryId")
.HasColumnType("INTEGER");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.HasIndex("ParentCategoryCategoryId");
b.ToTable("Categories");
b.HasData(
new
{
CategoryId = -1,
AudibleCategoryId = "",
Name = ""
});
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.HasOne("DataLayer.Category", "Category")
.WithMany()
.HasForeignKey("CategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Category");
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.HasOne("DataLayer.Category", "ParentCategory")
.WithMany()
.HasForeignKey("ParentCategoryCategoryId");
b.Navigation("ParentCategory");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,33 @@
using Microsoft.EntityFrameworkCore.Migrations;
namespace DataLayer.Migrations
{
public partial class AddSeriesOrderString : Migration
{
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Index",
table: "SeriesBook");
migrationBuilder.AddColumn<string>(
name: "Order",
table: "SeriesBook",
type: "TEXT",
nullable: true);
}
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "Order",
table: "SeriesBook");
migrationBuilder.AddColumn<float>(
name: "Index",
table: "SeriesBook",
type: "REAL",
nullable: true);
}
}
}
@@ -14,7 +14,7 @@ namespace DataLayer.Migrations
{ {
#pragma warning disable 612, 618 #pragma warning disable 612, 618
modelBuilder modelBuilder
.HasAnnotation("ProductVersion", "5.0.5"); .HasAnnotation("ProductVersion", "5.0.10");
modelBuilder.Entity("DataLayer.Book", b => modelBuilder.Entity("DataLayer.Book", b =>
{ {
@@ -28,6 +28,9 @@ namespace DataLayer.Migrations
b.Property<int>("CategoryId") b.Property<int>("CategoryId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished") b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT"); .HasColumnType("TEXT");
@@ -152,7 +155,7 @@ namespace DataLayer.Migrations
b.HasKey("BookId"); b.HasKey("BookId");
b.ToTable("Library"); b.ToTable("LibraryBooks");
}); });
modelBuilder.Entity("DataLayer.Series", b => modelBuilder.Entity("DataLayer.Series", b =>
@@ -182,8 +185,8 @@ namespace DataLayer.Migrations
b.Property<int>("BookId") b.Property<int>("BookId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b.Property<float?>("Index") b.Property<string>("Order")
.HasColumnType("REAL"); .HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId"); b.HasKey("SeriesId", "BookId");
@@ -253,9 +256,6 @@ namespace DataLayer.Migrations
b1.Property<int>("BookId") b1.Property<int>("BookId")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
b1.Property<string>("BookLocation")
.HasColumnType("TEXT");
b1.Property<int>("BookStatus") b1.Property<int>("BookStatus")
.HasColumnType("INTEGER"); .HasColumnType("INTEGER");
-19
View File
@@ -1,19 +0,0 @@
using System;
using System.Linq;
namespace DataLayer
{
public static class GenericPaging
{
public static IQueryable<T> Page<T>(this IQueryable<T> query, int pageNumZeroStart, int pageSize)
{
if (pageSize < 1)
throw new ArgumentOutOfRangeException(nameof(pageSize), "pageSize must be at least 1");
if (pageNumZeroStart > 0)
query = query.Skip(pageNumZeroStart * pageSize);
return query.Take(pageSize);
}
}
}
@@ -6,7 +6,7 @@ namespace DataLayer
{ {
// only library importing should use tracking. All else should be NoTracking. // only library importing should use tracking. All else should be NoTracking.
// only library importing should directly query Book. All else should use LibraryBook // only library importing should directly query Book. All else should use LibraryBook
public static class LibraryQueries public static class LibraryBookQueries
{ {
//// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid //// tracking is a bad idea for main grid. it prevents anything else from updating entities unless getting them from the grid
//public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context) //public static List<LibraryBook> GetLibrary_Flat_WithTracking(this LibationContext context)
@@ -17,14 +17,14 @@ namespace DataLayer
public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context) public static List<LibraryBook> GetLibrary_Flat_NoTracking(this LibationContext context)
=> context => context
.Library .LibraryBooks
.AsNoTrackingWithIdentityResolution() .AsNoTrackingWithIdentityResolution()
.GetLibrary() .GetLibrary()
.ToList(); .ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context => context
.Library .LibraryBooks
.AsNoTrackingWithIdentityResolution() .AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId); .GetLibraryBook(productId);
+1 -1
View File
@@ -24,7 +24,7 @@ namespace DataLayer
.Select(t => (t.Book.AudibleProductId, t.Tags)) .Select(t => (t.Book.AudibleProductId, t.Tags))
.ToList(); .ToList();
FileManager.TagsPersistence.Save(tagsCollection); LibationFileManager.TagsPersistence.Save(tagsCollection);
} }
} }
} }
-38
View File
@@ -1,38 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
namespace DataLayer.Utilities
{
public static class LocalDatabaseInfo
{
public static List<string> GetLocalDBInstances()
{
// Start the child process.
using var p = new System.Diagnostics.Process
{
StartInfo = new System.Diagnostics.ProcessStartInfo
{
UseShellExecute = false,
RedirectStandardOutput = true,
FileName = "cmd.exe",
Arguments = "/C sqllocaldb info",
CreateNoWindow = true,
WindowStyle = System.Diagnostics.ProcessWindowStyle.Hidden
}
};
p.Start();
var output = p.StandardOutput.ReadToEnd();
p.WaitForExit();
// if LocalDb is not installed then it will return that 'sqllocaldb' is not recognized as an internal or external command operable program or batch file
return string.IsNullOrWhiteSpace(output) || output.Contains("not recognized")
? new List<string>()
: output
.Split(new string[] { Environment.NewLine }, StringSplitOptions.None)
.Select(i => i.Trim())
.Where(i => !string.IsNullOrEmpty(i))
.ToList();
}
}
}
-57
View File
@@ -1,57 +0,0 @@
FOR QUICK MIGRATION INSTRUCTIONS:
_DB_NOTES.txt
HOW TO CREATE: EF CORE PROJECT
==============================
example is for sqlite but the same works with MsSql
nuget
Microsoft.EntityFrameworkCore.Tools (needed for using Package Manager Console)
Microsoft.EntityFrameworkCore.Sqlite
MIGRATIONS
require core, not standard
this can be a problem b/c standard and framework can only reference standard, not core
TO USE MIGRATIONS (core and/or standard)
add to csproj
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
</PropertyGroup>
TO USE MIGRATIONS AS *BOTH* CORE AND STANDARD
edit csproj
pluralize this xml tag
from: TargetFramework
to: TargetFrameworks
inside of TargetFrameworks
from: netstandard2.1
to: netcoreapp3.1;netstandard2.1
run. error
SQLite Error 1: 'no such table: Blogs'.
set project "Set as StartUp Project"
Tools >> Nuget Package Manager >> Package Manager Console
default project: Examples\SQLite_NETCore2_0
PM> add-migration InitialCreate
PM> Update-Database
if add-migration xyz throws and error, don't take the error msg at face value. try again with add-migration xyz -verbose
new sqlite .db file created: Copy always/Copy if newer
or copy .db file to destination
relative:
optionsBuilder.UseSqlite("Data Source=blogging.db");
absolute (use fwd slashes):
optionsBuilder.UseSqlite("Data Source=C:/foo/bar/blogging.db");
REFERENCE ARTICLES
------------------
https://docs.microsoft.com/en-us/ef/core/get-started/netcore/new-db-sqlite
https://carlos.mendible.com/2016/07/11/step-by-step-dotnet-core-and-entity-framework-core/
https://www.benday.com/2017/12/19/ef-core-2-0-migrations-without-hard-coded-connection-strings/
+74 -42
View File
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AudibleApiDTOs; using AudibleApi.Common;
using AudibleUtilities;
using DataLayer; using DataLayer;
using InternalUtilities;
namespace DtoImporterService namespace DtoImporterService
{ {
@@ -19,13 +19,13 @@ namespace DtoImporterService
new ContributorImporter(DbContext).Import(importItems); new ContributorImporter(DbContext).Import(importItems);
new SeriesImporter(DbContext).Import(importItems); new SeriesImporter(DbContext).Import(importItems);
new CategoryImporter(DbContext).Import(importItems); new CategoryImporter(DbContext).Import(importItems);
// get distinct // get distinct
var productIds = importItems.Select(i => i.DtoItem.ProductId).ToList(); var productIds = importItems.Select(i => i.DtoItem.ProductId).Distinct().ToList();
// load db existing => .Local // load db existing => .Local
loadLocal_books(productIds); loadLocal_books(productIds);
// upsert // upsert
var qtyNew = upsertBooks(importItems); var qtyNew = upsertBooks(importItems);
return qtyNew; return qtyNew;
@@ -33,12 +33,30 @@ namespace DtoImporterService
private void loadLocal_books(List<string> productIds) private void loadLocal_books(List<string> productIds)
{ {
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId); // if this context has already loaded books, don't need to reload them. vestige from when context was long-lived. in practice, we now typically use a fresh context. this is quick though so no harm in leaving it.
var localProductIds = DbContext.Books.Local.Select(b => b.AudibleProductId).ToList();
var remainingProductIds = productIds var remainingProductIds = productIds
.Distinct()
.Except(localProductIds) .Except(localProductIds)
.ToList(); .ToList();
#region // explanation of DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
/*
articles suggest loading to Local with
context.Books.Load();
we want Books and associated fields
context.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
this is emulating Load() but with also getting associated fields
from: Microsoft.EntityFrameworkCore.EntityFrameworkQueryableExtensions
// Summary:
// Enumerates the query. When using Entity Framework, this causes the results of
// the query to be loaded into the associated context. This is equivalent to calling
// ToList and then throwing away the list (without the overhead of actually creating
// the list).
public static void Load<TSource>([NotNullAttribute] this IQueryable<TSource> source);
*/
#endregion
// GetBooks() eager loads Series, category, et al // GetBooks() eager loads Series, category, et al
if (remainingProductIds.Any()) if (remainingProductIds.Any())
DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList(); DbContext.Books.GetBooks(b => remainingProductIds.Contains(b.AudibleProductId)).ToList();
@@ -50,7 +68,7 @@ namespace DtoImporterService
foreach (var item in importItems) foreach (var item in importItems)
{ {
var book = DbContext.Books.Local.SingleOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId); var book = DbContext.Books.Local.FirstOrDefault(p => p.AudibleProductId == item.DtoItem.ProductId);
if (book is null) if (book is null)
{ {
book = createNewBook(item); book = createNewBook(item);
@@ -67,6 +85,8 @@ namespace DtoImporterService
{ {
var item = importItem.DtoItem; var item = importItem.DtoItem;
var contentType = item.IsEpisodes ? DataLayer.ContentType.Episode : DataLayer.ContentType.Product;
// absence of authors is very rare, but possible // absence of authors is very rare, but possible
if (!item.Authors?.Any() ?? true) if (!item.Authors?.Any() ?? true)
item.Authors = new[] { new Person { Name = "", Asin = null } }; item.Authors = new[] { new Person { Name = "", Asin = null } };
@@ -74,7 +94,8 @@ namespace DtoImporterService
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
var authors = item var authors = item
.Authors .Authors
.Select(a => DbContext.Contributors.Local.Single(c => a.Name == c.Name)) // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
.Select(a => DbContext.Contributors.Local.FirstOrDefault(c => a.Name == c.Name))
.ToList(); .ToList();
var narrators var narrators
@@ -84,7 +105,8 @@ namespace DtoImporterService
// nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db // nested logic is required so order of names is retained. else, contributors may appear in the order they were inserted into the db
: item : item
.Narrators .Narrators
.Select(n => DbContext.Contributors.Local.Single(c => n.Name == c.Name)) // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
.Select(n => DbContext.Contributors.Local.FirstOrDefault(c => n.Name == c.Name))
.ToList(); .ToList();
// categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd // categories are laid out for a breadcrumb. category is 1st, subcategory is 2nd
@@ -98,30 +120,52 @@ namespace DtoImporterService
// 2+ // 2+
: item.Categories[1].CategoryId; : item.Categories[1].CategoryId;
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == lastCategory); // This should properly be SingleOrDefault() not FirstOrDefault(), but FirstOrDefault is defensive
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == lastCategory);
var book = DbContext.Books.Add(new Book( Book book;
new AudibleProductId(item.ProductId), try
item.TitleWithSubtitle, {
item.Description, book = DbContext.Books.Add(new Book(
item.LengthInMinutes, new AudibleProductId(item.ProductId),
authors, item.TitleWithSubtitle,
narrators, item.Description,
category, item.LengthInMinutes,
importItem.LocaleName) contentType,
).Entity; authors,
narrators,
category,
importItem.LocaleName)
).Entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding book. {@DebugInfo}", new {
item.ProductId,
item.TitleWithSubtitle,
item.Description,
item.LengthInMinutes,
contentType,
QtyAuthors = authors?.Count,
QtyNarrators = narrators?.Count,
Category = category?.Name,
importItem.LocaleName
});
throw;
}
var publisherName = item.Publisher; var publisherName = item.Publisher;
if (!string.IsNullOrWhiteSpace(publisherName)) if (!string.IsNullOrWhiteSpace(publisherName))
{ {
var publisher = DbContext.Contributors.Local.Single(c => publisherName == c.Name); // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
var publisher = DbContext.Contributors.Local.FirstOrDefault(c => publisherName == c.Name);
book.ReplacePublisher(publisher); book.ReplacePublisher(publisher);
} }
book.UpdateBookDetails(item.IsAbridged, item.DatePublished); book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
if (!string.IsNullOrWhiteSpace(item.SupplementUrl)) if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.SupplementUrl); book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
return book; return book;
} }
@@ -131,11 +175,10 @@ namespace DtoImporterService
var item = importItem.DtoItem; var item = importItem.DtoItem;
// set/update book-specific info which may have changed // set/update book-specific info which may have changed
book.PictureId = item.PictureId; if (item.PictureId is not null)
book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars); book.PictureId = item.PictureId;
// needed during v3 => v4 migration book.UpdateProductRating(item.Product_OverallStars, item.Product_PerformanceStars, item.Product_StoryStars);
book.UpdateLocale(importItem.LocaleName);
// important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import // important to update user-specific info. this will have changed if user has rated/reviewed the book since last library import
book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story); book.UserDefinedItem.UpdateRating(item.MyUserRating_Overall, item.MyUserRating_Performance, item.MyUserRating_Story);
@@ -146,19 +189,8 @@ namespace DtoImporterService
{ {
foreach (var seriesEntry in item.Series) foreach (var seriesEntry in item.Series)
{ {
var series = DbContext.Series.Local.Single(s => seriesEntry.SeriesId == s.AudibleSeriesId); var series = DbContext.Series.Local.FirstOrDefault(s => seriesEntry.SeriesId == s.AudibleSeriesId);
book.UpsertSeries(series, seriesEntry.Sequence);
var index = 0f;
try
{
index = seriesEntry.Index;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, $"Error parsing series index. Title: {item.Title}. ASIN: {item.Asin}. Series index: {seriesEntry.Sequence}");
}
book.UpsertSeries(series, index);
} }
} }
} }
+16 -6
View File
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AudibleApiDTOs; using AudibleApi.Common;
using AudibleUtilities;
using DataLayer; using DataLayer;
using InternalUtilities;
namespace DtoImporterService namespace DtoImporterService
{ {
@@ -35,7 +35,7 @@ namespace DtoImporterService
private void loadLocal_categories(List<string> categoryIds) private void loadLocal_categories(List<string> categoryIds)
{ {
var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId); var localIds = DbContext.Categories.Local.Select(c => c.AudibleCategoryId).ToList();
var remainingCategoryIds = categoryIds var remainingCategoryIds = categoryIds
.Distinct() .Distinct()
.Except(localIds) .Except(localIds)
@@ -66,12 +66,22 @@ namespace DtoImporterService
Category parentCategory = null; Category parentCategory = null;
if (i == 1) if (i == 1)
parentCategory = DbContext.Categories.Local.Single(c => c.AudibleCategoryId == pair[0].CategoryId); // should be "Single()" but user is getting a strange error
parentCategory = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == pair[0].CategoryId);
var category = DbContext.Categories.Local.SingleOrDefault(c => c.AudibleCategoryId == id); // should be "SingleOrDefault()" but user is getting a strange error
var category = DbContext.Categories.Local.FirstOrDefault(c => c.AudibleCategoryId == id);
if (category is null) if (category is null)
{ {
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity; try
{
category = DbContext.Categories.Add(new Category(new AudibleCategoryId(id), name)).Entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category. {@DebugInfo}", new { id, name });
throw;
}
qtyNew++; qtyNew++;
} }
+44 -20
View File
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AudibleApiDTOs; using AudibleApi.Common;
using AudibleUtilities;
using DataLayer; using DataLayer;
using InternalUtilities;
namespace DtoImporterService namespace DtoImporterService
{ {
@@ -47,57 +47,81 @@ namespace DtoImporterService
private void loadLocal_contributors(List<string> contributorNames) private void loadLocal_contributors(List<string> contributorNames)
{ {
// must include default/empty/missing
contributorNames.Add(Contributor.GetEmpty().Name);
//// BAD: very inefficient //// BAD: very inefficient
// var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name)); // var x = context.Contributors.Local.Where(c => !contribNames.Contains(c.Name));
// GOOD: Except() is efficient. Due to hashing, it's close to O(n) // GOOD: Except() is efficient. Due to hashing, it's close to O(n)
var localNames = DbContext.Contributors.Local.Select(c => c.Name); var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
var remainingContribNames = contributorNames var remainingContribNames = contributorNames
.Distinct() .Distinct()
.Except(localNames) .Except(localNames)
.ToList(); .ToList();
// load existing => local // load existing => local
// remember to include default/empty/missing
var emptyName = Contributor.GetEmpty().Name;
if (remainingContribNames.Any()) if (remainingContribNames.Any())
DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name) || c.Name == emptyName).ToList(); DbContext.Contributors.Where(c => remainingContribNames.Contains(c.Name)).ToList();
} }
// only use after loading contributors => local // only use after loading contributors => local
private int upsertPeople(List<Person> people) private int upsertPeople(List<Person> people)
{ {
var qtyNew = 0; var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
var newPeople = people
.Select(p => p.Name)
.Distinct()
.Except(localNames)
.ToList();
foreach (var p in people) var groupby = people.GroupBy(
p => p.Name,
p => p,
(key, g) => new { Name = key, People = g.ToList() }
);
foreach (var name in newPeople)
{ {
// Should be 'Single' not 'First'. A user had a duplicate get in somehow though so I'm now using 'First' defensively // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
var person = DbContext.Contributors.Local.FirstOrDefault(c => c.Name == p.Name); var p = groupby.FirstOrDefault(g => g.Name == name).People.First();
if (person == null)
try
{ {
person = DbContext.Contributors.Add(new Contributor(p.Name, p.Asin)).Entity; DbContext.Contributors.Add(new Contributor(p.Name, p.Asin));
qtyNew++; }
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding person. {@DebugInfo}", new { p?.Name, p?.Asin });
throw;
} }
} }
return qtyNew; return newPeople.Count;
} }
// only use after loading contributors => local // only use after loading contributors => local
private int upsertPublishers(List<string> publishers) private int upsertPublishers(List<string> publishers)
{ {
var qtyNew = 0; var localNames = DbContext.Contributors.Local.Select(c => c.Name).ToList();
var newPublishers = publishers
.Distinct()
.Except(localNames)
.ToList();
foreach (var publisherName in publishers) foreach (var pub in newPublishers)
{ {
if (DbContext.Contributors.Local.SingleOrDefault(c => c.Name == publisherName) == null) try
{ {
DbContext.Contributors.Add(new Contributor(publisherName)); DbContext.Contributors.Add(new Contributor(pub));
qtyNew++; }
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding publisher. {@DebugInfo}", new { pub });
throw;
} }
} }
return qtyNew; return newPublishers.Count;
} }
} }
} }
+2 -3
View File
@@ -1,13 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj" /> <ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
<ProjectReference Include="..\DataLayer\DataLayer.csproj" /> <ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
@@ -1,7 +1,7 @@
using System; using System;
using AudibleApiDTOs; using AudibleApi.Common;
namespace InternalUtilities namespace DtoImporterService
{ {
public class ImportItem public class ImportItem
{ {
+1 -1
View File
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AudibleUtilities;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using InternalUtilities;
namespace DtoImporterService namespace DtoImporterService
{ {
+74
View File
@@ -0,0 +1,74 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleUtilities;
using DataLayer;
namespace DtoImporterService
{
public class LibraryBookImporter : ItemsImporterBase
{
public LibraryBookImporter(LibationContext context) : base(context) { }
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
new BookImporter(DbContext).Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
var currentLibraryProductIds = DbContext.LibraryBooks.Select(l => l.Book.AudibleProductId).ToList();
var newItems = importItems
.Where(dto => !currentLibraryProductIds
.Contains(dto.DtoItem.ProductId))
.ToList();
// if 2 accounts try to import the same book in the same transaction: error since we're only tracking and pulling by asin.
// just use the first
var groupby = newItems.GroupBy(
i => i.DtoItem.ProductId,
i => i,
(key, g) => new { ProductId = key, ImportItems = g.ToList() }
)
.ToList();
foreach (var gb in groupby)
{
var newItem = gb.ImportItems.First();
var libraryBook = new LibraryBook(
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive
DbContext.Books.Local.FirstOrDefault(b => b.AudibleProductId == newItem.DtoItem.ProductId),
newItem.DtoItem.DateAdded,
newItem.AccountId);
try
{
DbContext.LibraryBooks.Add(libraryBook);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
}
}
var qtyNew = groupby.Count;
return qtyNew;
}
}
}
-62
View File
@@ -1,62 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AudibleApiDTOs;
using DataLayer;
using InternalUtilities;
namespace DtoImporterService
{
public class LibraryImporter : ItemsImporterBase
{
public LibraryImporter(LibationContext context) : base(context) { }
public override IEnumerable<Exception> Validate(IEnumerable<ImportItem> importItems) => new LibraryValidator().Validate(importItems.Select(i => i.DtoItem));
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
new BookImporter(DbContext).Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache. it'll be fixed in pre .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
var currentLibraryProductIds = DbContext.Library.Select(l => l.Book.AudibleProductId).ToList();
var newItems = importItems.Where(dto => !currentLibraryProductIds.Contains(dto.DtoItem.ProductId)).ToList();
foreach (var newItem in newItems)
{
var libraryBook = new LibraryBook(
DbContext.Books.Local.Single(b => b.AudibleProductId == newItem.DtoItem.ProductId),
newItem.DtoItem.DateAdded,
newItem.AccountId);
DbContext.Library.Add(libraryBook);
}
// needed for v3 => v4 upgrade
var toUpdate = DbContext.Library.Where(l => l.Account == null);
foreach (var u in toUpdate)
{
var item = importItems.FirstOrDefault(ii => ii.DtoItem.ProductId == u.Book.AudibleProductId);
if (item != null)
u.UpdateAccount(item.AccountId);
}
var qtyNew = newItems.Count;
return qtyNew;
}
}
}
+34
View File
@@ -0,0 +1,34 @@
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
namespace DtoImporterService
{
public record timeLogEntry(string msg, long totalElapsed, long delta);
public static class PerfLogger
{
private static Stopwatch sw = new Stopwatch();
private static List<timeLogEntry> __log { get; } = new List<timeLogEntry> { new("begin", 0, 0) };
public static void logTime(string s)
{
var totalElapsed = sw.ElapsedMilliseconds;
var prev = __log.Last().totalElapsed;
var delta = totalElapsed - prev;
__log.Add(new(s, totalElapsed, delta));
}
public static void logRestart()
{
__log.Clear();
__log.Add(new("begin", 0, 0));
sw.Restart();
}
public static void stop() => sw.Stop();
public static string logOutput =>
$"{nameof(timeLogEntry.msg)}\t{nameof(timeLogEntry.totalElapsed)}\t{nameof(timeLogEntry.delta)}\r\n"
+ __log.Select(t => $"{t.msg}\t{t.totalElapsed}\t{t.delta}").Aggregate((a, b) => $"{a}\r\n{b}");
}
}
+14 -6
View File
@@ -1,9 +1,9 @@
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Linq; using System.Linq;
using AudibleApiDTOs; using AudibleApi.Common;
using AudibleUtilities;
using DataLayer; using DataLayer;
using InternalUtilities;
namespace DtoImporterService namespace DtoImporterService
{ {
@@ -29,7 +29,7 @@ namespace DtoImporterService
return qtyNew; return qtyNew;
} }
private void loadLocal_series(List<AudibleApiDTOs.Series> series) private void loadLocal_series(List<AudibleApi.Common.Series> series)
{ {
var seriesIds = series.Select(s => s.SeriesId).ToList(); var seriesIds = series.Select(s => s.SeriesId).ToList();
var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList(); var localIds = DbContext.Series.Local.Select(s => s.AudibleSeriesId).ToList();
@@ -42,16 +42,24 @@ namespace DtoImporterService
DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList(); DbContext.Series.Where(s => remainingSeriesIds.Contains(s.AudibleSeriesId)).ToList();
} }
private int upsertSeries(List<AudibleApiDTOs.Series> requestedSeries) private int upsertSeries(List<AudibleApi.Common.Series> requestedSeries)
{ {
var qtyNew = 0; var qtyNew = 0;
foreach (var s in requestedSeries) foreach (var s in requestedSeries)
{ {
var series = DbContext.Series.Local.SingleOrDefault(c => c.AudibleSeriesId == s.SeriesId); var series = DbContext.Series.Local.FirstOrDefault(c => c.AudibleSeriesId == s.SeriesId);
if (series is null) if (series is null)
{ {
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity; try
{
series = DbContext.Series.Add(new DataLayer.Series(new AudibleSeriesId(s.SeriesId))).Entity;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding series. {@DebugInfo}", new { s?.SeriesId });
throw;
}
qtyNew++; qtyNew++;
} }
series.UpdateName(s.SeriesName); series.UpdateName(s.SeriesName);
+44
View File
@@ -0,0 +1,44 @@
using System;
namespace FileLiberator
{
public abstract class AudioDecodable : Processable
{
public event EventHandler<Action<byte[]>> RequestCoverArt;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageDiscovered;
public abstract void Cancel();
protected void OnRequestCoverArt(Action<byte[]> setCoverArtDel)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(RequestCoverArt) });
RequestCoverArt?.Invoke(this, setCoverArtDel);
}
protected void OnTitleDiscovered(string title)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(TitleDiscovered), Title = title });
TitleDiscovered?.Invoke(this, title);
}
protected void OnAuthorsDiscovered(string authors)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(AuthorsDiscovered), Authors = authors });
AuthorsDiscovered?.Invoke(this, authors);
}
protected void OnNarratorsDiscovered(string narrators)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(NarratorsDiscovered), Narrators = narrators });
NarratorsDiscovered?.Invoke(this, narrators);
}
protected void OnCoverImageDiscovered(byte[] coverImage)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(CoverImageDiscovered), CoverImageBytes = coverImage?.Length });
CoverImageDiscovered?.Invoke(this, coverImage);
}
}
}
+56
View File
@@ -0,0 +1,56 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using DataLayer;
using Dinah.Core;
using FileManager;
using LibationFileManager;
namespace FileLiberator
{
public static class AudioFileStorageExt
{
private class MultipartRenamer
{
private LibraryBook libraryBook { get; }
internal MultipartRenamer(LibraryBook libraryBook) => this.libraryBook = libraryBook;
internal string MultipartFilename(AaxDecrypter.MultiConvertFileProperties props)
=> Templates.ChapterFile.GetFilename(libraryBook.ToDto(), props);
}
public static Func<AaxDecrypter.MultiConvertFileProperties, string> CreateMultipartRenamerFunc(this AudioFileStorage _, LibraryBook libraryBook)
=> new MultipartRenamer(libraryBook).MultipartFilename;
/// <summary>
/// DownloadDecryptBook:
/// File path for where to move files into.
/// Path: directory nested inside of Books directory
/// File name: n/a
/// </summary>
public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBook libraryBook)
=> Templates.Folder.GetFilename(libraryBook.ToDto());
/// <summary>
/// DownloadDecryptBook:
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.DecryptInProgressDirectory, extension);
/// <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);
}
}
+26 -41
View File
@@ -1,64 +1,49 @@
using AAXClean; using System;
using System.IO;
using System.Threading.Tasks;
using AAXClean;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.ErrorHandling; using Dinah.Core.ErrorHandling;
using Dinah.Core.IO;
using Dinah.Core.Net.Http; using Dinah.Core.Net.Http;
using FileManager; using FileManager;
using System; using LibationFileManager;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace FileLiberator namespace FileLiberator
{ {
public class ConvertToMp3 : IAudioDecodable public class ConvertToMp3 : AudioDecodable
{ {
private Mp4File m4bBook; private Mp4File m4bBook;
public event EventHandler<TimeSpan> StreamingTimeRemaining; private long fileSize;
public event EventHandler<Action<byte[]>> RequestCoverArt; private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageDiscovered;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<LibraryBook> Completed;
private long fileSize; public override void Cancel() => m4bBook?.Cancel();
private string Mp3FileName(string m4bPath) => m4bPath is null ? string.Empty : PathLib.ReplaceExtension(m4bPath, ".mp3");
public void Cancel() => m4bBook?.Cancel(); public override bool Validate(LibraryBook libraryBook)
public bool Validate(LibraryBook libraryBook)
{ {
var path = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book); var path = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path)); return path?.ToLower()?.EndsWith(".m4b") == true && !File.Exists(Mp3FileName(path));
} }
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{ {
Begin?.Invoke(this, libraryBook); OnBegin(libraryBook);
StreamingBegin?.Invoke(this, $"Begin converting {libraryBook} to mp3"); OnStreamingBegin($"Begin converting {libraryBook} to mp3");
try try
{ {
var m4bPath = ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book); var m4bPath = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId);
m4bBook = new Mp4File(m4bPath, FileAccess.Read); m4bBook = new Mp4File(m4bPath, FileAccess.Read);
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate; m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
fileSize = m4bBook.InputStream.Length; fileSize = m4bBook.InputStream.Length;
TitleDiscovered?.Invoke(this, m4bBook.AppleTags.Title); OnTitleDiscovered(m4bBook.AppleTags.Title);
AuthorsDiscovered?.Invoke(this, m4bBook.AppleTags.FirstAuthor); OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
NarratorsDiscovered?.Invoke(this, m4bBook.AppleTags.Narrator); OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);
CoverImageDiscovered?.Invoke(this, m4bBook.AppleTags.Cover); OnCoverImageDiscovered(m4bBook.AppleTags.Cover);
using var mp3File = File.OpenWrite(Path.GetTempFileName()); using var mp3File = File.OpenWrite(Path.GetTempFileName());
@@ -66,9 +51,9 @@ namespace FileLiberator
m4bBook.InputStream.Close(); m4bBook.InputStream.Close();
mp3File.Close(); mp3File.Close();
var mp3Path = Mp3FileName(m4bPath); var proposedMp3Path = Mp3FileName(m4bPath);
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path);
FileExt.SafeMove(mp3File.Name, mp3Path); OnFileCreated(libraryBook, realMp3Path);
var statusHandler = new StatusHandler(); var statusHandler = new StatusHandler();
@@ -79,8 +64,8 @@ namespace FileLiberator
} }
finally finally
{ {
StreamingCompleted?.Invoke(this, $"Completed converting to mp3: {libraryBook.Book.Title}"); OnStreamingCompleted($"Completed converting to mp3: {libraryBook.Book.Title}");
Completed?.Invoke(this, libraryBook); OnCompleted(libraryBook);
} }
} }
@@ -91,11 +76,11 @@ namespace FileLiberator
double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed; double estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
if (double.IsNormal(estTimeRemaining)) if (double.IsNormal(estTimeRemaining))
StreamingTimeRemaining?.Invoke(this, TimeSpan.FromSeconds(estTimeRemaining)); OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds; double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
StreamingProgressChanged?.Invoke(this, OnStreamingProgressChanged(
new DownloadProgress new DownloadProgress
{ {
ProgressPercentage = progressPercent, ProgressPercentage = progressPercent,
+128 -151
View File
@@ -8,226 +8,203 @@ using AudibleApi;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.ErrorHandling; using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using FileManager; using FileManager;
using LibationFileManager;
namespace FileLiberator namespace FileLiberator
{ {
public class DownloadDecryptBook : IAudioDecodable public class DownloadDecryptBook : AudioDecodable
{ {
private AudiobookDownloadBase abDownloader;
private AaxcDownloadConverter aaxcDownloader; public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists;
public event EventHandler<TimeSpan> StreamingTimeRemaining; public override void Cancel() => abDownloader?.Cancel();
public event EventHandler<Action<byte[]>> RequestCoverArt;
public event EventHandler<string> TitleDiscovered;
public event EventHandler<string> AuthorsDiscovered;
public event EventHandler<string> NarratorsDiscovered;
public event EventHandler<byte[]> CoverImageDiscovered;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<LibraryBook> Begin;
public event EventHandler<string> StatusUpdate;
public event EventHandler<LibraryBook> Completed;
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{ {
Begin?.Invoke(this, 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 try
{ {
if (ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book)) if (libraryBook.Book.Audio_Exists)
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" }; return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
var outputAudioFilename = await aaxToM4bConverterDecryptAsync(AudibleFileStorage.DownloadsInProgress, AudibleFileStorage.DecryptInProgress, libraryBook); 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 // decrypt failed
if (outputAudioFilename is null) if (!success)
return new StatusHandler { "Decrypt failed" }; return new StatusHandler { "Decrypt failed" };
// moves files and returns dest dir // moves new files from temp dir to final dest
_ = moveFilesToBooksDir(libraryBook.Book, outputAudioFilename); var movedAudioFile = moveFilesToBooksDir(libraryBook, entries);
var finalAudioExists = ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book); // decrypt failed
if (!finalAudioExists) if (!movedAudioFile)
return new StatusHandler { "Cannot find final audio file after decryption" }; return new StatusHandler { "Cannot find final audio file after decryption" };
// only need to update if success. if failure, it will remain at 0 == NotLiberated libraryBook.Book.UserDefinedItem.BookStatus = LiberatedStatus.Liberated;
ApplicationServices.LibraryCommands.UpdateBook(libraryBook, LiberatedStatus.Liberated, outputAudioFilename);
return new StatusHandler(); return new StatusHandler();
} }
finally finally
{ {
Completed?.Invoke(this, libraryBook); OnCompleted(libraryBook);
} }
} }
private async Task<string> aaxToM4bConverterDecryptAsync(string cacheDir, string destinationDir, LibraryBook libraryBook) private async Task<bool> downloadAudiobookAsync(LibraryBook libraryBook)
{ {
StreamingBegin?.Invoke(this, $"Begin decrypting {libraryBook}"); OnStreamingBegin($"Begin decrypting {libraryBook}");
try try
{ {
validate(libraryBook); downloadValidation(libraryBook);
var api = await InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
var api = await libraryBook.GetApiAsync();
var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId); var contentLic = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId);
var aaxcDecryptDlLic = new DownloadLicense var audiobookDlLic = new DownloadLicense
( (
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
contentLic?.Voucher?.Key, contentLic?.Voucher?.Key,
contentLic?.Voucher?.Iv, contentLic?.Voucher?.Iv,
Resources.UserAgent Resources.USER_AGENT
); );
if (Configuration.Instance.AllowLibationFixup) //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.
var outputFormat =
contentLic.ContentMetadata.ContentReference.ContentFormat == "MPEG" ||
(Configuration.Instance.AllowLibationFixup && Configuration.Instance.DecryptToLossy) ?
OutputFormat.Mp3 : OutputFormat.M4b;
if (Configuration.Instance.AllowLibationFixup || outputFormat == OutputFormat.Mp3)
{ {
aaxcDecryptDlLic.ChapterInfo = new AAXClean.ChapterInfo(); audiobookDlLic.ChapterInfo = new AAXClean.ChapterInfo();
foreach (var chap in contentLic.ContentMetadata?.ChapterInfo?.Chapters) foreach (var chap in contentLic.ContentMetadata?.ChapterInfo?.Chapters)
aaxcDecryptDlLic.ChapterInfo.AddChapter(chap.Title, TimeSpan.FromMilliseconds(chap.LengthMs)); audiobookDlLic.ChapterInfo.AddChapter(chap.Title, TimeSpan.FromMilliseconds(chap.LengthMs));
} }
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(libraryBook, outputFormat.ToString().ToLower());
var format = Configuration.Instance.DecryptToLossy ? OutputFormat.Mp3 : OutputFormat.Mp4a; var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
var extension = format switch abDownloader
{ = contentLic.DrmType != AudibleApi.Common.DrmType.Adrm ? new UnencryptedAudiobookDownloader(outFileName, cacheDir, audiobookDlLic)
OutputFormat.Mp4a => "m4b", : Configuration.Instance.SplitFilesByChapter ? new AaxcDownloadMultiConverter(
OutputFormat.Mp3 => "mp3", outFileName, cacheDir, audiobookDlLic, outputFormat,
_ => throw new NotImplementedException(), AudibleFileStorage.Audio.CreateMultipartRenamerFunc(libraryBook)
}; )
: new AaxcDownloadSingleConverter(outFileName, cacheDir, audiobookDlLic, outputFormat);
var outFileName = Path.Combine(destinationDir, $"{PathLib.ToPathSafeString(libraryBook.Book.Title)} [{libraryBook.Book.AudibleProductId}].{extension}"); abDownloader.DecryptProgressUpdate += (_, progress) => OnStreamingProgressChanged(progress);
abDownloader.DecryptTimeRemaining += (_, remaining) => OnStreamingTimeRemaining(remaining);
abDownloader.RetrievedTitle += (_, title) => OnTitleDiscovered(title);
aaxcDownloader = new AaxcDownloadConverter(outFileName, cacheDir, aaxcDecryptDlLic, format) { AppName = "Libation" }; abDownloader.RetrievedAuthors += (_, authors) => OnAuthorsDiscovered(authors);
aaxcDownloader.DecryptProgressUpdate += (s, progress) => StreamingProgressChanged?.Invoke(this, progress); abDownloader.RetrievedNarrators += (_, narrators) => OnNarratorsDiscovered(narrators);
aaxcDownloader.DecryptTimeRemaining += (s, remaining) => StreamingTimeRemaining?.Invoke(this, remaining); abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
aaxcDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt; abDownloader.FileCreated += (_, path) => OnFileCreated(libraryBook, path);
aaxcDownloader.RetrievedTags += aaxcDownloader_RetrievedTags;
// REAL WORK DONE HERE // REAL WORK DONE HERE
var success = await Task.Run(() => aaxcDownloader.Run()); var success = await Task.Run(abDownloader.Run);
return success;
// decrypt failed
if (!success)
return null;
return outFileName;
} }
finally finally
{ {
StreamingCompleted?.Invoke(this, $"Completed downloading and decrypting {libraryBook.Book.Title}"); OnStreamingCompleted($"Completed downloading and decrypting {libraryBook.Book.Title}");
} }
} }
private static void downloadValidation(LibraryBook libraryBook)
private void AaxcDownloader_RetrievedCoverArt(object sender, byte[] e)
{ {
if (e is null && Configuration.Instance.AllowLibationFixup) string errorString(string field)
{ => $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
RequestCoverArt?.Invoke(this, aaxcDownloader.SetCoverArt);
}
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 sender, byte[] e)
{
if (e is not null) if (e is not null)
{ OnCoverImageDiscovered(e);
CoverImageDiscovered?.Invoke(this, e); else if (Configuration.Instance.AllowLibationFixup)
} OnRequestCoverArt(abDownloader.SetCoverArt);
} }
private void aaxcDownloader_RetrievedTags(object sender, AAXClean.AppleTags e) /// <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)
{ {
TitleDiscovered?.Invoke(this, e.TitleSansUnabridged); // create final directory. move each file into it
AuthorsDiscovered?.Invoke(this, e.FirstAuthor ?? "[unknown]"); var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
NarratorsDiscovered?.Invoke(this, e.Narrator ?? "[unknown]");
}
private static string moveFilesToBooksDir(Book product, string outputAudioFilename)
{
// create final directory. move each file into it. MOVE AUDIO FILE LAST
// new dir: safetitle_limit50char + " [" + productId + "]"
var destinationDir = AudibleFileStorage.Audio.GetDestDir(product.Title, product.AudibleProductId);
Directory.CreateDirectory(destinationDir); Directory.CreateDirectory(destinationDir);
var sortedFiles = getProductFilesSorted(product, outputAudioFilename); FilePathCache.CacheEntry getFirstAudio() => entries.FirstOrDefault(f => f.FileType == FileType.Audio);
var musicFileExt = Path.GetExtension(outputAudioFilename).Trim('.'); if (getFirstAudio() == default)
return false;
// audio filename: safetitle_limit50char + " [" + productId + "]." + audio_ext for (var i = 0; i < entries.Count; i++)
var audioFileName = FileUtility.GetValidFilename(destinationDir, product.Title, musicFileExt, product.AudibleProductId);
foreach (var f in sortedFiles)
{
var dest
= AudibleFileStorage.Audio.IsFileTypeMatch(f)
? audioFileName
// non-audio filename: safetitle_limit50char + " [" + productId + "][" + audio_ext +"]." + non_audio_ext
: FileUtility.GetValidFilename(destinationDir, product.Title, f.Extension, product.AudibleProductId, musicFileExt);
if (Path.GetExtension(dest).Trim('.').ToLower() == "cue")
Cue.UpdateFileName(f, audioFileName);
File.Move(f.FullName, dest);
}
AudibleFileStorage.Audio.Refresh();
return destinationDir;
}
private static List<FileInfo> getProductFilesSorted(Book product, string outputAudioFilename)
{
// files are: temp path\author\[asin].ext
var m4bDir = new FileInfo(outputAudioFilename).Directory;
var files = m4bDir
.EnumerateFiles()
.Where(f => f.Name.ContainsInsensitive(product.AudibleProductId))
.ToList();
// move audio files to the end of the collection so these files are moved last
var musicFiles = files.Where(f => AudibleFileStorage.Audio.IsFileTypeMatch(f));
var sortedFiles = files
.Except(musicFiles)
.Concat(musicFiles)
.ToList();
return sortedFiles;
}
private static void validate(LibraryBook libraryBook)
{
string errorString(string field)
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
string errorTitle()
{ {
var title var entry = entries[i];
= (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)) var realDest = FileUtility.SaferMoveToValidPath(entry.Path, Path.Combine(destinationDir, Path.GetFileName(entry.Path)));
throw new Exception(errorString("Account")); FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale)) // propogate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
throw new Exception(errorString("Locale")); entries[i] = entry with { Path = realDest };
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
Cue.UpdateFileName(cue.Path, getFirstAudio().Path);
AudibleFileStorage.Audio.Refresh();
return true;
} }
}
public bool Validate(LibraryBook libraryBook)
=> !ApplicationServices.TransitionalFileLocator.Audio_Exists(libraryBook.Book);
public void Cancel()
{
aaxcDownloader?.Cancel();
}
}
} }
+5 -9
View File
@@ -6,30 +6,26 @@ using Dinah.Core.Net.Http;
namespace FileLiberator namespace FileLiberator
{ {
// currently only used to download the .zip flies for upgrade // currently only used to download the .zip flies for upgrade
public class DownloadFile : IStreamable public class DownloadFile : Streamable
{ {
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath) public async Task<string> PerformDownloadFileAsync(string downloadUrl, string proposedDownloadFilePath)
{ {
var client = new HttpClient(); var client = new HttpClient();
var progress = new Progress<DownloadProgress>(); var progress = new Progress<DownloadProgress>();
progress.ProgressChanged += (_, e) => StreamingProgressChanged?.Invoke(this, e); progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
StreamingBegin?.Invoke(this, proposedDownloadFilePath); OnStreamingBegin(proposedDownloadFilePath);
try try
{ {
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress); var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated("Upgrade", actualDownloadedFilePath);
return actualDownloadedFilePath; return actualDownloadedFilePath;
} }
finally finally
{ {
StreamingCompleted?.Invoke(this, proposedDownloadFilePath); OnStreamingCompleted(proposedDownloadFilePath);
} }
} }
} }
+48 -29
View File
@@ -8,60 +8,79 @@ using DataLayer;
using Dinah.Core.ErrorHandling; using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http; using Dinah.Core.Net.Http;
using FileManager; using FileManager;
using LibationFileManager;
namespace FileLiberator namespace FileLiberator
{ {
public class DownloadPdf : DownloadableBase public class DownloadPdf : Processable
{ {
public override bool Validate(LibraryBook libraryBook) public override bool Validate(LibraryBook libraryBook)
=> !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook)) => !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook))
&& !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book); && !libraryBook.Book.PDF_Exists;
public override async Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook) public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{ {
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook); OnBegin(libraryBook);
await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(libraryBook);
var liberatedStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated; try
ApplicationServices.LibraryCommands.UpdatePdf(libraryBook, liberatedStatus); {
var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook);
var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath);
var result = verifyDownload(actualDownloadedFilePath);
return result; libraryBook.Book.UserDefinedItem.PdfStatus = result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated;
return result;
}
finally
{
OnCompleted(libraryBook);
}
} }
private static string getProposedDownloadFilePath(LibraryBook libraryBook) private static string getProposedDownloadFilePath(LibraryBook libraryBook)
{ {
var extension = Path.GetExtension(getdownloadUrl(libraryBook));
// if audio file exists, get it's dir. else return base Book dir // if audio file exists, get it's dir. else return base Book dir
var existingPath = Path.GetDirectoryName(ApplicationServices.TransitionalFileLocator.Audio_GetPath(libraryBook.Book)); var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId));
var file = getdownloadUrl(libraryBook); if (existingPath is not null)
return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, existingPath, extension);
if (existingPath != null) return AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, extension);
return Path.Combine(existingPath, Path.GetFileName(file));
var full = FileUtility.GetValidFilename(
AudibleFileStorage.PDF.StorageDirectory,
libraryBook.Book.Title,
Path.GetExtension(file),
libraryBook.Book.AudibleProductId);
return full;
} }
private static string getdownloadUrl(LibraryBook libraryBook) private static string getdownloadUrl(LibraryBook libraryBook)
=> libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url; => libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url;
private async Task downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath) private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath)
{ {
var api = await GetApiAsync(libraryBook); OnStreamingBegin(proposedDownloadFilePath);
var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var client = new HttpClient(); try
var actualDownloadedFilePath = await PerformDownloadAsync( {
proposedDownloadFilePath, var api = await libraryBook.GetApiAsync();
(p) => client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, p)); var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId);
var progress = new Progress<DownloadProgress>();
progress.ProgressChanged += (_, e) => OnStreamingProgressChanged(e);
var client = new HttpClient();
var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress);
OnFileCreated(libraryBook, actualDownloadedFilePath);
OnStatusUpdate(actualDownloadedFilePath);
return actualDownloadedFilePath;
}
finally
{
OnStreamingCompleted(proposedDownloadFilePath);
}
} }
private static StatusHandler verifyDownload(LibraryBook libraryBook) private static StatusHandler verifyDownload(string actualDownloadedFilePath)
=> !ApplicationServices.TransitionalFileLocator.PDF_Exists(libraryBook.Book) => !File.Exists(actualDownloadedFilePath)
? new StatusHandler { "Downloaded PDF cannot be found" } ? new StatusHandler { "Downloaded PDF cannot be found" }
: new StatusHandler(); : new StatusHandler();
} }
-66
View File
@@ -1,66 +0,0 @@
using System;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public abstract class DownloadableBase : IProcessable
{
public event EventHandler<LibraryBook> Begin;
public event EventHandler<LibraryBook> Completed;
public event EventHandler<string> StreamingBegin;
public event EventHandler<DownloadProgress> StreamingProgressChanged;
public event EventHandler<string> StreamingCompleted;
public event EventHandler<string> StatusUpdate;
public event EventHandler<TimeSpan> StreamingTimeRemaining;
protected void Invoke_StatusUpdate(string message) => StatusUpdate?.Invoke(this, message);
public abstract bool Validate(LibraryBook libraryBook);
public abstract Task<StatusHandler> ProcessItemAsync(LibraryBook libraryBook);
// do NOT use ConfigureAwait(false) on ProcessAsync()
// often calls events which prints to forms in the UI context
public async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
Begin?.Invoke(this, libraryBook);
try
{
return await ProcessItemAsync(libraryBook);
}
finally
{
Completed?.Invoke(this, libraryBook);
}
}
protected static Task<AudibleApi.Api> GetApiAsync(LibraryBook libraryBook)
=> InternalUtilities.AudibleApiActions.GetApiAsync(libraryBook.Account, libraryBook.Book.Locale);
protected async Task<string> PerformDownloadAsync(string proposedDownloadFilePath, Func<Progress<DownloadProgress>, Task<string>> func)
{
var progress = new Progress<DownloadProgress>();
progress.ProgressChanged += (_, e) => StreamingProgressChanged?.Invoke(this, e);
StreamingBegin?.Invoke(this, proposedDownloadFilePath);
try
{
var result = await func(progress);
StatusUpdate?.Invoke(this, result);
return result;
}
finally
{
StreamingCompleted?.Invoke(this, proposedDownloadFilePath);
}
}
}
}
+2 -5
View File
@@ -1,16 +1,13 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
<ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" /> <ProjectReference Include="..\AaxDecrypter\AaxDecrypter.csproj" />
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\DataLayer\DataLayer.csproj" /> <ProjectReference Include="..\DataLayer\DataLayer.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" /> <ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" />
<ProjectReference Include="..\InternalUtilities\InternalUtilities.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
-14
View File
@@ -1,14 +0,0 @@
using System;
namespace FileLiberator
{
public interface IAudioDecodable : IProcessable
{
event EventHandler<Action<byte[]>> RequestCoverArt;
event EventHandler<string> TitleDiscovered;
event EventHandler<string> AuthorsDiscovered;
event EventHandler<string> NarratorsDiscovered;
event EventHandler<byte[]> CoverImageDiscovered;
void Cancel();
}
}
-23
View File
@@ -1,23 +0,0 @@
using System;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core.ErrorHandling;
namespace FileLiberator
{
public interface IProcessable : IStreamable
{
event EventHandler<LibraryBook> Begin;
/// <summary>General string message to display. DON'T rely on this for success, failure, or control logic</summary>
event EventHandler<string> StatusUpdate;
event EventHandler<LibraryBook> Completed;
/// <returns>True == Valid</returns>
bool Validate(LibraryBook libraryBook);
/// <returns>True == success</returns>
Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
}
}
-51
View File
@@ -1,51 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
namespace FileLiberator
{
public static class IProcessableExt
{
//
// DO NOT USE ConfigureAwait(false) WITH ProcessAsync() unless ensuring ProcessAsync() implementation is cross-thread compatible
// ProcessAsync() often does a lot with forms in the UI context
//
// when used in foreach: stateful. deferred execution
public static IEnumerable<LibraryBook> GetValidLibraryBooks(this IProcessable processable)
=> DbContexts.GetContext()
.GetLibrary_Flat_NoTracking()
.Where(libraryBook => processable.Validate(libraryBook));
public static async Task<StatusHandler> ProcessSingleAsync(this IProcessable processable, LibraryBook libraryBook, bool validate)
{
if (validate && !processable.Validate(libraryBook))
return new StatusHandler { "Validation failed" };
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
{
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,
libraryBook.Book.Locale,
Account = libraryBook.Account?.ToMask() ?? "[empty]"
});
var status
= (await processable.ProcessAsync(libraryBook))
?? new StatusHandler { "Processable should never return a null status" };
return status;
}
public static async Task<StatusHandler> TryProcessAsync(this IProcessable processable, LibraryBook libraryBook)
=> processable.Validate(libraryBook)
? await processable.ProcessAsync(libraryBook)
: new StatusHandler();
}
}
-13
View File
@@ -1,13 +0,0 @@
using System;
using Dinah.Core.Net.Http;
namespace FileLiberator
{
public interface IStreamable
{
event EventHandler<string> StreamingBegin;
event EventHandler<DownloadProgress> StreamingProgressChanged;
event EventHandler<TimeSpan> StreamingTimeRemaining;
event EventHandler<string> StreamingCompleted;
}
}
+76
View File
@@ -0,0 +1,76 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
namespace FileLiberator
{
public abstract class Processable : Streamable
{
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;
public event EventHandler<LibraryBook> Completed;
/// <returns>True == Valid</returns>
public abstract bool Validate(LibraryBook libraryBook);
/// <returns>True == success</returns>
public abstract Task<StatusHandler> ProcessAsync(LibraryBook libraryBook);
// when used in foreach: stateful. deferred execution
public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library)
=> library.Where(libraryBook =>
Validate(libraryBook)
&& (libraryBook.Book.ContentType != ContentType.Episode || LibationFileManager.Configuration.Instance.DownloadEpisodes)
);
public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, bool validate)
{
if (validate && !Validate(libraryBook))
return new StatusHandler { "Validation failed" };
Serilog.Log.Logger.Information("Begin " + nameof(ProcessSingleAsync) + " {@DebugInfo}", new
{
libraryBook.Book.Title,
libraryBook.Book.AudibleProductId,
libraryBook.Book.Locale,
Account = libraryBook.Account?.ToMask() ?? "[empty]"
});
var status
= (await ProcessAsync(libraryBook))
?? new StatusHandler { "Processable should never return a null status" };
return status;
}
public async Task<StatusHandler> TryProcessAsync(LibraryBook libraryBook)
=> Validate(libraryBook)
? await ProcessAsync(libraryBook)
: new StatusHandler();
protected void OnBegin(LibraryBook libraryBook)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Begin), Book = libraryBook.LogFriendly() });
Begin?.Invoke(this, libraryBook);
}
protected void OnStatusUpdate(string statusUpdate)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(StatusUpdate), Status = statusUpdate });
StatusUpdate?.Invoke(this, statusUpdate);
}
protected void OnCompleted(LibraryBook libraryBook)
{
Serilog.Log.Logger.Debug("Event fired {@DebugInfo}", new { Name = nameof(Completed), Book = libraryBook.LogFriendly() });
Completed?.Invoke(this, libraryBook);
}
}
}
+45
View File
@@ -0,0 +1,45 @@
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)
{
StreamingProgressChanged?.Invoke(this, progress);
}
protected void OnStreamingTimeRemaining(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));
}
}
}
+43
View File
@@ -0,0 +1,43 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
namespace FileLiberator
{
public static class UtilityExtensions
{
public static (string id, string title, string locale, string account) LogFriendly(this LibraryBook libraryBook)
=> (
id: libraryBook.Book.AudibleProductId,
title: libraryBook.Book.Title,
locale: libraryBook.Book.Locale,
account: libraryBook.Account.ToMask()
);
public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook)
{
var apiExtended = await AudibleUtilities.ApiExtended.CreateAsync(libraryBook.Account, libraryBook.Book.Locale);
return apiExtended.Api;
}
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
{
Account = libraryBook.Account,
AudibleProductId = libraryBook.Book.AudibleProductId,
Title = libraryBook.Book.Title ?? "",
Locale = libraryBook.Book.Locale,
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
Narrators = libraryBook.Book.Narrators.Select(c => c.Name).ToList(),
SeriesName = libraryBook.Book.SeriesLink.FirstOrDefault()?.Series.Name,
SeriesNumber = libraryBook.Book.SeriesLink.FirstOrDefault()?.Order
};
}
}
+1
View File
@@ -0,0 +1 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")]
-172
View File
@@ -1,172 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
using Dinah.Core.Collections.Generic;
namespace FileManager
{
public enum FileType { Unknown, Audio, AAXC, PDF }
public abstract class AudibleFileStorage : Enumeration<AudibleFileStorage>
{
protected abstract string[] Extensions { get; }
public abstract string StorageDirectory { get; }
#region static
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static AudibleFileStorage AAXC { get; } = new AaxcFileStorage();
public static AudibleFileStorage PDF { get; } = new PdfFileStorage();
public static string DownloadsInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DownloadsInProgress")).FullName;
public static string DecryptInProgress => Directory.CreateDirectory(Path.Combine(Configuration.Instance.InProgress, "DecryptInProgress")).FullName;
public static string BooksDirectory
{
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
}
}
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
#endregion
#region instance
public FileType FileType => (FileType)Value;
private IEnumerable<string> extensions_noDots { get; }
private string extAggr { get; }
protected AudibleFileStorage(FileType fileType) : base((int)fileType, fileType.ToString())
{
extensions_noDots = Extensions.Select(ext => ext.Trim('.')).ToList();
extAggr = extensions_noDots.Aggregate((a, b) => $"{a}|{b}");
BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
}
public void Refresh()
{
BookDirectoryFiles.RefreshFiles();
}
/// <summary>
/// Example for full books:
/// Search recursively in _books directory. Full book exists if either are true
/// - a directory name has the product id and an audio file is immediately inside
/// - any audio filename contains the product id
/// </summary>
public bool Exists(string productId) => GetPath(productId) != null;
public string GetPath(string productId)
{
var cachedFile = FilePathCache.GetPath(productId, FileType);
if (cachedFile != null)
return cachedFile;
string storageDir = StorageDirectory;
string regexPattern = $@"{productId}.*?\.({extAggr})$";
string firstOrNull;
if (storageDir == BooksDirectory)
{
//If user changed the BooksDirectory, reinitialize.
if (storageDir != BookDirectoryFiles.RootDirectory)
{
lock (BookDirectoryFiles)
{
if (storageDir != BookDirectoryFiles.RootDirectory)
{
BookDirectoryFiles = new BackgroundFileSystem(storageDir, "*.*", SearchOption.AllDirectories);
}
}
}
firstOrNull = BookDirectoryFiles.FindFile(regexPattern, RegexOptions.IgnoreCase);
}
else
{
firstOrNull =
Directory
.EnumerateFiles(storageDir, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, RegexOptions.IgnoreCase));
}
if (firstOrNull is null)
return null;
FilePathCache.Upsert(productId, FileType, firstOrNull);
return firstOrNull;
}
public string GetDestDir(string title, string asin)
{
// to prevent the paths from getting too long, we don't need after the 1st ":" for the folder
var underscoreIndex = title.IndexOf(':');
var titleDir
= underscoreIndex < 4
? title
: title.Substring(0, underscoreIndex);
var finalDir = FileUtility.GetValidFilename(StorageDirectory, titleDir, null, asin);
return finalDir;
}
public bool IsFileTypeMatch(FileInfo fileInfo)
=> extensions_noDots.ContainsInsensative(fileInfo.Extension.Trim('.'));
#endregion
}
public class AudioFileStorage : AudibleFileStorage
{
public const string SKIP_FILE_EXT = "libhack";
protected override string[] Extensions { get; } = new[] { "m4b", "mp3", "aac", "mp4", "m4a", "ogg", "flac", SKIP_FILE_EXT };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public AudioFileStorage() : base(FileType.Audio) { }
public string CreateSkipFile(string title, string asin, string contents = null)
{
var destinationDir = GetDestDir(title, asin);
Directory.CreateDirectory(destinationDir);
var path = FileUtility.GetValidFilename(destinationDir, title, SKIP_FILE_EXT, asin);
File.WriteAllText(path, contents ?? string.Empty);
return path;
}
}
public class AaxcFileStorage : AudibleFileStorage
{
protected override string[] Extensions { get; } = new[] { "aaxc" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => DownloadsInProgress;
public AaxcFileStorage() : base(FileType.AAXC) { }
}
public class PdfFileStorage : AudibleFileStorage
{
protected override string[] Extensions { get; } = new[] { "pdf", "zip" };
// we always want to use the latest config value, therefore
// - DO use 'get' arrow "=>"
// - do NOT use assign "="
public override string StorageDirectory => BooksDirectory;
public PdfFileStorage() : base(FileType.PDF) { }
}
}
+19 -18
View File
@@ -3,13 +3,14 @@ using System.Collections.Concurrent;
using System.Collections.Generic; using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq; using System.Linq;
using System.Text;
using System.Text.RegularExpressions;
using System.Threading.Tasks; using System.Threading.Tasks;
namespace FileManager namespace FileManager
{ {
class BackgroundFileSystem /// <summary>
/// Tracks actual locations of files.
/// </summary>
public class BackgroundFileSystem
{ {
public string RootDirectory { get; private set; } public string RootDirectory { get; private set; }
public string SearchPattern { get; private set; } public string SearchPattern { get; private set; }
@@ -18,6 +19,8 @@ namespace FileManager
private FileSystemWatcher fileSystemWatcher { get; set; } private FileSystemWatcher fileSystemWatcher { get; set; }
private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; } private BlockingCollection<FileSystemEventArgs> directoryChangesEvents { get; set; }
private Task backgroundScanner { get; set; } private Task backgroundScanner { get; set; }
private object fsCacheLocker { get; } = new();
private List<string> fsCache { get; } = new(); private List<string> fsCache { get; } = new();
public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions) public BackgroundFileSystem(string rootDirectory, string searchPattern, SearchOption searchOptions)
@@ -29,17 +32,15 @@ namespace FileManager
Init(); Init();
} }
public string FindFile(string regexPattern, RegexOptions options) public string FindFile(System.Text.RegularExpressions.Regex regex)
{ {
lock (fsCache) lock (fsCacheLocker)
{ return fsCache.FirstOrDefault(s => regex.IsMatch(s));
return fsCache.FirstOrDefault(s => Regex.IsMatch(s, regexPattern, options));
}
} }
public void RefreshFiles() public void RefreshFiles()
{ {
lock (fsCache) lock (fsCacheLocker)
{ {
fsCache.Clear(); fsCache.Clear();
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption)); fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
@@ -50,17 +51,19 @@ namespace FileManager
{ {
Stop(); Stop();
lock (fsCache) lock (fsCacheLocker)
fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption)); fsCache.AddRange(Directory.EnumerateFiles(RootDirectory, SearchPattern, SearchOption));
directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>(); directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>();
fileSystemWatcher = new FileSystemWatcher(RootDirectory); fileSystemWatcher = new FileSystemWatcher(RootDirectory)
fileSystemWatcher.Created += FileSystemWatcher_Changed; {
IncludeSubdirectories = true,
EnableRaisingEvents = true
};
fileSystemWatcher.Created += FileSystemWatcher_Changed;
fileSystemWatcher.Deleted += FileSystemWatcher_Changed; fileSystemWatcher.Deleted += FileSystemWatcher_Changed;
fileSystemWatcher.Renamed += FileSystemWatcher_Changed; fileSystemWatcher.Renamed += FileSystemWatcher_Changed;
fileSystemWatcher.Error += FileSystemWatcher_Error; fileSystemWatcher.Error += FileSystemWatcher_Error;
fileSystemWatcher.IncludeSubdirectories = true;
fileSystemWatcher.EnableRaisingEvents = true;
backgroundScanner = new Task(BackgroundScanner); backgroundScanner = new Task(BackgroundScanner);
backgroundScanner.Start(); backgroundScanner.Start();
@@ -79,7 +82,7 @@ namespace FileManager
//Dispose of directoryChangesEvents after backgroundScanner exists. //Dispose of directoryChangesEvents after backgroundScanner exists.
directoryChangesEvents?.Dispose(); directoryChangesEvents?.Dispose();
lock (fsCache) lock (fsCacheLocker)
fsCache.Clear(); fsCache.Clear();
} }
@@ -99,7 +102,7 @@ namespace FileManager
{ {
while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1)) while (directoryChangesEvents.TryTake(out FileSystemEventArgs change, -1))
{ {
lock (fsCache) lock (fsCacheLocker)
UpdateLocalCache(change); UpdateLocalCache(change);
} }
} }
@@ -139,9 +142,7 @@ namespace FileManager
private void AddUniqueFiles(IEnumerable<string> newFiles) private void AddUniqueFiles(IEnumerable<string> newFiles)
{ {
foreach (var file in newFiles) foreach (var file in newFiles)
{
AddUniqueFile(file); AddUniqueFile(file);
}
} }
private void AddUniqueFile(string newFile) private void AddUniqueFile(string newFile)
{ {
+4 -9
View File
@@ -1,17 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Microsoft.Extensions.Configuration.Json" Version="5.0.0" /> <PackageReference Include="Dinah.Core" Version="4.0.3.1" />
<PackageReference Include="Octokit" Version="0.50.0" /> <PackageReference Include="Polly" Version="7.2.3" />
<PackageReference Include="Polly" Version="7.2.2" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\Dinah.Core\Dinah.Core\Dinah.Core.csproj" />
</ItemGroup> </ItemGroup>
</Project> </Project>
+64
View File
@@ -0,0 +1,64 @@
using System;
using System.Collections.Generic;
using System.Linq;
using Dinah.Core;
namespace FileManager
{
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
public class FileNamingTemplate
{
/// <summary>Proposed full file path. May contain optional html-styled template tags. Eg: &lt;name&gt;</summary>
public string Template { get; }
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
public FileNamingTemplate(string template) => Template = ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
/// <summary>Optional step 1: Replace html-styled template tags with parameters. Eg {"name", "Bill Gates"} => /&lt;name&gt;/ => /Bill Gates/</summary>
public Dictionary<string, object> ParameterReplacements { get; } = new Dictionary<string, object>();
/// <summary>Convenience method</summary>
public void AddParameterReplacement(string key, object value)
// using .Add() instead of "[key] = value" will make unintended overwriting throw exception
=> ParameterReplacements.Add(key, value);
/// <summary>If set, truncate each parameter replacement to this many characters. Default 50</summary>
public int? ParameterMaxSize { get; set; } = 50;
/// <summary>Optional step 2: Replace all illegal characters with this. Default=<see cref="string.Empty"/></summary>
public string IllegalCharacterReplacements { get; set; }
/// <summary>Generate a valid path for this file or directory</summary>
public string GetFilePath()
{
var filename = Template;
foreach (var r in ParameterReplacements)
filename = filename.Replace($"<{formatKey(r.Key)}>", formatValue(r.Value));
return FileUtility.GetValidFilename(filename, IllegalCharacterReplacements);
}
private static string formatKey(string key)
=> key
.Replace("<", "")
.Replace(">", "");
private string formatValue(object value)
{
if (value is null)
return "";
// Other illegal characters will be taken care of later. Must take care of slashes now so params can't introduce new folders.
// Esp important for file templates.
var val = value
.ToString()
.Replace($"{System.IO.Path.DirectorySeparatorChar}", IllegalCharacterReplacements)
.Replace($"{System.IO.Path.AltDirectorySeparatorChar}", IllegalCharacterReplacements);
return
ParameterMaxSize.HasValue && ParameterMaxSize.Value > 0
? val.Truncate(ParameterMaxSize.Value)
: val;
}
}
}
-94
View File
@@ -1,94 +0,0 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using Dinah.Core.Collections.Immutable;
using Newtonsoft.Json;
namespace FileManager
{
public static class FilePathCache
{
internal class CacheEntry
{
public string Id { get; set; }
public FileType FileType { get; set; }
public string Path { get; set; }
}
private static Cache<CacheEntry> cache { get; } = new Cache<CacheEntry>();
private static string jsonFile => Path.Combine(Configuration.Instance.LibationFiles, "FilePaths.json");
static FilePathCache()
{
// load json into memory. if file doesn't exist, nothing to do. save() will create if needed
if (File.Exists(jsonFile))
{
var list = JsonConvert.DeserializeObject<List<CacheEntry>>(File.ReadAllText(jsonFile));
cache = new Cache<CacheEntry>(list);
}
}
public static bool Exists(string id, FileType type) => GetPath(id, type) != null;
public static string GetPath(string id, FileType type)
{
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
if (entry == null)
return null;
if (!File.Exists(entry.Path))
{
remove(entry);
return null;
}
return entry.Path;
}
private static void remove(CacheEntry entry)
{
cache.Remove(entry);
save();
}
public static void Upsert(string id, FileType type, string path)
{
if (!File.Exists(path))
throw new FileNotFoundException("Cannot add path to cache. File not found");
var entry = cache.SingleOrDefault(i => i.Id == id && i.FileType == type);
if (entry is null)
cache.Add(new CacheEntry { Id = id, FileType = type, Path = path });
else
entry.Path = path;
save();
}
// cache is thread-safe and lock free. but file saving is not
private static object locker { get; } = new object();
private static void save()
{
// create json if not exists
static void resave() => File.WriteAllText(jsonFile, JsonConvert.SerializeObject(cache.ToList(), Formatting.Indented));
lock (locker)
{
try { resave(); }
catch (IOException)
{
try { resave(); }
catch (IOException ex)
{
Serilog.Log.Logger.Error(ex, "Error saving FilePaths.json");
throw;
}
}
}
}
}
}
+195 -27
View File
@@ -1,52 +1,220 @@
using System; using System;
using System.Collections.Generic;
using System.IO; using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
using Polly;
using Polly.Retry;
namespace FileManager namespace FileManager
{ {
public static class FileUtility public static class FileUtility
{ {
public static string GetValidFilename(string dirFullPath, string filename, string extension, params string[] metadataSuffixes) /// <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)
{ {
if (string.IsNullOrWhiteSpace(dirFullPath)) ArgumentValidator.EnsureGreaterThan(position, nameof(position), 0);
throw new ArgumentException($"{nameof(dirFullPath)} may not be null or whitespace", nameof(dirFullPath)); ArgumentValidator.EnsureGreaterThan(total, nameof(total), 0);
if (position > total)
throw new ArgumentException($"{position} may not be greater than {total}");
// file max length = 255. dir max len = 247 return position.ToString().PadLeft(total.ToString().Length, '0');
}
// sanitize private const int MAX_FILENAME_LENGTH = 255;
filename = GetAsciiTag(filename); private const int MAX_DIRECTORY_LENGTH = 247;
// manage length
if (filename.Length > 50)
filename = filename.Substring(0, 50) + "[...]";
// append id. it is 10 or 14 char in the common cases /// <summary>
if (metadataSuffixes != null && metadataSuffixes.Length > 0) /// Ensure valid file name path:
filename += " [" + string.Join("][", metadataSuffixes) + "]"; /// <br/>- remove invalid chars
/// <br/>- ensure uniqueness
/// <br/>- enforce max file length
/// </summary>
public static string GetValidFilename(string path, string illegalCharacterReplacements = "")
{
ArgumentValidator.EnsureNotNull(path, nameof(path));
// this method may also be used for directory names, so no guarantee of extension // remove invalid chars
if (!string.IsNullOrWhiteSpace(extension)) path = GetSafePath(path, illegalCharacterReplacements);
extension = '.' + extension.Trim('.');
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path);
dir = dir.Truncate(MAX_DIRECTORY_LENGTH);
var filename = Path.GetFileNameWithoutExtension(path);
var fileStem = Path.Combine(dir, filename);
var extension = Path.GetExtension(path);
var fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - extension.Length) + extension;
fullfilename = removeInvalidWhitespace(fullfilename);
// ensure uniqueness
var fullfilename = Path.Combine(dirFullPath, filename + extension);
var i = 0; var i = 0;
while (File.Exists(fullfilename)) while (File.Exists(fullfilename))
fullfilename = Path.Combine(dirFullPath, filename + $" ({++i})" + extension); {
var increm = $" ({++i})";
fullfilename = fileStem.Truncate(MAX_FILENAME_LENGTH - increm.Length - extension.Length) + increm + extension;
}
return fullfilename; return fullfilename;
} }
public static string GetAsciiTag(string property) // GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/'
/// <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 charaters which are invalid file name characters will be retained: '\\', '/'</summary>
public static string GetSafePath(string path, string illegalCharacterReplacements = "")
{ {
if (property == null) ArgumentValidator.EnsureNotNull(path, nameof(path));
return "";
// omit characters which are invalid. EXCEPTION: change colon to underscore path = replaceInvalidChars(path, illegalCharacterReplacements);
property = property.Replace(':', '_'); path = standardizeSlashes(path);
path = replaceColons(path, illegalCharacterReplacements);
path = removeDoubleSlashes(path);
// GetInvalidFileNameChars contains everything in GetInvalidPathChars plus ':', '*', '?', '\\', '/' return path;
foreach (var ch in Path.GetInvalidFileNameChars())
property = property.Replace(ch.ToString(), "");
return property;
} }
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 standardizeSlashes(string path)
=> path.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
private static string replaceColons(string path, string illegalCharacterReplacements)
{
// replace all colons except within the first 2 chars
var builder = new System.Text.StringBuilder();
for (var i = 0; i < path.Length; i++)
{
var c = path[i];
if (i >= 2 && c == ':')
builder.Append(illegalCharacterReplacements);
else
builder.Append(c);
}
return builder.ToString();
}
private static string removeDoubleSlashes(string path)
{
if (path.Length < 2)
return path;
// exception: don't try to condense the initial dbl bk slashes in a path. eg: \\192.168.0.1
var remainder = path[1..];
var dblSeparator = $"{Path.DirectorySeparatorChar}{Path.DirectorySeparatorChar}";
while (remainder.Contains(dblSeparator))
remainder = remainder.Replace(dblSeparator, $"{Path.DirectorySeparatorChar}");
return path[0] + remainder;
}
private static string removeInvalidWhitespace_pattern { get; } = $@"\s*\{Path.DirectorySeparatorChar}\s*";
private static Regex removeInvalidWhitespace_regex { get; } = new(removeInvalidWhitespace_pattern, RegexOptions.Compiled | RegexOptions.IgnorePatternWhitespace);
/// <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* => \
fullfilename = fullfilename.Trim();
fullfilename = removeInvalidWhitespace_regex.Replace(fullfilename, @"\");
fullfilename = removeDoubleSlashes(fullfilename);
return 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(string source, string destination)
{
destination = GetValidFilename(destination);
SaferMove(source, destination);
return destination;
}
private static int maxRetryAttempts { get; } = 3;
private static TimeSpan pauseBetweenFailures { get; } = TimeSpan.FromMilliseconds(100);
private static RetryPolicy retryPolicy { get; } =
Policy
.Handle<Exception>()
.WaitAndRetry(maxRetryAttempts, i => pauseBetweenFailures);
/// <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))
{
File.Delete(source);
Serilog.Log.Logger.Information("File successfully deleted", new { source });
}
}
catch (Exception e)
{
Serilog.Log.Logger.Error(e, "Failed to delete file", new { source });
throw;
}
});
/// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary>
public static void SaferMove(string source, string destination)
=> retryPolicy.Execute(() =>
{
try
{
if (File.Exists(source))
{
SaferDelete(destination);
Directory.CreateDirectory(Path.GetDirectoryName(destination));
File.Move(source, destination);
Serilog.Log.Logger.Information("File successfully moved", new { source, destination });
}
}
catch (Exception e)
{
Serilog.Log.Logger.Error(e, "Failed to move file", new { source, destination });
throw;
}
});
} }
} }
+33 -3
View File
@@ -30,8 +30,7 @@ namespace FileManager
if (IsReadOnly) if (IsReadOnly)
return; return;
File.WriteAllText(Filepath, "{}"); createNewFile();
System.Threading.Thread.Sleep(100);
} }
public string GetString(string propertyName) public string GetString(string propertyName)
@@ -51,7 +50,8 @@ namespace FileManager
{ {
var obj = GetObject(propertyName); var obj = GetObject(propertyName);
if (obj is null) return default; if (obj is null) return default;
if (obj is JToken jToken) return jToken.Value<T>(); if (obj is JValue jValue) return jValue.Value<T>();
if (obj is JObject jObject) return jObject.ToObject<T>();
return (T)obj; return (T)obj;
} }
@@ -204,9 +204,39 @@ namespace FileManager
private JObject readFile() private JObject readFile()
{ {
if (!File.Exists(Filepath))
{
var msg = "Unrecoverable error. Settings file cannot be found";
var ex = new FileNotFoundException(msg, Filepath);
Serilog.Log.Logger.Error(ex, msg);
throw ex;
}
var settingsJsonContents = File.ReadAllText(Filepath); var settingsJsonContents = File.ReadAllText(Filepath);
if (string.IsNullOrWhiteSpace(settingsJsonContents))
{
createNewFile();
settingsJsonContents = File.ReadAllText(Filepath);
}
var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents); var jObject = JsonConvert.DeserializeObject<JObject>(settingsJsonContents);
if (jObject is null)
{
var msg = "Unrecoverable error. Unable to read settings from Settings file";
var ex = new NullReferenceException(msg);
Serilog.Log.Logger.Error(ex, msg);
throw ex;
}
return jObject; return jObject;
} }
private void createNewFile()
{
File.WriteAllText(Filepath, "{}");
System.Threading.Thread.Sleep(100);
}
} }
} }
+1
View File
@@ -0,0 +1 @@
[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")]
+1 -1
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net5.0</TargetFramework> <TargetFramework>net6.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
</Project> </Project>
-104
View File
@@ -1,104 +0,0 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApiDTOs;
using Dinah.Core;
using Polly;
using Polly.Retry;
namespace InternalUtilities
{
public static class AudibleApiActions
{
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public static Task<Api> GetApiAsync(string username, string localeName, ILoginCallback loginCallback = null)
{
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
{
Username = username.ToMask(),
LocaleName = localeName,
});
return EzApiCreator.GetApiAsync(
Localization.Get(localeName),
AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName),
loginCallback);
}
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public static Task<Api> GetApiAsync(ILoginCallback loginCallback, Account account)
{
Serilog.Log.Logger.Information("GetApiAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
return EzApiCreator.GetApiAsync(
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath(),
loginCallback);
}
private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>()
// 2 retries == 3 total
.RetryAsync(2);
public static Task<List<Item>> GetLibraryValidatedAsync(Api api, LibraryOptions.ResponseGroupOptions responseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS)
{
// bug on audible's side. the 1st time after a long absence, a query to get library will return without titles or authors. a subsequent identical query will be successful. this is true whether or tokens are refreshed
// worse, this 1st dummy call doesn't seem to help:
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
return policy.ExecuteAsync(() => getItemsAsync(api, responseGroups));
}
private static async Task<List<Item>> getItemsAsync(Api api, LibraryOptions.ResponseGroupOptions responseGroups)
{
var items = await api.GetAllLibraryItemsAsync(responseGroups);
// remove episode parents
items.RemoveAll(i => i.IsEpisodes);
#region // episode handling. doesn't quite work
// // add individual/children episodes
// var childIds = items
// .Where(i => i.Episodes)
// .SelectMany(ep => ep.Relationships)
// .Where(r => r.RelationshipToProduct == AudibleApiDTOs.RelationshipToProduct.Child && r.RelationshipType == AudibleApiDTOs.RelationshipType.Episode)
// .Select(c => c.Asin)
// .ToList();
// foreach (var childId in childIds)
// {
// var bookResult = await api.GetLibraryBookAsync(childId, AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS);
// var bookItem = AudibleApiDTOs.LibraryDtoV10.FromJson(bookResult.ToString()).Item;
// items.Add(bookItem);
// }
#endregion
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
{
var exceptions = v.Validate(items);
if (exceptions != null && exceptions.Any())
throw new AggregateException(exceptions);
}
return items;
}
private static List<IValidator> getValidators()
{
var type = typeof(IValidator);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
}
}
}
@@ -1,12 +0,0 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net5.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj" />
<ProjectReference Include="..\FileManager\FileManager.csproj" />
</ItemGroup>
</Project>
+58 -142
View File
@@ -1,10 +1,11 @@
Microsoft Visual Studio Solution File, Format Version 12.00 Microsoft Visual Studio Solution File, Format Version 12.00
# Visual Studio Version 16 # Visual Studio Version 17
VisualStudioVersion = 16.0.28803.156 VisualStudioVersion = 17.0.31903.59
MinimumVisualStudioVersion = 10.0.40219.1 MinimumVisualStudioVersion = 10.0.40219.1
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Solution Items", "_Solution Items", "{03C8835F-936C-4AF7-87AE-FF92BDBE8B9B}"
ProjectSection(SolutionItems) = preProject ProjectSection(SolutionItems) = preProject
__README - COLLABORATORS.txt = __README - COLLABORATORS.txt
__TODO.txt = __TODO.txt __TODO.txt = __TODO.txt
_DB_NOTES.txt = _DB_NOTES.txt _DB_NOTES.txt = _DB_NOTES.txt
REFERENCE.txt = REFERENCE.txt REFERENCE.txt = REFERENCE.txt
@@ -22,71 +23,46 @@ Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "2 Utilities (domain ignoran
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AaxDecrypter", "AaxDecrypter\AaxDecrypter.csproj", "{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager", "FileManager\FileManager.csproj", "{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager", "LibationFileManager\LibationFileManager.csproj", "{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DataLayer", "DataLayer\DataLayer.csproj", "{59A10DF3-63EC-43F1-A3BF-4000CFA118D2}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator", "FileLiberator\FileLiberator.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator", "FileLiberator\FileLiberator.csproj", "{393B5B27-D15C-4F77-9457-FA14BA8F3C73}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities", "InternalUtilities\InternalUtilities.csproj", "{06882742-27A6-4347-97D9-56162CEC9C11}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleUtilities", "AudibleUtilities\AudibleUtilities.csproj", "{06882742-27A6-4347-97D9-56162CEC9C11}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities (db ignorant)", "3 Domain Internal Utilities (db ignorant)", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "3 Domain Internal Utilities (db ignorant)", "3 Domain Internal Utilities (db ignorant)", "{F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine", "LibationSearchEngine\LibationSearchEngine.csproj", "{2E1F5DB4-40CC-4804-A893-5DCE0193E598}"
EndProject EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Tests", "0 Tests", "{38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Demos and Examples", "0 Demos and Examples", "{F61184E7-2426-4A13-ACEF-5689928E2CE2}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Demos", "..\Dinah.Core\_Demos\Dinah.Core.Demos\Dinah.Core.Demos.csproj", "{9F1AA3DE-962F-469B-82B2-46F93491389B}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.Tests", "..\Dinah.Core\_Tests\Dinah.Core.Tests\Dinah.Core.Tests.csproj", "{E874D000-AD3A-4629-AC65-7219C2C7C1F0}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "TestCommon", "..\Dinah.Core\_Tests\TestCommon\TestCommon.csproj", "{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "GitAllRepos", "..\GitAllRepos\GitAllRepos\GitAllRepos.csproj", "{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi", "..\audible api\AudibleApi\AudibleApi\AudibleApi.csproj", "{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApi.Tests", "..\audible api\AudibleApi\_Tests\AudibleApi.Tests\AudibleApi.Tests.csproj", "{111420E2-D4F0-4068-B46A-C4B6DCC823DC}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationWinForms", "LibationWinForms\LibationWinForms.csproj", "{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}"
EndProject ProjectSection(ProjectDependencies) = postProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "WinFormsDesigner", "WinFormsDesigner\WinFormsDesigner.csproj", "{0807616A-A77A-4B08-A65A-1582B09E114B}" {428163C3-D558-4914-B570-A92069521877} = {428163C3-D558-4914-B570-A92069521877}
EndProject EndProjectSection
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core", "..\Dinah.Core\Dinah.Core\Dinah.Core.csproj", "{9E951521-2587-4FC6-AD26-FAA9179FB6C4}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore", "..\Dinah.Core\Dinah.EntityFrameworkCore\Dinah.EntityFrameworkCore.csproj", "{1255D9BA-CE6E-42E4-A253-6376540B9661}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2", "..\LuceneNet303r2\LuceneNet303r2\LuceneNet303r2.csproj", "{35803735-B669-4090-9681-CC7F7FABDC71}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LuceneNet303r2.Tests", "..\LuceneNet303r2\LuceneNet303r2.Tests\LuceneNet303r2.Tests.csproj", "{5A7681A5-60D9-480B-9AC7-63E0812A2548}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "DtoImporterService", "DtoImporterService\DtoImporterService.csproj", "{401865F5-1942-4713-B230-04544C0A97B0}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiDTOs", "..\audible api\AudibleApi\AudibleApiDTOs\AudibleApiDTOs.csproj", "{C03C5D65-3B7F-453B-972F-23950B7E0604}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiDTOs.Tests", "..\audible api\AudibleApi\_Tests\AudibleApiDTOs.Tests\AudibleApiDTOs.Tests.csproj", "{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleApiClientExample", "..\audible api\AudibleApi\_Demos\AudibleApiClientExample\AudibleApiClientExample.csproj", "{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "ApplicationServices", "ApplicationServices\ApplicationServices.csproj", "{B95650EA-25F0-449E-BA5D-99126BC5D730}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.Core.WindowsDesktop", "..\Dinah.Core\Dinah.Core.WindowsDesktop\Dinah.Core.WindowsDesktop.csproj", "{059CE32C-9AD6-45E9-A166-790DFFB0B730}" Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "_Tests", "_Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationLauncher", "LibationLauncher\LibationLauncher.csproj", "{F3B04A3A-20C8-4582-A54A-715AF6A5D859}"
EndProject
Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "0 Libation Tests", "0 Libation Tests", "{67E66E82-5532-4440-AFB3-9FB1DF9DEF53}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "InternalUtilities.Tests", "_Tests\InternalUtilities.Tests\InternalUtilities.Tests.csproj", "{8447C956-B03E-4F59-9DD4-877793B849D9}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationSearchEngine.Tests", "_Tests\LibationSearchEngine.Tests\LibationSearchEngine.Tests.csproj", "{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Dinah.EntityFrameworkCore.Tests", "..\Dinah.Core\_Tests\Dinah.EntityFrameworkCore.Tests\Dinah.EntityFrameworkCore.Tests.csproj", "{6F5131A0-09AE-4707-B82B-5E53CB74688E}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}"
EndProject EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AAXClean", "..\AAXClean\AAXClean.csproj", "{94BEB7CC-511D-45AB-9F09-09BE858EE486}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationCli", "LibationCli\LibationCli.csproj", "{428163C3-D558-4914-B570-A92069521877}"
EndProject EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Hoopla", "Hoopla\Hoopla.csproj", "{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}" Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AppScaffolding", "AppScaffolding\AppScaffolding.csproj", "{595E7C4D-506D-486D-98B7-5FDDF398D033}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager", "FileManager\FileManager.csproj", "{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "AudibleUtilities.Tests", "_Tests\AudibleUtilities.Tests\AudibleUtilities.Tests.csproj", "{788294BE-0D8E-40D4-9CEE-67896FBB52CE}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileLiberator.Tests", "_Tests\FileLiberator.Tests\FileLiberator.Tests.csproj", "{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "FileManager.Tests", "_Tests\FileManager.Tests\FileManager.Tests.csproj", "{F2E04270-4551-41C4-99FF-E7125BED708C}"
EndProject
Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "LibationFileManager.Tests", "_Tests\LibationFileManager.Tests\LibationFileManager.Tests.csproj", "{EB781571-8548-477E-82AD-FB9FAB548D2F}"
EndProject EndProject
Global Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution GlobalSection(SolutionConfigurationPlatforms) = preSolution
@@ -118,137 +94,77 @@ Global
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Debug|Any CPU.Build.0 = Debug|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.ActiveCfg = Release|Any CPU {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.ActiveCfg = Release|Any CPU
{2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = Release|Any CPU {2E1F5DB4-40CC-4804-A893-5DCE0193E598}.Release|Any CPU.Build.0 = Release|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9F1AA3DE-962F-469B-82B2-46F93491389B}.Release|Any CPU.Build.0 = Release|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E874D000-AD3A-4629-AC65-7219C2C7C1F0}.Release|Any CPU.Build.0 = Release|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994}.Release|Any CPU.Build.0 = Release|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4}.Release|Any CPU.Build.0 = Release|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Debug|Any CPU.Build.0 = Debug|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.ActiveCfg = Release|Any CPU
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60}.Release|Any CPU.Build.0 = Release|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Debug|Any CPU.Build.0 = Debug|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.ActiveCfg = Release|Any CPU
{111420E2-D4F0-4068-B46A-C4B6DCC823DC}.Release|Any CPU.Build.0 = Release|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = Debug|Any CPU {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.ActiveCfg = Release|Any CPU {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = Release|Any CPU {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6}.Release|Any CPU.Build.0 = Release|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{0807616A-A77A-4B08-A65A-1582B09E114B}.Release|Any CPU.Build.0 = Release|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Debug|Any CPU.Build.0 = Debug|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.ActiveCfg = Release|Any CPU
{9E951521-2587-4FC6-AD26-FAA9179FB6C4}.Release|Any CPU.Build.0 = Release|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Debug|Any CPU.Build.0 = Debug|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.ActiveCfg = Release|Any CPU
{1255D9BA-CE6E-42E4-A253-6376540B9661}.Release|Any CPU.Build.0 = Release|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Debug|Any CPU.Build.0 = Debug|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.ActiveCfg = Release|Any CPU
{35803735-B669-4090-9681-CC7F7FABDC71}.Release|Any CPU.Build.0 = Release|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5A7681A5-60D9-480B-9AC7-63E0812A2548}.Release|Any CPU.Build.0 = Release|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.Build.0 = Debug|Any CPU {401865F5-1942-4713-B230-04544C0A97B0}.Debug|Any CPU.Build.0 = Debug|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.ActiveCfg = Release|Any CPU {401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.ActiveCfg = Release|Any CPU
{401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.Build.0 = Release|Any CPU {401865F5-1942-4713-B230-04544C0A97B0}.Release|Any CPU.Build.0 = Release|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Debug|Any CPU.Build.0 = Debug|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Release|Any CPU.ActiveCfg = Release|Any CPU
{C03C5D65-3B7F-453B-972F-23950B7E0604}.Release|Any CPU.Build.0 = Release|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD}.Release|Any CPU.Build.0 = Release|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Debug|Any CPU.Build.0 = Debug|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Release|Any CPU.ActiveCfg = Release|Any CPU
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6}.Release|Any CPU.Build.0 = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU {B95650EA-25F0-449E-BA5D-99126BC5D730}.Debug|Any CPU.Build.0 = Debug|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU {B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.ActiveCfg = Release|Any CPU
{B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU {B95650EA-25F0-449E-BA5D-99126BC5D730}.Release|Any CPU.Build.0 = Release|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Debug|Any CPU.Build.0 = Debug|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Release|Any CPU.ActiveCfg = Release|Any CPU
{059CE32C-9AD6-45E9-A166-790DFFB0B730}.Release|Any CPU.Build.0 = Release|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3B04A3A-20C8-4582-A54A-715AF6A5D859}.Release|Any CPU.Build.0 = Release|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Debug|Any CPU.Build.0 = Debug|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.ActiveCfg = Release|Any CPU
{8447C956-B03E-4F59-9DD4-877793B849D9}.Release|Any CPU.Build.0 = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Debug|Any CPU.Build.0 = Debug|Any CPU {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.ActiveCfg = Release|Any CPU
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU {C5B21768-C7C9-4FCB-AC1E-187B223D5A98}.Release|Any CPU.Build.0 = Release|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{6F5131A0-09AE-4707-B82B-5E53CB74688E}.Release|Any CPU.Build.0 = Release|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Debug|Any CPU.Build.0 = Debug|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Release|Any CPU.ActiveCfg = Release|Any CPU
{94BEB7CC-511D-45AB-9F09-09BE858EE486}.Release|Any CPU.Build.0 = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU {D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Debug|Any CPU.Build.0 = 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.ActiveCfg = Release|Any CPU
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU {D8F56E5A-3E65-41A6-B7E7-C4515A264B1F}.Release|Any CPU.Build.0 = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Debug|Any CPU.Build.0 = Debug|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.ActiveCfg = Release|Any CPU
{428163C3-D558-4914-B570-A92069521877}.Release|Any CPU.Build.0 = Release|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Debug|Any CPU.Build.0 = Debug|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.ActiveCfg = Release|Any CPU
{595E7C4D-506D-486D-98B7-5FDDF398D033}.Release|Any CPU.Build.0 = Release|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Debug|Any CPU.Build.0 = Debug|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Release|Any CPU.ActiveCfg = Release|Any CPU
{E86014F9-E4B3-4CD4-A210-2B3DB571DD86}.Release|Any CPU.Build.0 = Release|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Debug|Any CPU.Build.0 = Debug|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Release|Any CPU.ActiveCfg = Release|Any CPU
{788294BE-0D8E-40D4-9CEE-67896FBB52CE}.Release|Any CPU.Build.0 = Release|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Debug|Any CPU.Build.0 = Debug|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Release|Any CPU.ActiveCfg = Release|Any CPU
{5B8FC827-BF58-4CB1-A59E-BDEB9C62A05E}.Release|Any CPU.Build.0 = Release|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F2E04270-4551-41C4-99FF-E7125BED708C}.Release|Any CPU.Build.0 = Release|Any CPU
{EB781571-8548-477E-82AD-FB9FAB548D2F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{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
EndGlobalSection EndGlobalSection
GlobalSection(SolutionProperties) = preSolution GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE HideSolutionNode = FALSE
EndGlobalSection EndGlobalSection
GlobalSection(NestedProjects) = preSolution GlobalSection(NestedProjects) = preSolution
{8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} {8BD8E012-F44F-4EE2-A234-D66C14D5FE4B} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} {1AE65B61-9C05-4C80-ABFF-48F16E22FDF1} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E} {59A10DF3-63EC-43F1-A3BF-4000CFA118D2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E}
{393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF} {393B5B27-D15C-4F77-9457-FA14BA8F3C73} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249} {06882742-27A6-4347-97D9-56162CEC9C11} = {F0CBB7A7-D3FB-41FF-8F47-CF3F6A592249}
{2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF} {2E1F5DB4-40CC-4804-A893-5DCE0193E598} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{9F1AA3DE-962F-469B-82B2-46F93491389B} = {F61184E7-2426-4A13-ACEF-5689928E2CE2}
{E874D000-AD3A-4629-AC65-7219C2C7C1F0} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{FF12ADA0-8975-4E67-B6EA-4AC82E0C8994} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{AD1FDDC9-8D2A-436A-8EED-91FD74E7C7B4} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{7EA01F9C-E579-4B01-A3B9-733B49DD0B60} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{111420E2-D4F0-4068-B46A-C4B6DCC823DC} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14} {635F00E1-AAD1-45F7-BEB7-D909AD33B9F6} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{0807616A-A77A-4B08-A65A-1582B09E114B} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{9E951521-2587-4FC6-AD26-FAA9179FB6C4} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{1255D9BA-CE6E-42E4-A253-6376540B9661} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{35803735-B669-4090-9681-CC7F7FABDC71} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{5A7681A5-60D9-480B-9AC7-63E0812A2548} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF} {401865F5-1942-4713-B230-04544C0A97B0} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{C03C5D65-3B7F-453B-972F-23950B7E0604} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{6069D7F6-BEA0-4917-AFD4-4EB680CB0EDD} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{282EEE16-F569-47E1-992F-C6DB8AEC7AA6} = {F61184E7-2426-4A13-ACEF-5689928E2CE2}
{B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF} {B95650EA-25F0-449E-BA5D-99126BC5D730} = {41CDCC73-9B81-49DD-9570-C54406E852AF}
{059CE32C-9AD6-45E9-A166-790DFFB0B730} = {43E3ACB3-E0BC-4370-8DBB-E3720C8C8FD1}
{F3B04A3A-20C8-4582-A54A-715AF6A5D859} = {8679CAC8-9164-4007-BDD2-F004810EDA14}
{8447C956-B03E-4F59-9DD4-877793B849D9} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} {C5B21768-C7C9-4FCB-AC1E-187B223D5A98} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{6F5131A0-09AE-4707-B82B-5E53CB74688E} = {38E6C6D9-963A-4C5B-89F4-F2F14885ADFD}
{94BEB7CC-511D-45AB-9F09-09BE858EE486} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05}
{D8F56E5A-3E65-41A6-B7E7-C4515A264B1F} = {7FBBB086-0807-4998-85BF-6D1A49C8AD05} {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}
{788294BE-0D8E-40D4-9CEE-67896FBB52CE} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53}
{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}
EndGlobalSection EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9} SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9}
+41
View File
@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<OutputType>Exe</OutputType>
<TargetFramework>net6.0</TargetFramework>
<PublishTrimmed>true</PublishTrimmed>
<PublishReadyToRun>true</PublishReadyToRun>
<RuntimeIdentifier>win-x64</RuntimeIdentifier>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
</PropertyGroup>
<!--
When LibationWinForms and LibationCli output to the same dir, LibationCli must build before LibationWinForms
VS > rt-clik solution > Project Build Order...
Dependencies [tab]
Projects: LibationWinForms
manually check LibationCli
-->
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Debug</OutputPath>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<OutputPath>..\LibationWinForms\bin\Release</OutputPath>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CommandLineParser" Version="2.8.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
<ProjectReference Include="..\AppScaffolding\AppScaffolding.csproj" />
<ProjectReference Include="..\FileLiberator\FileLiberator.csproj" />
</ItemGroup>
</Project>
+14
View File
@@ -0,0 +1,14 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
namespace LibationCli
{
[Verb("convert", HelpText = "Convert mp4 to mp3.")]
public class ConvertOptions : ProcessableOptionsBase
{
protected override Task ProcessAsync() => RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
}
}
+55
View File
@@ -0,0 +1,55 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AudibleUtilities;
using CommandLine;
namespace LibationCli
{
[Verb("export", HelpText = "Must include path and flag for export file type: --xlsx , --csv , --json]")]
public class ExportOptions : OptionsBase
{
[Option(shortName: 'p', longName: "path", Required = true, HelpText = "Path to save file to.")]
public string FilePath { get; set; }
#region explanation of mutually exclusive options
/*
giving these SetName values makes them mutually exclusive. they are in different sets. eg:
class Options
{
[Option("username", SetName = "auth")]
public string Username { get; set; }
[Option("password", SetName = "auth")]
public string Password { get; set; }
[Option("guestaccess", SetName = "guest")]
public bool GuestAccess { get; set; }
}
*/
#endregion
[Option(shortName: 'x', longName: "xlsx", SetName = "xlsx", Required = true)]
public bool xlsx { get; set; }
[Option(shortName: 'c', longName: "csv", SetName = "csv", Required = true)]
public bool csv { get; set; }
[Option(shortName: 'j', longName: "json", SetName = "json", Required = true)]
public bool json { get; set; }
protected override Task ProcessAsync()
{
if (xlsx)
LibraryExporter.ToXlsx(FilePath);
if (csv)
LibraryExporter.ToCsv(FilePath);
if (json)
LibraryExporter.ToJson(FilePath);
Console.WriteLine($"Library exported to: {FilePath}");
return Task.CompletedTask;
}
}
}
+37
View File
@@ -0,0 +1,37 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
using DataLayer;
using FileLiberator;
namespace LibationCli
{
[Verb("liberate", HelpText = "Liberate: book and pdf backups. Default: download and decrypt all un-liberated titles and download pdfs. "
+ "Optional: use 'pdf' flag to only download pdfs.")]
public class LiberateOptions : ProcessableOptionsBase
{
[Option(shortName: 'p', longName: "pdf", Required = false, Default = false, HelpText = "Flag to only download pdfs")]
public bool PdfOnly { get; set; }
protected override Task ProcessAsync()
=> PdfOnly
? RunAsync(CreateProcessable<DownloadPdf>())
: RunAsync(CreateBackupBook());
private static Processable CreateBackupBook()
{
var downloadPdf = CreateProcessable<DownloadPdf>();
//Chain pdf download on DownloadDecryptBook.Completed
async void onDownloadDecryptBookCompleted(object sender, LibraryBook e)
{
await downloadPdf.TryProcessAsync(e);
}
var downloadDecryptBook = CreateProcessable<DownloadDecryptBook>(onDownloadDecryptBookCompleted);
return downloadDecryptBook;
}
}
}
+77
View File
@@ -0,0 +1,77 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using AudibleUtilities;
using CommandLine;
namespace LibationCli
{
[Verb("scan", HelpText = "Scan library. Default: scan all accounts. Optional: use 'account' flag to specify a single account.")]
public class ScanOptions : OptionsBase
{
[Value(0, MetaName = "Accounts", HelpText = "Optional: nicknames of accounts to scan.", Required = false)]
public IEnumerable<string> AccountNicknames { get; set; }
protected override async Task ProcessAsync()
{
var accounts = getAccounts();
if (!accounts.Any())
{
Console.WriteLine("No accounts. Exiting.");
Environment.ExitCode = (int)ExitCode.RunTimeError;
return;
}
var _accounts = accounts.ToArray();
var intro
= (_accounts.Length == 1)
? "Scanning Audible library. This may take a few minutes."
: $"Scanning Audible library: {_accounts.Length} accounts. This may take a few minutes per account.";
Console.WriteLine(intro);
var (TotalBooksProcessed, NewBooksAdded) = await LibraryCommands.ImportAccountAsync((a) => ApiExtended.CreateAsync(a), _accounts);
Console.WriteLine("Scan complete.");
Console.WriteLine($"Total processed: {TotalBooksProcessed}\r\nNew: {NewBooksAdded}");
}
private Account[] getAccounts()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings.GetAll().ToArray();
if (!AccountNicknames.Any())
return accounts;
var found = accounts.Where(acct => AccountNicknames.Contains(acct.AccountName)).ToArray();
var notFound = AccountNicknames.Except(found.Select(f => f.AccountName)).ToArray();
// no accounts found. do not continue
if (!found.Any())
{
Console.WriteLine("Accounts not found:");
foreach (var nf in notFound)
Console.WriteLine($"- {nf}");
return found;
}
// some accounts not found. continue after message
if (notFound.Any())
{
Console.WriteLine("Accounts found:");
foreach (var f in found)
Console.WriteLine($"- {f}");
Console.WriteLine("Accounts not found:");
foreach (var nf in notFound)
Console.WriteLine($"- {nf}");
}
// else: all accounts area found. silently continue
return found;
}
}
}
+31
View File
@@ -0,0 +1,31 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
namespace LibationCli
{
public abstract class OptionsBase
{
public async Task Run()
{
try
{
await ProcessAsync();
}
catch (Exception ex)
{
Environment.ExitCode = (int)ExitCode.RunTimeError;
Console.WriteLine("ERROR");
Console.WriteLine("=====");
Console.WriteLine(ex.Message);
Console.WriteLine();
Console.WriteLine(ex.StackTrace);
}
}
protected abstract Task ProcessAsync();
}
}
@@ -0,0 +1,58 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using ApplicationServices;
using CommandLine;
using DataLayer;
using FileLiberator;
namespace LibationCli
{
// streamlined, non-Forms copy of ProcessorAutomationController
public abstract class ProcessableOptionsBase : OptionsBase
{
protected static TProcessable CreateProcessable<TProcessable>(EventHandler<LibraryBook> completedAction = null)
where TProcessable : Processable, new()
{
var strProc = new TProcessable();
strProc.Begin += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Begin: {e}");
strProc.Completed += (o, e) => Console.WriteLine($"{typeof(TProcessable).Name} Completed: {e}");
strProc.Completed += completedAction;
return strProc;
}
protected static async Task RunAsync(Processable Processable)
{
foreach (var libraryBook in Processable.GetValidLibraryBooks(DbContexts.GetLibrary_Flat_NoTracking()))
await ProcessOneAsync(Processable, libraryBook, false);
var done = "Done. All books have been processed";
Console.WriteLine(done);
Serilog.Log.Logger.Information(done);
}
private static async Task ProcessOneAsync(Processable Processable, LibraryBook libraryBook, bool validate)
{
try
{
var statusHandler = await Processable.ProcessSingleAsync(libraryBook, validate);
if (statusHandler.IsSuccess)
return;
foreach (var errorMessage in statusHandler.Errors)
Serilog.Log.Logger.Error(errorMessage);
}
catch (Exception ex)
{
var msg = "Error processing book. Skipping. This book will be tried again on next attempt. For options of skipping or marking as error, retry with main Libation app.";
Console.WriteLine(msg + ". See log for more details.");
Serilog.Log.Logger.Error(ex, $"{msg} {{@DebugInfo}}", new { Book = libraryBook.LogFriendly() });
}
}
}
}
+84
View File
@@ -0,0 +1,84 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using CommandLine;
using CommandLine.Text;
using Dinah.Core;
using Dinah.Core.Collections;
using Dinah.Core.Collections.Generic;
namespace LibationCli
{
public enum ExitCode
{
ProcessCompletedSuccessfully = 0,
NonRunNonError = 1,
ParseError = 2,
RunTimeError = 3
}
class Program
{
static async Task<int> Main(string[] args)
{
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
Setup.Initialize();
Setup.SubscribeToDatabaseEvents();
var types = Setup.LoadVerbs();
#if DEBUG
string input = null;
//input = " export --help";
//input = " scan rmcrackan";
//input = " liberate ";
// note: this hack will fail for quoted file paths with spaces because it will break on those spaces
if (!string.IsNullOrWhiteSpace(input))
args = input.Split(' ', StringSplitOptions.RemoveEmptyEntries);
var setBreakPointHere = args;
#endif
var result = Parser.Default.ParseArguments(args, types);
// if successfully parsed
// async: run parsed options
await result.WithParsedAsync<OptionsBase>(opt => opt.Run());
// if not successfully parsed
// sync: handle parse errors
result.WithNotParsed(errors => HandleErrors(result, errors));
return Environment.ExitCode;
}
private static void HandleErrors(ParserResult<object> result, IEnumerable<Error> errors)
{
var errorsList = errors.ToList();
if (errorsList.Any(e => e.Tag.In(ErrorType.HelpRequestedError, ErrorType.VersionRequestedError, ErrorType.HelpVerbRequestedError)))
{
Environment.ExitCode = (int)ExitCode.NonRunNonError;
return;
}
Environment.ExitCode = (int)ExitCode.ParseError;
if (errorsList.Any(e => e.Tag.In(ErrorType.NoVerbSelectedError)))
{
Console.WriteLine("No verb selected");
return;
}
var helpText = HelpText.AutoBuild(result,
h => HelpText.DefaultParsingErrorsHandler(result, h),
e => e);
Console.WriteLine(helpText);
}
}
}
+63
View File
@@ -0,0 +1,63 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
using System.Threading.Tasks;
using AppScaffolding;
using CommandLine;
using CommandLine.Text;
using Dinah.Core;
using Dinah.Core.Collections;
using Dinah.Core.Collections.Generic;
namespace LibationCli
{
public static class Setup
{
public static void Initialize()
{
//***********************************************//
// //
// do not use Configuration before this line //
// //
//***********************************************//
var config = LibationScaffolding.RunPreConfigMigrations();
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(config);
#if !DEBUG
checkForUpdate();
#endif
}
private static void checkForUpdate()
{
var (hasUpgrade, zipUrl, htmlUrl, zipName) = LibationScaffolding.GetLatestRelease();
if (!hasUpgrade)
return;
var origColor = Console.ForegroundColor;
try
{
Console.ForegroundColor = ConsoleColor.Red;
Console.WriteLine($"UPDATE AVAILABLE @ {zipUrl}");
}
finally
{
Console.ForegroundColor = origColor;
}
}
public static void SubscribeToDatabaseEvents()
{
DataLayer.UserDefinedItem.ItemChanged += (sender, e) => ApplicationServices.LibraryCommands.UpdateUserDefinedItem(((DataLayer.UserDefinedItem)sender).Book);
}
public static Type[] LoadVerbs() => Assembly.GetExecutingAssembly()
.GetTypes()
.Where(t => t.GetCustomAttribute<VerbAttribute>() is not null)
.ToArray();
}
}
+106
View File
@@ -0,0 +1,106 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Text.RegularExpressions;
using FileManager;
namespace LibationFileManager
{
public abstract class AudibleFileStorage
{
protected abstract string GetFilePathCustom(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;
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
{
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Path.Combine(Configuration.UserProfile, "Books");
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
}
}
#endregion
#region instance
private FileType FileType { get; }
private string regexTemplate { get; }
protected AudibleFileStorage(FileType fileType)
{
FileType = fileType;
var extAggr = FileTypes.GetExtensions(FileType).Aggregate((a, b) => $"{a}|{b}");
regexTemplate = $@"{{0}}.*?\.({extAggr})$";
}
protected string GetFilePath(string productId)
{
// primary lookup
var cachedFile = FilePathCache.GetFirstPath(productId, FileType);
if (cachedFile is not null && File.Exists(cachedFile))
return cachedFile;
// secondary lookup attempt
var firstOrNull = GetFilePathCustom(productId);
if (firstOrNull is not null)
FilePathCache.Insert(productId, firstOrNull);
return firstOrNull;
}
protected Regex GetBookSearchRegex(string productId)
{
var pattern = string.Format(regexTemplate, productId);
return new Regex(pattern, RegexOptions.IgnoreCase);
}
#endregion
}
internal class AaxcFileStorage : AudibleFileStorage
{
internal AaxcFileStorage() : base(FileType.AAXC) { }
protected override string GetFilePathCustom(string productId)
{
var regex = GetBookSearchRegex(productId);
return Directory
.EnumerateFiles(DownloadsInProgressDirectory, "*.*", SearchOption.AllDirectories)
.FirstOrDefault(s => regex.IsMatch(s));
}
public bool Exists(string productId) => GetFilePath(productId) != null;
}
public class AudioFileStorage : AudibleFileStorage
{
internal AudioFileStorage() : base(FileType.Audio)
=> BookDirectoryFiles ??= new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
private static BackgroundFileSystem BookDirectoryFiles { get; set; }
private static object bookDirectoryFilesLocker { get; } = new();
protected override string GetFilePathCustom(string productId)
{
// If user changed the BooksDirectory: reinitialize
lock (bookDirectoryFilesLocker)
if (BooksDirectory != BookDirectoryFiles.RootDirectory)
BookDirectoryFiles = new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
var regex = GetBookSearchRegex(productId);
return BookDirectoryFiles.FindFile(regex);
}
public void Refresh() => BookDirectoryFiles.RefreshFiles();
public string GetPath(string productId) => GetFilePath(productId);
}
}
@@ -5,13 +5,14 @@ using System.IO;
using System.Linq; using System.Linq;
using Dinah.Core; using Dinah.Core;
using Dinah.Core.Logging; using Dinah.Core.Logging;
using FileManager;
using Microsoft.Extensions.Configuration; using Microsoft.Extensions.Configuration;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Serilog; using Serilog;
using Serilog.Events; using Serilog.Events;
namespace FileManager namespace LibationFileManager
{ {
public class Configuration public class Configuration
{ {
@@ -46,13 +47,17 @@ namespace FileManager
private PersistentDictionary persistentDictionary; private PersistentDictionary persistentDictionary;
public T GetNonString<T>(string propertyName) => persistentDictionary.GetNonString<T>(propertyName);
public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName); public object GetObject(string propertyName) => persistentDictionary.GetObject(propertyName);
public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue); public void SetObject(string propertyName, object newValue) => persistentDictionary.SetNonString(propertyName, newValue);
/// <summary>WILL ONLY set if already present. WILL NOT create new</summary> /// <summary>WILL ONLY set if already present. WILL NOT create new</summary>
/// <returns>Value was changed</returns> public void SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false)
public bool SetWithJsonPath(string jsonPath, string propertyName, string newValue, bool suppressLogging = false) {
=> persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging); var settingWasChanged = persistentDictionary.SetWithJsonPath(jsonPath, propertyName, newValue, suppressLogging);
if (settingWasChanged)
configuration?.Reload();
}
public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json"); public string SettingsFilePath => Path.Combine(LibationFiles, "Settings.json");
@@ -67,39 +72,7 @@ namespace FileManager
return attribute?.Description; return attribute?.Description;
} }
public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName); public bool Exists(string propertyName) => persistentDictionary.Exists(propertyName);
#region MainForm: X, Y, Width, Height, MainFormIsMaximized
public int MainFormX
{
get => persistentDictionary.GetNonString<int>(nameof(MainFormX));
set => persistentDictionary.SetNonString(nameof(MainFormX), value);
}
public int MainFormY
{
get => persistentDictionary.GetNonString<int>(nameof(MainFormY));
set => persistentDictionary.SetNonString(nameof(MainFormY), value);
}
public int MainFormWidth
{
get => persistentDictionary.GetNonString<int>(nameof(MainFormWidth));
set => persistentDictionary.SetNonString(nameof(MainFormWidth), value);
}
public int MainFormHeight
{
get => persistentDictionary.GetNonString<int>(nameof(MainFormHeight));
set => persistentDictionary.SetNonString(nameof(MainFormHeight), value);
}
public bool MainFormIsMaximized
{
get => persistentDictionary.GetNonString<bool>(nameof(MainFormIsMaximized));
set => persistentDictionary.SetNonString(nameof(MainFormIsMaximized), value);
}
#endregion
[Description("Location for book storage. Includes destination of newly liberated books")] [Description("Location for book storage. Includes destination of newly liberated books")]
public string Books public string Books
@@ -116,7 +89,7 @@ namespace FileManager
set => persistentDictionary.SetString(nameof(InProgress), value); set => persistentDictionary.SetString(nameof(InProgress), value);
} }
[Description("Allow Libation for fix up audiobook metadata?")] [Description("Allow Libation to fix up audiobook metadata")]
public bool AllowLibationFixup public bool AllowLibationFixup
{ {
get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup)); get => persistentDictionary.GetNonString<bool>(nameof(AllowLibationFixup));
@@ -129,6 +102,89 @@ namespace FileManager
get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy)); get => persistentDictionary.GetNonString<bool>(nameof(DecryptToLossy));
set => persistentDictionary.SetNonString(nameof(DecryptToLossy), value); set => persistentDictionary.SetNonString(nameof(DecryptToLossy), 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);
}
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);
}
#region templates: custom file naming
[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);
}
[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);
}
[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
#endregion #endregion
@@ -190,13 +246,9 @@ namespace FileManager
#region logging #region logging
private IConfigurationRoot configuration; private IConfigurationRoot configuration;
public void ConfigureLogging() public void ConfigureLogging()
{ {
//// with code. also persists to Settings.json
//SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
//// hack which achieves the same, in memory only
//configuration["Serilog:WriteTo:1:Args:path"] = logPath;
configuration = new ConfigurationBuilder() configuration = new ConfigurationBuilder()
.AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true) .AddJsonFile(SettingsFilePath, optional: false, reloadOnChange: true)
.Build(); .Build();
@@ -210,23 +262,15 @@ namespace FileManager
{ {
get get
{ {
try var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
{ return Enum.TryParse<LogEventLevel>(logLevelStr, out var logLevelEnum) ? logLevelEnum : LogEventLevel.Information;
var logLevelStr = persistentDictionary.GetStringFromJsonPath("Serilog", "MinimumLevel");
var logLevelEnum = Enum<LogEventLevel>.Parse(logLevelStr);
return logLevelEnum;
}
catch
{
return LogEventLevel.Information;
}
} }
set set
{ {
var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString()); var valueWasChanged = persistentDictionary.SetWithJsonPath("Serilog", "MinimumLevel", value.ToString());
if (!valueWasChanged) if (!valueWasChanged)
{ {
Log.Logger.Information("LogLevel.set attempt. No change"); Log.Logger.Debug("LogLevel.set attempt. No change");
return; return;
} }
@@ -248,11 +292,10 @@ namespace FileManager
#region singleton stuff #region singleton stuff
public static Configuration Instance { get; } = new Configuration(); public static Configuration Instance { get; } = new Configuration();
private Configuration() { } private Configuration() { }
#endregion #endregion
#region LibationFiles #region LibationFiles
private static string APPSETTINGS_JSON { get; } = Path.Combine(Path.GetDirectoryName(System.Reflection.Assembly.GetEntryAssembly().Location), "appsettings.json");
private const string APPSETTINGS_JSON = "appsettings.json";
private const string LIBATION_FILES_KEY = "LibationFiles"; private const string LIBATION_FILES_KEY = "LibationFiles";
[Description("Location for storage of program-created files")] [Description("Location for storage of program-created files")]
@@ -269,12 +312,16 @@ namespace FileManager
// SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist // SECOND. before setting to json file with SetWithJsonPath, PersistentDictionary must exist
persistentDictionary = new PersistentDictionary(SettingsFilePath); persistentDictionary = new PersistentDictionary(SettingsFilePath);
// Config init in Program.ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run). // Config init in ensureSerilogConfig() only happens when serilog setting is first created (prob on 1st run).
// This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles // This Set() enforces current LibationFiles every time we restart Libation or redirect LibationFiles
var logPath = Path.Combine(LibationFiles, "Log.log"); var logPath = Path.Combine(LibationFiles, "Log.log");
bool settingWasChanged = SetWithJsonPath("Serilog.WriteTo[1].Args", "path", logPath, true);
if (settingWasChanged) // BAD: Serilog.WriteTo[1].Args
configuration?.Reload(); // "[1]" assumes ordinal position
// GOOD: Serilog.WriteTo[?(@.Name=='File')].Args
var jsonpath = "Serilog.WriteTo[?(@.Name=='File')].Args";
SetWithJsonPath(jsonpath, "path", logPath, true);
return libationFilesPathCache; return libationFilesPathCache;
} }
Loaded 100 of 205 files, more files were not shown because too many files have changed in this diff. Show more