Compare commits

..
323 Commits
Author SHA1 Message Date
Robert 4f44c26b57 incr ver 2025-12-23 09:52:25 -05:00
rmcrackan 03534773ab Merge pull request #1510 from rmcrackan/dependabot/github_actions/actions/configure-pages-5
Bump actions/configure-pages from 4 to 5
2025-12-23 09:39:59 -05:00
rmcrackan 37f223fb77 Merge pull request #1509 from rmcrackan/dependabot/github_actions/actions/upload-pages-artifact-4
Bump actions/upload-pages-artifact from 3 to 4
2025-12-23 09:31:01 -05:00
rmcrackan f0dc33a01e Merge pull request #1511 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-12-23 09:11:11 -05:00
dependabot[bot] 315d76e061 Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:06:01 +00:00
dependabot[bot] 6e78145adc Bump actions/configure-pages from 4 to 5
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/configure-pages
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:05:51 +00:00
dependabot[bot] 200a334f86 Bump actions/upload-pages-artifact from 3 to 4
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

---
updated-dependencies:
- dependency-name: actions/upload-pages-artifact
  dependency-version: '4'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:05:45 +00:00
rmcrackan 4dd4a1495a Update docker.md 2025-12-22 17:46:10 -05:00
rmcrackan b3ce0e0af0 Update docker.md
Add LIBATION_CONNECTION_STRING
2025-12-22 17:45:15 -05:00
Mbucari 1299d91d08 Restrict workflow runs to specific paths 2025-12-22 12:54:58 -07:00
rmcrackan ad3a767057 Merge pull request #1504 from Youssef1313/mtp
Migrate from VSTest to MTP
2025-12-22 14:45:19 -05:00
Michael Bucari-Tovo a59c73caf8 Add win-arm64 release identfier 2025-12-22 12:37:22 -07:00
rmcrackan 442a688b85 Update README.md 2025-12-22 10:28:21 -05:00
rmcrackan 0c85ea4d11 Merge pull request #1498 from radiorambo/new-documentation-website
add new documentation website
2025-12-22 09:50:45 -05:00
Youssef1313 03ed8e6b57 Migrate from VSTest to MTP 2025-12-21 15:03:52 +01:00
rmcrackan 3eca508a26 Merge pull request #1501 from Mbucari/master
Add support for decoding Windows Arm64 and AC-4 audio files
2025-12-19 08:21:26 -05:00
radiorambo 770adf33f3 add gitHub actions workflow for vitePress deployment to gitHub pages. 2025-12-19 14:09:21 +05:30
MBucari 1087ffb150 Add support for converting AC-4 files to mp3 2025-12-19 00:18:06 -07:00
radiorambo f620234e7d add docs overview in homepage and in nav bar links 2025-12-18 14:28:46 +05:30
radiorambo 2b6b5d082e fix nav link increase website width in tablet view 2025-12-18 14:27:17 +05:30
Michael Bucari-Tovo cbbc45c3c5 Add Windows arm64 build 2025-12-17 11:00:29 -07:00
rmcrackan 28de1a6cb6 Merge pull request #1500 from Mbucari/master
Update AAXClean
2025-12-17 06:45:13 -05:00
Michael Bucari-Tovo 1615c6ef77 Update AAXClean 2025-12-16 23:44:04 -07:00
radiorambo 6961bd72fa rename 'report issues' button to 'issues & requests' and simple installation routes 2025-12-16 13:22:56 +05:30
radiorambo 68846a90e5 fix dead links 2025-12-16 13:11:56 +05:30
radiorambo d60ec0702c update nav and homepage buttons 2025-12-16 13:07:55 +05:30
radiorambo 1c55c8533a improve docs 2025-12-16 13:06:44 +05:30
radiorambo 6fa69b603e rename files 2025-12-15 22:08:01 +05:30
radiorambo 3df8a97463 configure config for clean urls 2025-12-15 21:36:39 +05:30
rmcrackan 0bd7bd80b9 Merge pull request #1496 from rmcrackan/dependabot/github_actions/actions/download-artifact-7
Bump actions/download-artifact from 6 to 7
2025-12-15 09:17:03 -05:00
rmcrackan 13bb4238b4 Merge pull request #1497 from rmcrackan/dependabot/github_actions/actions/upload-artifact-6
Bump actions/upload-artifact from 5 to 6
2025-12-15 09:16:49 -05:00
dependabot[bot] d5021e4f74 Bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 14:06:46 +00:00
dependabot[bot] 5e1458cfb4 Bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '7'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 14:06:41 +00:00
radiorambo e1d4533887 use github syntax for admonitions in homepage 2025-12-15 18:27:37 +05:30
radiorambo c1bd1d983b make documentation development section in readme concise 2025-12-15 17:50:39 +05:30
radiorambo b567c38a98 update syntax, adjust heading levels, reformat tables, and apply minor text and capitalization fixes across documentation. 2025-12-15 17:45:34 +05:30
radiorambo 348ec22465 update package-lock.json 2025-12-15 17:43:46 +05:30
radiorambo 90bb4d9176 fix favicon and logo not visible 2025-12-15 16:05:02 +05:30
radiorambo 7944154ea6 add icons 2025-12-15 15:37:19 +05:30
radiorambo 01fc7f3fb9 rename documentation files for simple routing paths and reorganise for better navigation 2025-12-15 15:37:07 +05:30
radiorambo b70f973994 update .gitignore for vitepress 2025-12-15 15:34:37 +05:30
radiorambo 98d3f85579 install vitepress and configure 2025-12-15 15:28:05 +05:30
Robert bdae155af6 incr ver 2025-12-11 16:41:36 -05:00
rmcrackan c8b44193ac Merge pull request #1490 from Mbucari/master
Two bugfixes
2025-12-09 13:39:14 -05:00
Mbucari 9545b3a874 Invoke MessageBox on UI thread 2025-12-06 18:55:38 -07:00
Mbucari e932c9fab9 Merge branch 'rmcrackan:master' into master 2025-12-06 18:02:38 -07:00
Robert c8f4c1e751 Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-12-06 14:48:26 -05:00
Robert 0303db153f update audibleapi 2025-12-06 14:48:24 -05:00
Mbucari a7e9479eab Fix file utility modifying file extension using replacement character
The file extension should not be subject to renaming. #1483
2025-12-06 10:40:02 -07:00
Mbucari d339dbc906 Update macOS install instructions. 2025-12-05 21:06:02 -07:00
Robert 5fe6f931ad incr ver 2025-12-04 20:54:08 -05:00
rmcrackan ca9fe9fc32 Merge pull request #1479 from Mbucari/master
Two minor bug fixes
2025-12-04 20:52:19 -05:00
MBucari 986dbd678f Don't throw exceptions from failure to delete db-wal and db-shm files (#1478) 2025-12-03 22:09:35 -07:00
MBucari ea3716f48a Fix books dialog not saving or updating properly (#1477) 2025-12-03 22:03:14 -07:00
rmcrackan 426d5a87b4 Merge pull request #1476 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-6
Bump apple-actions/import-codesign-certs from 5 to 6
2025-12-03 09:48:46 -05:00
dependabot[bot] c893bbe52e Bump apple-actions/import-codesign-certs from 5 to 6
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 5 to 6.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v5...v6)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 14:06:28 +00:00
Robert ad5a9874af incr ver 2025-12-02 15:28:59 -05:00
rmcrackan 3b70c08439 Merge pull request #1471 from Mbucari/master
Classic dark mode, customizable downloads, bug fixes
2025-12-02 15:26:24 -05:00
Michael Bucari-Tovo a230605ed5 Improve GetCounts performance. 2025-12-02 12:43:12 -07:00
Michael Bucari-Tovo d48ce39773 Move LibraryCommands.GetCounts() to background thread. 2025-12-02 12:30:05 -07:00
Michael Bucari-Tovo 368e695214 Allow users to shoose whether Libation imports Audible Plus titles. 2025-12-02 12:28:16 -07:00
Michael Bucari-Tovo 9c3881c67d Fix LibationCli reusing content licenses (#1473) 2025-12-02 11:09:58 -07:00
MBucari 4c5fdf05f5 Add "Download split by chapters" context menu item (#1436)
All processables are now created with an instance of Configuration, and they use that instance's settings.

Added Configuration.CreateEphemeralCopy() to clone Configuration without persistence.
2025-12-01 23:23:47 -07:00
MBucari 4bd491f5b9 Make winforms book details and search syntax dialogs nonmodal
Match Chardonnay behavior
2025-12-01 21:24:30 -07:00
MBucari c34b1e752e Update dependencies 2025-12-01 20:40:34 -07:00
MBucari fa30c10435 Fix PDF validation error (#1470 ) 2025-12-01 20:39:52 -07:00
Michael Bucari-Tovo cdb91ae2ca Add dark mode to winforms
- Add dark theme icon variants
- Change all light theme icon fill colors to match Chardonnay

Also fixed #1460  by chaing the directory select control to DirectoryOrCustomSelectControl
2025-12-01 20:39:22 -07:00
Robert 7852067b81 incr ver 2025-11-29 23:22:42 -05:00
rmcrackan 3708515df9 Merge pull request #1467 from Mbucari/master
Fix MessageBox launch error on macOS
2025-11-29 23:21:14 -05:00
Mbucari 530aca4f4d Fix MessageBox launch error on macOS 2025-11-29 15:41:38 -07:00
Robert cf571148bc incr ver 2025-11-25 23:19:49 -05:00
rmcrackan 2c2a720ba9 Merge pull request #1458 from Mbucari/master
Bug fixes in the downloader
2025-11-25 22:47:48 -05:00
Michael Bucari-Tovo b577ef7187 Improve SetBeckupCounts
Change Avalonia's Task-based approach to WinForms' BackgroundWorker approach.
- Reduce number of calls to GetLibrary by adding the Library to the LibraryStats record.
2025-11-25 14:59:48 -07:00
Michael Bucari-Tovo ffbb3c3516 Make namespace name match assembly name 2025-11-25 13:34:12 -07:00
Michael Bucari-Tovo 2a6cf38677 Fix book details dialog not saving 2025-11-25 13:33:40 -07:00
Michael Bucari-Tovo d8104a4d7c Check is stream is disposed before reading position. 2025-11-25 12:34:20 -07:00
Michael Bucari-Tovo af85ea9219 Fix exception being throw in Dispose() 2025-11-25 12:24:07 -07:00
rmcrackan c30e149a36 Merge pull request #1456 from Mbucari/master
Fix database lock from -wal and -whm files
2025-11-24 22:57:12 -05:00
MBucari 050a4867b7 Fix database lock from -wal and -whm files
Delete LibationContext.db-shm and LibationContext.db-wal files as part of startup routine.
2025-11-24 20:45:55 -07:00
Robert 2bf6f7a4f2 incr ver 2025-11-24 15:46:01 -05:00
rmcrackan 788a768271 Merge pull request #1455 from Mbucari/master
Add liberate option `--license` to use license file.
2025-11-24 15:43:18 -05:00
Michael Bucari-Tovo 022a6e979d Add error handling for cookies 2025-11-24 12:01:10 -07:00
Michael Bucari-Tovo 9fc5a7d834 Add liberate option --license to use license file.
Added instructions for liberating using a license file.
2025-11-24 11:36:31 -07:00
Robert b72e5039b1 incr ver 2025-11-24 09:48:22 -05:00
rmcrackan e992b49da2 Merge pull request #1451 from Mbucari/master
Add more liberation logging
2025-11-24 09:14:23 -05:00
MBucari 74afbbf581 Add more liberation logging 2025-11-23 23:40:23 -07:00
rmcrackan d82ffe1467 Merge pull request #1444 from Mbucari/master
Use C# 14 `field` and extension members.
2025-11-23 22:44:57 -05:00
Mbucari 8a84a083d1 Fix removed field 2025-11-23 20:38:20 -07:00
Mbucari 04827f81da Tweak in-app web browser login
- Use private browsing mode
- Use the Android User-Agent
- Use initial signin cookies
2025-11-23 20:35:04 -07:00
Mbucari 805f42b1cc Add warnings for inaccessable InProgress directory (#1446) 2025-11-22 15:29:54 -07:00
Mbucari b9a1709284 Use release version of EntityFrameworkCore.PostgreSQL 2025-11-22 11:46:27 -07:00
Michael Bucari-Tovo b0a40e12b7 Create some extension members
Trying out .NET 10s extension members with some Book extension properties.
2025-11-21 12:31:50 -07:00
Michael Bucari-Tovo dfbc5ec9db Use the new field keyword where appropriate. 2025-11-21 11:50:07 -07:00
rmcrackan 649ef5f864 Merge pull request #1441 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-5
Bump apple-actions/import-codesign-certs from 3 to 5
2025-11-21 11:19:27 -05:00
rmcrackan 4345bf2ee2 Merge pull request #1442 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-11-21 11:19:09 -05:00
rmcrackan 441d430dea Merge pull request #1439 from Mbucari/master
Update to .NET 10
2025-11-21 09:15:00 -05:00
dependabot[bot] 85ee0bcddb Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:06:00 +00:00
dependabot[bot] 0f1fc0f11d Bump apple-actions/import-codesign-certs from 3 to 5
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 3 to 5.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v3...v5)

---
updated-dependencies:
- dependency-name: apple-actions/import-codesign-certs
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-11-21 14:05:53 +00:00
MBucari 75aa17df11 Fix winforms STAThread on main 2025-11-20 22:35:13 -07:00
MBucari 913019cdfd Revert hack workaround for DataGridView error 2025-11-20 22:19:43 -07:00
MBucari a55da5f187 Refactor DbContext access and disposal
- Remove instance queue. This is a database, after all, and is designed to be accessed and written to concurrently
- Reduce the number of calls to DbContexts.Create()
- Ensure that no LibationContext remains open across an await boundary. Multithread context access is the most likely culprit for past issues.
- Make all Update UserDefinedItem methods asynchronous.
2025-11-20 22:15:54 -07:00
MBucari f9ac0253fb Improve import performance on large batches 2025-11-20 21:37:13 -07:00
MBucari fde78f4167 Update to .NET 10
- Update all project runtime targets
- Update all dependencies
  - NOTE: Using Npgsql.EntityFrameworkCore.PostgreSQL RTM build from MyGet
- Delete unused pubxml files (they were made redundant by recent workflow changes)
- Replace Libation.sln with Libation.slnx
2025-11-20 15:56:47 -07:00
rmcrackan 22159b79d8 Merge pull request #1429 from Mbucari/master
Minor Bugfixes, new Workflows, and signed macOS bundles, refactor LibationFiles, improve libationCLI
2025-11-20 13:45:25 -05:00
MBucari 7fe170acdf Fix CLI help tex being squashed to a single column 2025-11-20 10:42:14 -07:00
MBucari c0898a288b Additional LibationCli documentation
Move CLI examples to the bottom of the page.
2025-11-20 09:55:35 -07:00
MBucari ce2b81036f Add license and settings overrides to LibationCli
- Add `LIBATION_FILES_DIR` environment variable to specify LibationFiles directory instead of appsettings.json
- OptionsBase supports overriding setting
  - Added `EphemeralSettings` which are loaded from Settings.json once and can be modified with the `--override` command parameter
- Added `get-setting` command
  - Prints (editable) settings and their values. Prints specified settings, or all settings if none specified
  - `--listEnumValues` option will list all names for a speficied enum-type settings. If no setting names are specified, prints all enum values for all enum settings.
  - Prints in a text-based table or bare with `-b` switch
- Added `get-license` command which requests a content license and prints it as a json to stdout
- Improved `liberate` command
  - Added `-force` option to force liberation without validation.
  - Added support to download with a license file supplied to stdin
  - Improve startup performance when downloading explicit ASIN(s)
  - Fix long-standing bug where cover art was not being downloading
2025-11-19 23:47:41 -07:00
Michael Bucari-Tovo 65d24ce223 Fix NRE in FileSystemTest when Books dir is invalid (#1435) 2025-11-17 10:54:25 -07:00
MBucari e8c911e603 Improve management and validation of Libation settings
- Move all settings file logic into new LibationFiles class
  - Configuration.LibationFiles is a singleton instance of the LibationFiles class.
  - A LibationFiles instance is bound to a single appsettings.json path. All updates to LibationFiles location are updated in that appsettings.json file
- Unify initial setup and settings validation process
  - Add LibationSetup which handles all startup validation of settings files and prompts users for setup if needed
  - Added a new LibationUiBase.Tests test project with tests for various
2025-11-17 10:49:23 -07:00
Michael Bucari-Tovo 59f66ff480 Fix subdirectory create failing on root drive (#1432)
This appears to be a bug in .NET. Someone started to fix it in a PR, but it went stale and was auto-closed.

https://github.com/dotnet/runtime/pull/117519
2025-11-14 13:44:46 -07:00
Michael Bucari-Tovo e05dcd6f54 Don't overwrite LibationFiles setting in appsettings.json 2025-11-14 13:35:36 -07:00
Michael Bucari-Tovo d1ce9d5a83 Update Mac Workflow
- Add new repo variables
  - `SIGN_MAC_APP_ON_VALIDATE` will force sign/notarize on the validate workflow (normally only done for releases)
  - `WAIT_FOR_NOTARIZE` Causes the build-mac workflow to wait for apple to notarize the bundle so that it can be stapled. This is usually fast (1-2 mis), but can be very long and may cause workflow runners to time out.
2025-11-14 11:19:21 -07:00
Mbucari 2213f5c86a Change msaOS updater to get DMGs
This will break _Automatic_ updates for existing mac users (although I'm not sure it worked all that well to begin with. However, the update notification dialog has had a link to download the bundle manually for a long time now. Old users will still be notified of the new release and be given a direct link to download it.
2025-11-13 23:15:29 -07:00
Mbucari d6b232f342 Update Logging and Error Handling
- Add Configuration.LoggingEnabled property which gets set as soon as Serilog is configured
- Add error handling to InteropFactory
2025-11-13 23:12:57 -07:00
Mbucari f29c19beb8 Update .gitignore 2025-11-13 23:04:33 -07:00
Mbucari 9f6d08fc1f Update Workflows
- Simplify workflows build commands
- Don't build ReadyToRun on validate
- Move get-version into it's own job in build.yml
- Split  macOS into it's own reusable workflow
  - Add app bundle code signing
  - Add notarization
2025-11-13 22:59:26 -07:00
MBucari 717dfcd923 Fix trying to cancel disposed cancellation token source 2025-11-11 14:06:54 -07:00
MBucari a3d181b2ec Fix Primary Screen NRE (#1420) 2025-11-11 03:21:20 -07:00
Mbucari d16eeea56b Improve detection of NativeWebDialog crash 2025-11-10 22:58:05 -07:00
Mbucari 5d2513ec33 Improve button display size uniformity. 2025-11-10 22:21:51 -07:00
Mbucari e5043dcf40 Move button styling to App.xaml 2025-11-10 21:42:34 -07:00
Mbucari c61bfb4134 Fix EditQuickFilters not displaying with no filters 2025-11-10 21:42:06 -07:00
Robert d47a2595b9 incr ver 2025-11-10 22:19:24 -05:00
rmcrackan 55e74db4fb Merge pull request #1419 from Mbucari/master
Bug Fixes and UI Improvement
2025-11-10 22:17:12 -05:00
MBucari 0a171222bc Update Dependency 2025-11-10 19:29:30 -07:00
MBucari c2093157ca Add dolby atmos logo for spatial audiobooks 2025-11-10 19:28:18 -07:00
MBucari 8e073800cd Fix BookDetailsDialog crash when changing error status 2025-11-10 18:25:59 -07:00
MBucari 1daf07b882 Improve logging 2025-11-10 17:58:48 -07:00
MBucari 27a23a16d6 Update AAXClean 2025-11-10 17:34:17 -07:00
Michael Bucari-Tovo c878b9fec0 Detect webview crash and disable webview login 2025-11-10 13:14:23 -07:00
rmcrackan 7a01f075ac Merge pull request #1415 from Mbucari/master
Fix minor UI bugs
2025-11-08 18:04:13 -05:00
Michael Bucari-Tovo 23d391485d Update AboutDialog and add recent contributors 2025-11-07 10:35:33 -07:00
Michael Bucari-Tovo 46be532740 Improve SearchSyntaxDialog
- Double-clicking a tag will paste the tage into the search bar
- SearchSyntaxDialog now modeless
2025-11-06 23:53:57 -07:00
Michael Bucari-Tovo e2fd88d075 Improve ScanAccountsDialog usability 2025-11-06 23:24:17 -07:00
Michael Bucari-Tovo bb0dea3fa9 Improve EditReplacementChars dialog usability 2025-11-06 22:49:09 -07:00
Michael Bucari-Tovo def0b1f611 Prevent crash if watched RootDirectory is deleted 2025-11-06 14:57:54 -07:00
Michael Bucari-Tovo bfee579719 Fix DirectoryOrCustomSelectControl 2025-11-06 13:47:51 -07:00
Mbucari d4139861f3 Only allow mocking lobby bugging 2025-11-06 07:59:55 -07:00
Robert ba15eb1a95 incr ver 2025-11-06 09:43:11 -05:00
rmcrackan 6263fedf84 Merge pull request #1406 from Mbucari/master
Improved Login experience, error messages, and published size.
2025-11-06 08:44:44 -05:00
MBucari 0cbffc3f6c Only allow mocking settings while debugging 2025-11-05 23:52:44 -07:00
MBucari 5f093b06ec Improve Chardonnay setup reliability. 2025-11-05 23:40:20 -07:00
Michael Bucari-Tovo f815c5fd47 Define context menu in XAML, remove need for reflection 2025-11-05 16:20:47 -07:00
Michael Bucari-Tovo 69a8eaad4a Add mock LibraryBook and Configuration capabilities
- Added `MockLibraryBook` which contains factories for easily creating mock LibraryBooks and Books
- Added mock Configuration
  - New `IPersistentDictionary` interface
  - New `MockPersistentDictionary` class which uses a `JObject` as its data store
  - Added `public static Configuration CreateMockInstance()`
    - This method returns a mock Configuration instance **and also sets the `Configuration.Instance` property**
    - Throws an exception if not in debug
- Updated all chardonnay controls to use the mocks in design mode. Previously I was using my actual database and settings file, but that approach is fragile and is unfriendly towards anyone else trying to work on it.
2025-11-05 13:28:49 -07:00
Michael Bucari-Tovo 01b5c18b2b Improve Column Context Menu CheckBox display 2025-11-05 09:39:32 -07:00
Michael Bucari-Tovo 5634fee2aa Add Account column #1398 2025-11-05 08:48:29 -07:00
MBucari e98e4f10bc Ensure FileSystemWatcher is disposed 2025-11-04 22:08:36 -07:00
MBucari ec32ff77b2 Fix theme not being applied when changed by the system (#1368) 2025-11-04 22:07:29 -07:00
Michael Bucari-Tovo 683c984246 Modify script which populates username in webview 2025-11-04 15:06:03 -07:00
Michael Bucari-Tovo 0fa5c4eb1e Request metadata for the audiobook version being downloaded (#1261) 2025-11-04 14:58:27 -07:00
Michael Bucari-Tovo 7507044b82 Improve detection and logging of download capabilities
- Check if the Api supports widevine before trying to download
- Additional logging
2025-11-04 14:32:28 -07:00
Michael Bucari-Tovo 017902ab52 Click to open libation log file 2025-11-04 13:19:40 -07:00
Mbucari dcc5c1c640 Fix quotes in Libation.desktop Exec command 2025-11-03 18:32:05 -07:00
Michael Bucari-Tovo 19efa8c918 Improve error messages for Chardonnay
- A message box alert should be possible regardless of the error
- If crash pre-logging, attempt to write to last used Libation log file
2025-11-03 17:40:38 -07:00
Michael Bucari-Tovo a34efb5e61 Add multiple image sizes in windows folder icons 2025-11-03 14:04:30 -07:00
Michael Bucari-Tovo 9533f80e89 Replace NPOI excel workbook library with ClosedXML
- Reduce build bundles by 30-40 MB
2025-11-03 13:13:13 -07:00
Michael Bucari-Tovo fa238a0915 Use xplat webview control for Audible login
- Use Avalonia-based webview control for Audible login with Chardonnay
- Remove webview interfaces from IInteropFunctions
- Remove Microsoft.Web.WebView2 package from WindowsConfigApp
- Add Microsoft.Web.WebView2 to LibationWinForms
- Remove all other login forms except the external login dialog (fallback in case webview doesn't work). The AudibleApi login with username/password doesn't work anymore. Need to use external browser login method.
2025-11-03 11:29:57 -07:00
Michael Bucari-Tovo f98adef9e9 Update Dependencies
- Remove package references that are already included transitively
- Change Avalonia.ReactiveUI to ReactiveUI.Avalonia
2025-11-03 09:34:23 -07:00
rmcrackan d85e5a0f98 Update Advanced.md
CLI: copy the local sqlite database to postgres
2025-11-03 11:24:02 -05:00
Robert 365ac8167f incr ver 2025-11-03 11:04:55 -05:00
Robert 4720779373 Bug fix. DesignTimeDbContextFactoryBase used to call context.Database.Migrate() . Must now do so ourselves 2025-11-03 11:00:10 -05:00
Robert 0c512162ab Reverting migration notes from #1402 2025-11-03 10:21:13 -05:00
rmcrackan 09ca419faf Merge pull request #1402 from twsouthwick/postgres
Add support for postgres
2025-11-03 09:47:48 -05:00
Taylor Southwick a2b1f13601 remove migration calls 2025-11-02 18:32:20 -08:00
Robert f4e7cf3418 Bugfix #1403 : Trash bin actions result in app crashes 2025-11-01 13:32:19 -04:00
MBucari 8492a7ea3a Properly disposed of LibationContext (#1403) 2025-10-31 13:45:07 -06:00
Taylor Southwick 1b5db9b28f Add support for postgres
Supporting postgres simplifies deployments to environments such as kubernetes. Since sqlite doesn't work well on nfs shares it can be easier for databases to have a dedicated db set up that applications can connect to. Sqlite is easier for most deployments though, so this will default to that if the settings haven't been updated to support it.

This change does the following:

- Separate out SQLite from the DataLayer and adds a Postgres assembly for migrations as well
- Add a configuration setting for a postgres connection string that will be used if it is there, otherwise reverts to the original sqlite string
- Add a copydb command for the cli to bootstrap the postgres db
- A convenience script to update migrations for both dbs at the same time
2025-10-27 16:30:50 -07:00
rmcrackan 5589a6cbd5 Merge pull request #1401 from rmcrackan/dependabot/github_actions/actions/upload-artifact-5
Bump actions/upload-artifact from 4 to 5
2025-10-27 11:22:19 -04:00
dependabot[bot] bfbad988c0 Bump actions/upload-artifact from 4 to 5
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/upload-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 15:14:03 +00:00
rmcrackan 8db8615713 Merge pull request #1400 from rmcrackan/dependabot/github_actions/actions/download-artifact-6
Bump actions/download-artifact from 5 to 6
2025-10-27 11:07:00 -04:00
dependabot[bot] e8fa3f14b3 Bump actions/download-artifact from 5 to 6
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v5...v6)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '6'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-10-27 14:59:43 +00:00
Robert 71552f2417 incr ver 2025-10-23 09:45:55 -04:00
rmcrackan 5b96f96a80 Merge pull request #1390 from Mbucari/master
Bug Fixes and attempts to solve random crashes
2025-10-23 09:43:23 -04:00
Michael Bucari-Tovo afffeb953c Enforce sequential access to DbContext. 2025-10-22 09:22:05 -06:00
Mbucari f4d8685058 Merge branch 'rmcrackan:master' into master 2025-10-22 09:16:49 -06:00
Robert b4cfd18976 incr ver 2025-10-20 21:12:43 -04:00
rmcrackan e12548dacd Merge pull request #1388 from delebash/master
Added IncludedUntil Date
2025-10-20 21:10:46 -04:00
delebash fd95ac7a9c changes per Mbucari 2025-10-20 16:31:06 -04:00
delebash f7cd2b106b fix migration files 2025-10-20 14:12:04 -04:00
delebash 07e51f2191 IncludedUntil migration 2025-10-20 13:09:41 -04:00
delebash fcd79c5561 InludedUntil fixes by Mbucari 2025-10-20 12:55:48 -04:00
delebash ba98820989 move code to LibraryBook 2025-10-18 02:05:56 -04:00
delebash b07e61e6a8 fix UntilDate 2025-10-17 13:49:37 -04:00
delebash 8c3fd19c70 Merge remote-tracking branch 'origin/master' 2025-10-17 13:36:21 -04:00
delebash fa8f761771 Added IncludedUntil Date 2025-10-17 13:36:01 -04:00
rmcrackan 2c882e883d #1378 : allow for invalid beginning-of-time 'purchase_date'. This is the case for an actual user 2025-09-30 16:28:48 -04:00
Mbucari 74c76a7414 Enhance bug report template formatting and instructions
Updated the bug report template to improve clarity and formatting, including default log file locations for various platforms.
2025-09-04 12:46:22 -06:00
Mbucari 17a0c21453 Document xHE-AAC conformance errors for Audible
Added notes on xHE-AAC conformance errors related to Audible files.
2025-09-04 12:24:46 -06:00
rmcrackan fc9c9dfe48 Merge pull request #1360 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-5
Bump actions/setup-dotnet from 4 to 5
2025-09-03 21:43:10 -04:00
dependabot[bot] d5f0e39981 Bump actions/setup-dotnet from 4 to 5
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 4 to 5.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/setup-dotnet
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 00:05:35 +00:00
rmcrackan 0f6493f4af Merge pull request #1353 from rmcrackan/rmcrackan-patch-2
Update NamingTemplates.md
2025-08-27 09:30:09 -04:00
rmcrackan 454b490a06 Update NamingTemplates.md 2025-08-27 09:28:45 -04:00
rmcrackan ffea2648aa Merge pull request #1352 from rmcrackan/rmcrackan-patch-1
Update NamingTemplates.md
2025-08-27 09:22:20 -04:00
rmcrackan 1ac967500c Update NamingTemplates.md
Better documentation for inverted tags
2025-08-27 08:57:20 -04:00
Michael Bucari-Tovo 39e9f675d2 Fix possible race error on startup with autoscan 2025-08-26 10:00:21 -06:00
rmcrackan ed5afe5d0f update dependencies 2025-08-20 12:07:53 -04:00
rmcrackan ab075d0bef Merge pull request #1343 from MajorTanya/patch-1
Place examples in their own line
2025-08-20 11:49:46 -04:00
MajorTanya 7fb1adb41b Place examples in their own line
Markdown collapses single line breaks, so this change makes it so the examples will have their own lines.
2025-08-20 17:26:48 +02:00
rmcrackan 9735a8391c incr ver 2025-08-20 08:39:10 -04:00
rmcrackan dbdfdbc536 Merge pull request #1342 from Mbucari/master
Added new <has PROPERTY-><-has> conditional tag
2025-08-20 08:37:11 -04:00
Michael Bucari-Tovo 3b86fc405f Add <has-> template tag 2025-08-19 18:41:31 -06:00
MBucari 4ea7f04921 Preserve space between series order numbers 2025-08-17 13:40:37 -06:00
MBucari 5b59b442ab Add last downloaded sample rate, bitrate and codec name to search engine. 2025-08-17 13:07:24 -06:00
rmcrackan b5d9c0a27a Incr ver 2025-08-17 10:06:12 -04:00
rmcrackan f5cbf89e13 Merge pull request #1337 from Mbucari/master
Fix linux crpto and series order parsing
2025-08-17 09:57:43 -04:00
rmcrackan 00dc9e020d Update bug_report.md 2025-08-17 09:55:26 -04:00
MBucari bfa0e4d338 Parse floats with invariant culture 2025-08-16 16:39:36 -06:00
Mbucari 5ceda408da Use managed RSASSA-PSS with SHA-1
OpenSSL (the underlying RSA implementation on Linux) has deprecated SHA-1 signing. Used a managed implementation so that it does not error.
2025-08-16 16:28:33 -06:00
Mbucari 716b1923a4 Update FrequentlyAskedQuestions.md 2025-08-15 12:25:03 -06:00
rmcrackan 1148d8125d incr ver 2025-08-15 13:10:05 -04:00
rmcrackan 690fd10e42 Merge pull request #1331 from Mbucari/master
Audio format docs, new audio format options, series order parsing.
2025-08-15 13:08:10 -04:00
Michael Bucari-Tovo 736fbbf82f Improve FilePathCache performance 2025-08-15 10:50:37 -06:00
Michael Bucari-Tovo eda100b7ac Remove FluentAssertions 2025-08-15 10:29:23 -06:00
Michael Bucari-Tovo ceb007500d Update assertions to use ThrowsExactly 2025-08-14 15:58:01 -06:00
Michael Bucari-Tovo 05fad01624 Update dependencies 2025-08-14 15:57:35 -06:00
Michael Bucari-Tovo e1d789ccdc Improve series order parsing and formatting 2025-08-14 15:37:53 -06:00
Michael Bucari-Tovo d0f00f3f1e Add xHE-AAC option 2025-08-14 13:20:01 -06:00
Michael Bucari-Tovo 6ab82dba7b Add audio format info to wiki 2025-08-14 13:16:27 -06:00
rmcrackan 0045202334 update dependencies 2025-08-13 07:52:13 -04:00
rmcrackan 4c80813651 Merge pull request #1330 from rmcrackan/dependabot/github_actions/actions/checkout-5
Bump actions/checkout from 4 to 5
2025-08-11 17:38:40 -04:00
dependabot[bot] 6b637b35ab Bump actions/checkout from 4 to 5
Bumps [actions/checkout](https://github.com/actions/checkout) from 4 to 5.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/checkout
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-11 20:35:21 +00:00
rmcrackan 9b55ffa715 Merge pull request #1329 from rmcrackan/rmcrackan/dolby-atmos
Begin documentation for Dolby Atmos
2025-08-11 09:22:03 -04:00
rmcrackan 65da7890f1 Begin documentation for Dolby Atmos 2025-08-11 09:21:03 -04:00
rmcrackan 72f92ec6c0 update dependencies 2025-08-10 11:21:34 -04:00
rmcrackan 4efc084375 Merge pull request #1324 from rmcrackan/dependabot/github_actions/actions/download-artifact-5
Bump actions/download-artifact from 4 to 5
2025-08-06 10:24:33 -04:00
dependabot[bot] f955daa5ed Bump actions/download-artifact from 4 to 5
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 4 to 5.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v4...v5)

---
updated-dependencies:
- dependency-name: actions/download-artifact
  dependency-version: '5'
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-08-06 14:09:36 +00:00
rmcrackan 144ab2162a incr ver 2025-08-05 12:58:45 -04:00
rmcrackan 6d0c4a9b3c Merge pull request #1322 from Mbucari/master
Improve Libation's interaction with the file system & other minor fixes
2025-08-05 12:57:03 -04:00
Michael Bucari-Tovo 8a682533c1 Change repo link to rmcrackan 2025-08-05 10:39:03 -06:00
Michael Bucari-Tovo cecabc911e Add "Locate Audiobooks" help text 2025-08-05 10:29:48 -06:00
Michael Bucari-Tovo e35f5209dc Don't change user's replacement character settings
Instead, add the NTFS-only invalid characters to the set of invalid filename characters.
2025-08-05 09:49:22 -06:00
MBucari 4ffe70af0e Fix serilog not logging caller name 2025-08-04 23:24:55 -06:00
MBucari 233ba3184f Add link to naming template wiki 2025-08-04 21:23:50 -06:00
Michael Bucari-Tovo ac4c168725 Allow Libation to start with an invalid Books directory
- Configuration.LibationSettingsAreValid is true if Books property exists and is any non-null, non-empty string.
- If LibationSettingsAreValid is false, Libation will prompt user to set up Libation.
- When the main window is shown, Libation checks if the books directory exists, and if it doesn't, user is notified and prompted to change their setting
- When a user tries to liberate or convert a book, Books directory is validated and user notified if it does not exist.
2025-08-04 19:58:26 -06:00
Michael Bucari-Tovo db588629c0 Null safety and minor UI bugfix
Properly cancel the Locate Audiobooks when the dialog window closes before scanning is finished.
2025-08-04 17:15:37 -06:00
Michael Bucari-Tovo 29be091a4b Fix cross-thread error on AccoundSettings.Saved event 2025-08-04 14:18:04 -06:00
Michael Bucari-Tovo 82a48db57b Fix walkthrough errors on chardonnay. 2025-08-04 10:27:37 -06:00
Mbucari 9f0f32a462 Merge branch 'rmcrackan:master' into master 2025-08-04 10:02:02 -06:00
rmcrackan f64239b5ee Merge pull request #1316 from ajundi/master
Added nix flake and shell files for nixos developers
2025-08-04 09:55:42 -04:00
Ayman Jundi bc8a35aedd Added nix flake and shell files for nixos users who want to work on development. You can use flake method => 'nix develop' or non flake method =>`nix-shell'
The shell.nix file is used for both flake and non-flake invocations. The lock file is also set at a version where the project works.
Note the none-flake method will follow the version of the system and isn't guaranteed to work on older installations if they haven't been updated in a while.
Added Documentation for using Nix package manager for development ./Documentation/LinuxDevelopmentSetupUsingNix.md

Signed-off-by: Ayman Jundi <ajundi@gmail.com>
2025-08-03 16:36:03 -04:00
Ayman Jundi 2fca6b8b91 Added launch.json and task.json entries that allows building and debugging for linux-64 targets without having to modify the csproj files.
Signed-off-by: Ayman Jundi <ajundi@gmail.com>
2025-08-03 15:33:17 -04:00
Mbucari bc2eddd2dd Merge branch 'rmcrackan:master' into master 2025-07-31 10:36:26 -06:00
Michael Bucari-Tovo ae012548bd Smart handling of filename limitations cross platform
Automatically determine if filename lengths in the Books directory are limited to 255 UTF-16 characters (NTFS) or 255 UTF-8 bytes (pretty much every other file system) (#1260)

In non-Windows environments, determine if the Books directory supports filenames containing characters which are illegal in Windows environments (<>|:*?). If it doesn't, then ensure those characters are included in the user's ReplacementCharacters settings (#1258).
2025-07-30 16:04:48 -06:00
rmcrackan 76a59873ea undo dependabot overreach 2025-07-30 09:42:43 -04:00
rmcrackan 3129fdba7b Merge pull request #1319 from rmcrackan/dependabot/nuget/Source/ApplicationServices/multi-ab24e9613a
Bump SixLabors.ImageSharp to 2.1.11, 3.1.11
2025-07-30 09:37:18 -04:00
dependabot[bot] 1771813849 Bump SixLabors.ImageSharp to 2.1.11, 3.1.11
---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-version: 2.1.11
  dependency-type: direct:production
- dependency-name: SixLabors.ImageSharp
  dependency-version: 3.1.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 13:30:26 +00:00
Michael Bucari-Tovo 7024bbf823 Provide NTFS default characters for non-windows users (#1258) 2025-07-29 15:20:52 -06:00
Michael Bucari-Tovo 663f70b8bf Use series Order string instead of parsed float in template tags (#1056) 2025-07-29 12:18:37 -06:00
rmcrackan 7741e3caff incr ver 2025-07-29 07:54:24 -04:00
rmcrackan c82eefa768 Merge pull request #1317 from Mbucari/master
Bug fixes and UI tweak
2025-07-29 07:52:01 -04:00
Michael Bucari-Tovo 0e4231906a Set IsSpatial field instead of only flipping to true (#1273) 2025-07-28 15:28:04 -06:00
Michael Bucari-Tovo 9bca84dca4 Sort columns with null values always at the bottom 2025-07-28 09:29:17 -06:00
MBucari ca30fd41c6 Use proper version string based on build version 2025-07-28 08:59:20 -06:00
MBucari be96f99461 Increment Version 2025-07-27 11:40:35 -06:00
MBucari f017fe419f Fix ID3 tag encoding error (#1315) 2025-07-27 11:38:01 -06:00
rmcrackan ed42916cb2 incr ver 2025-07-26 23:11:51 -04:00
rmcrackan 0bb5bba3c8 Merge pull request #1314 from Mbucari/master
New audio format features, bug fixes, and minor tweaks/improvements.
2025-07-26 23:10:05 -04:00
MBucari a887bf4619 Add "Is Spatial" grid column. 2025-07-26 18:19:19 -06:00
MBucari 53eebcd6ba Use single file downloader/namer if file has only 1 chapter 2025-07-25 16:02:28 -06:00
MBucari a09ae1316d Don't display null file versions 2025-07-25 16:01:48 -06:00
MBucari 7088bd4b8d Check for file existance 2025-07-25 15:49:41 -06:00
MBucari b27325cdcb Improve comvert to mp3 task
- Improve progress reporting and cancellation performance
- Clear current book from queue before queueing single convert to mp3 task
2025-07-25 15:35:03 -06:00
MBucari accedeb1b1 Improve EditQuickFilters dialog reordering behavior 2025-07-25 14:23:14 -06:00
MBucari c98c7c095a Fix quickfilter modification bug (#1313) 2025-07-25 14:22:29 -06:00
MBucari 9b217a4e18 Add audio format data
- Add Book.IsSpatial property and add it to search index
- Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s.
- Store last downloaded audio file version
- Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs.
- Add last downloaded audio file version and format info to the Last Downloaded tab
- Migrated the DB
- Update AAXClean with some bug fixes
  - Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file)
  - Improve mp3 ID# tags support. Chapter titles are now preserved.
  - Add support for reading EC-3 and AC-4 audio format metadata
2025-07-25 12:18:50 -06:00
Michael Bucari-Tovo a62a9ffc5b Use HttpClient in synchronous mode 2025-07-23 17:00:54 -06:00
Michael Bucari-Tovo 08aebf8ecf Add thread safety 2025-07-23 17:00:36 -06:00
Michael Bucari-Tovo 2f082a9656 Refactor and optimize audiobook download and decrypt process
- Add more null safety
- Fix possible FilePathCache race condition
- Add MoveFilesToBooksDir progress reporting
- All metadata is now downloaded in parallel with other post-success tasks.
- Improve download resuming and file cleanup reliability
- The downloader creates temp files with a UUID filename and does not insert them into the FilePathCache. Created files only receive their final file names when they are moved into the Books folder. This is to prepare for a future plan re naming templates
2025-07-23 16:55:09 -06:00
Michael Bucari-Tovo 1f473039e1 Make search syntax dialog field names scrollable 2025-07-22 15:39:43 -06:00
Michael Bucari-Tovo 0f4197924e Use LibationUiBase.ReactiveObject where applicable
Also tweak the classic process queue control layout
2025-07-22 11:59:34 -06:00
rmcrackan 0f7ffacdf8 incr ver 2025-07-22 10:20:39 -04:00
rmcrackan 829b35c5a8 Merge pull request #1311 from Mbucari/master
Fix serilog dynamic assembly loading issue (#1310)
2025-07-22 10:18:33 -04:00
Michael Bucari-Tovo 614b05d5ff Fix serilog dynamic assembly loading issue (#1310) 2025-07-22 08:00:31 -06:00
rmcrackan 26ccc77b47 incr ver 2025-07-22 07:24:26 -04:00
rmcrackan 64fb2ccf7c Merge pull request #1308 from Mbucari/master
Refactors, bug fixes, and performance improvements.
2025-07-22 07:22:35 -04:00
MBucari 890747a902 Do library scan on background thread 2025-07-22 00:20:16 -06:00
Michael Bucari-Tovo 1fdcea929f Form thread safety 2025-07-21 22:52:17 -06:00
Michael Bucari-Tovo 7848366818 Write logs to text .log file instead of .zip file
The ZipFile sink could cause program hangs. Additionally, the only reason it was ever used was to package verbose AudibleApi account login errors, saving the returned Html page as a file. Otherwise, the zip file only contains a .log text file.

- Removed Serilog.Sinks.ZipFile
- Add Serilog configuration migration
- Added a custom destructure to handle logging files. If any files are logged, they will be written to "LogyyyyMM_AdditionalFiles.zip"
2025-07-21 22:19:55 -06:00
Michael Bucari-Tovo 40b4915b65 Improve download/decrypt cancellation 2025-07-21 15:56:41 -06:00
Michael Bucari-Tovo 80b86086ca Consolidate process queue view models
Remove classic and chardonnay-specific implementations
Refactor TrackedQueue into an IList with INotifyCollectionChanged
2025-07-21 15:56:30 -06:00
Michael Bucari-Tovo bff9b67b72 Remove GridEntry derrived types and interfaces
Use existing BaseUtil.LoadImage delegate, obviating need for derrived classes to load images

Since GridEntry types are no longer generic, interfaces are unnecessary and deleted.
2025-07-21 10:47:10 -06:00
Mbucari 657a7bb6bc Improve podcast episode GridEntry creation performance.
Tested on a library with ~5000 podcast episodes on an AMD Ryzen 7700X. Startup time decreases by ~400 ms in Release mode.
2025-07-21 09:49:25 -06:00
rmcrackan f0d7a7bf64 incr ver 2025-07-18 07:19:09 -04:00
rmcrackan 8bc098e7bd Merge pull request #1303 from Mbucari/master
Fix upgrade bug when Libation's working dir isn't program files dir
2025-07-18 07:16:46 -04:00
Michael Bucari-Tovo 9280b29512 Fix upgrade bug when Libation's working dir isn't program files dir
Add MockUpgrader for testing the Upgrade process.
Fixes issue #1302
2025-07-17 13:10:42 -06:00
rmcrackan d8e9b9c505 incr ver 2025-07-17 08:07:08 -04:00
rmcrackan 554b308364 Merge pull request #1299 from Mbucari/master
Bugfixes and minor improvements
2025-07-17 08:04:43 -04:00
MBucari 8d7872a376 UI tweak and optimization 2025-07-16 23:31:34 -06:00
MBucari 747451d243 Refactor Classic process queue
The queue is now more MVVM-like.
2025-07-16 22:58:03 -06:00
MBucari 7e79e98771 Fix possible cross-threading errors with MessageBoxBase 2025-07-16 22:57:25 -06:00
Michael Bucari-Tovo 4b7939541a Code cleanup and refactoring for clarity 2025-07-16 22:55:57 -06:00
MBucari a3734c76b1 Use SynchronizeInvoker's Invoke() method. 2025-07-15 23:22:42 -06:00
MBucari ced4ea6c17 Improve sorting by Liberate status by grouping books with PDFs 2025-07-15 22:50:53 -06:00
MBucari 35ca6f2621 Use built-in comparer and ReactiveObject types 2025-07-15 22:50:28 -06:00
MBucari 4dab16837e Move ProcessQueueViewModel logic into LibationUiBase
Fix UI bug in classic when queue is in popped-out mode.
2025-07-15 22:31:17 -06:00
MBucari 1cf889eed7 Move ProcessBookViewModel logic into LiationUiBase 2025-07-15 15:05:33 -06:00
MBucari b65b1e819b Consolidate queue commands into UI base 2025-07-15 13:32:42 -06:00
MBucari 3d50643ab0 Fix visible book counts being incorrect on startup
If quick filters are applied on startup, a race condition was created between the initial library load book counting and the visible books counting. Only display results of the latest book count.
2025-07-15 11:49:20 -06:00
MBucari abd18d74b0 Fix crash when setting drive root as custom directory (#1300) 2025-07-15 11:44:45 -06:00
MBucari 0e49df06b8 Add message box handler to LibationUiBase 2025-07-15 11:40:01 -06:00
MBucari 38cc3e9725 Revert change to release title 2025-07-15 08:54:22 -06:00
MBucari c9af2bba4b Reduce GitHub API calls when no upgrades are available 2025-07-14 14:43:48 -06:00
MBucari 2191c1536d Prepare Libation for win-arm64 releases
Also add support for four-part version numbers in releases.
2025-07-14 14:20:57 -06:00
MBucari 5b9bf2fbb0 Remove duplicate tests 2025-07-14 12:53:47 -06:00
MBucari 9b1ce8c1d7 Update dependencies 2025-07-14 12:43:53 -06:00
MBucari 9f8075041b Only remove a LibraryBook from queue if we are trying to re-download. 2025-07-14 12:42:05 -06:00
MBucari 944645379e Fix message box text truncation when there is no icon (#1294) 2025-07-14 12:19:26 -06:00
Mbucari cc72517284 Merge branch 'rmcrackan:master' into master 2025-07-14 11:45:44 -06:00
rmcrackan 0044820415 Update README.md 2025-07-07 16:31:09 -04:00
rmcrackan 9f24027de1 Update README.md 2025-07-07 16:29:46 -04:00
rmcrackan 24f95cb03d Update GettingStarted.md 2025-07-07 16:27:59 -04:00
rmcrackan 3aeea54615 Update FrequentlyAskedQuestions.md 2025-07-07 16:26:10 -04:00
rmcrackan f511041781 Create a cue sheet: default false 2025-06-25 12:43:50 -04:00
rmcrackan da9dc91469 incr ver for docker enhancement 2025-06-25 06:58:14 -04:00
rmcrackan e04e70d333 Merge pull request #1265 from vipervire/master
Update Books directory to use LIBATION_BOOKS_DIR if populated
2025-06-25 06:57:01 -04:00
rmcrackan e0b566ee60 Merge pull request #1277 from dev-nicolaos/patch-1
Update deb/rpm Installation Instructions
2025-06-24 07:50:35 -04:00
Nicolaos Skimas bf15d7302e Update Deb/RHEL/Fed Installation Instructions 2025-06-23 22:39:45 -07:00
rmcrackan 8f01c644c0 Update bug_report.md 2025-06-19 07:21:21 -04:00
Mbucari ebd2cc96c5 Merge branch 'rmcrackan:master' into master 2025-06-18 12:13:14 -06:00
rmcrackan 0d1cc42ca7 Bugfix #1269 : Chardonnay. Bad filter string causes infinite loop 2025-06-16 13:19:48 -04:00
vipervire e126dd09ce Update Books directory to use LIBATION_BOOKS_DIR if populated 2025-06-05 23:26:52 +00:00
Michael Bucari-Tovo ec497f4f81 Use virtualized list to improve large queue performance 2025-05-19 10:40:41 -06:00
rmcrackan 248fdfd2bc Probably unnecessary paranoid incr ver. Everything looks correct but I've never actually released relying on the ver's 4th part. I'm incrementing just in case 2025-05-10 16:53:04 -04:00
590 changed files with 19227 additions and 12194 deletions

No files matched your search

+20 -7
View File
@@ -6,10 +6,14 @@ labels: bug
assignees: '' assignees: ''
--- ---
**Describe the bug** PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking an attached log file may get limited or delayed help.
___
## Describe the bug
A clear and concise description of what the bug is. A clear and concise description of what the bug is.
**To Reproduce** ## To Reproduce
Steps to reproduce the behavior: Steps to reproduce the behavior:
1. Go to '...' 1. Go to '...'
@@ -17,14 +21,23 @@ Steps to reproduce the behavior:
3. Scroll down to '....' 3. Scroll down to '....'
4. See error 4. See error
**Expected behavior** ## Expected behavior
A clear and concise description of what you expected to happen. A clear and concise description of what you expected to happen.
**Screenshots** ## Screenshots
If applicable, add screenshots to help explain your problem. If applicable, add screenshots to help explain your problem.
**Platform** ## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)] [e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files** ## Log Files
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder' Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
**Default Log File Locations**
|Platform|Folder|
|-|-|
|Windows|`%userprofile%\Libation`|
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.
+39 -88
View File
@@ -6,63 +6,46 @@ name: build
on: on:
workflow_call: workflow_call:
inputs: inputs:
version_override: libation-version:
type: string type: string
description: "Version number override" required: true
required: false dotnet-version:
run_unit_tests: type: string
required: true
run-unit-tests:
type: boolean type: boolean
description: "Skip running unit tests" publish-r2r:
required: false type: boolean
default: true retention-days:
runs_on: type: number
architecture:
type: string type: string
description: "The GitHub hosted runner to use" description: "CPU architecture targeted by the build."
required: true required: true
OS: OS:
type: string type: string
description: > description: >
The operating system targeted by the build. The operating system targeted by the build.
There must be a corresponding Bundle_$OS.sh script file in ./Scripts There must be a corresponding Bundle_$OS.sh script file in ./Scripts
required: true required: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
RELEASE_NAME: "chardonnay"
jobs: jobs:
build: build:
name: "${{ inputs.OS }}-${{ inputs.architecture }}" name: "${{ inputs.OS }}-${{ inputs.architecture }}"
runs-on: ${{ inputs.runs_on }} runs-on: ubuntu-latest
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v5
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ inputs.dotnet-version }}
env: dotnet-quality: "ga"
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
inputVersion="${{ inputs.version_override }}"
if [[ "${#inputVersion}" -gt 0 ]]
then
version="${inputVersion}"
else
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
- name: Unit test - name: Unit test
if: ${{ inputs.run_unit_tests }} if: ${{ inputs.run-unit-tests }}
working-directory: ./Source working-directory: ./Source
run: dotnet test run: dotnet test
@@ -70,63 +53,31 @@ jobs:
id: publish id: publish
working-directory: ./Source working-directory: ./Source
run: | run: |
if [[ "${{ inputs.OS }}" == "MacOS" ]] PUBLISH_ARGS=(
then '--runtime' '${{ env.RUNTIME_ID }}'
display_os="macOS" '--configuration' 'Release'
RUNTIME_ID="osx-${{ inputs.architecture }}" '--output' '../bin'
else '-p:PublishProtocol=FileSystem'
display_os="Linux" "-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
RUNTIME_ID="linux-${{ inputs.architecture }}" '-p:SelfContained=true')
fi
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}" dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
echo "display_os=${display_os}" >> $GITHUB_OUTPUT dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
echo "Runtime Identifier: $RUNTIME_ID"
echo "Output Directory: $OUTPUT"
dotnet publish \
LibationAvalonia/LibationAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LibationCli/LibationCli.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
HangoverAvalonia/HangoverAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
- name: Build bundle - name: Build bundle
id: bundle id: bundle
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
run: | run: |
BUNDLE_DIR=$(pwd) SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
echo "Bundle dir: ${BUNDLE_DIR}"
cd ..
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
chmod +rx ${SCRIPT} chmod +rx ${SCRIPT}
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}" ${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
artifact=$(ls ./bundle) artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}" echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle - uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with: with:
name: ${{ steps.bundle.outputs.artifact }} name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }} path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: ${{ inputs.retention-days }}
+104
View File
@@ -0,0 +1,104 @@
# build-mac.yml
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
sign-app:
type: boolean
description: "Wheather to sign an notorize the app bundle and dmg."
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
jobs:
build:
name: "macOS-${{ inputs.architecture }}"
runs-on: macos-latest
env:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- uses: apple-actions/import-codesign-certs@v6
if: ${{ inputs.sign-app }}
with:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
run: |
SCRIPT=./Scripts/Bundle_MacOS.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Notarize bundle
if: ${{ inputs.sign-app }}
run: |
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
WAIT="--wait"
fi
echo "::debug::Submitting the disk image for notarization"
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
echo "$RESPONSE"
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
echo "::debug::Stapling the notarization ticket to the disk image"
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
fi
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
+45 -65
View File
@@ -6,102 +6,82 @@ name: build
on: on:
workflow_call: workflow_call:
inputs: inputs:
version_override: libation-version:
type: string type: string
description: "Version number override" required: true
required: false dotnet-version:
run_unit_tests: type: string
required: true
run-unit-tests:
type: boolean type: boolean
description: "Skip running unit tests" publish-r2r:
required: false type: boolean
default: true retention-days:
type: number
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
jobs: jobs:
build: build:
name: "${{ matrix.os }}-${{ matrix.release_name }}" name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
runs-on: windows-latest runs-on: windows-latest
strategy: strategy:
matrix: matrix:
os: [Windows]
ui: [Avalonia] ui: [Avalonia]
architecture: [x64]
release_name: [chardonnay] release_name: [chardonnay]
include: include:
- os: Windows - architecture: x64
ui: WinForms ui: WinForms
release_name: classic release_name: classic
prefix: Classic- prefix: Classic-
- architecture: arm64
ui: Avalonia
release_name: chardonnay
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Setup .NET
uses: actions/setup-dotnet@v4 - uses: actions/setup-dotnet@v5
with: with:
dotnet-version: ${{ env.DOTNET_VERSION }} dotnet-version: ${{ inputs.dotnet-version }}
env: dotnet-quality: "ga"
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
if ("${{ inputs.version_override }}".length -gt 0) {
$version = "${{ inputs.version_override }}"
} else {
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
}
"version=$version" >> $env:GITHUB_OUTPUT
- name: Unit test - name: Unit test
if: ${{ inputs.run_unit_tests }} if: ${{ inputs.run-unit-tests }}
working-directory: ./Source working-directory: ./Source
run: dotnet test run: dotnet test
- name: Publish - name: Publish
working-directory: ./Source working-directory: ./Source
run: | run: |
dotnet publish ` $PUBLISH_ARGS=@(
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj ` "--runtime", "win-${{ matrix.architecture }}",
--configuration ${{ env.DOTNET_CONFIGURATION }} ` "--configuration", "Release",
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` "--output", "../bin",
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml "-p:PublishProtocol=FileSystem",
dotnet publish ` "-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj ` "-p:SelfContained=true")
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} ` dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
dotnet publish ` dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
LibationCli/LibationCli.csproj ` dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
- name: Zip artifact - name: Zip artifact
id: zip id: zip
working-directory: ./Source/bin/Publish working-directory: ./bin
run: | run: |
$bin_dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
$delfiles = @( $delfiles = @(
"WindowsConfigApp.exe", "WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json", "WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json" "WindowsConfigApp.deps.json")
)
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } } foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}" $artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT "artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip" Compress-Archive -Path * -DestinationPath "$artifact"
- name: Publish artifact - uses: actions/upload-artifact@v6
uses: actions/upload-artifact@v4
with: with:
name: ${{ steps.zip.outputs.artifact }}.zip name: ${{ steps.zip.outputs.artifact }}
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error if-no-files-found: error
retention-days: 7 retention-days: ${{ inputs.retention-days }}
+45 -25
View File
@@ -6,22 +6,51 @@ name: build
on: on:
workflow_call: workflow_call:
inputs: inputs:
version_override: libation-version:
type: string type: string
description: "Version number override" description: "Libation version number"
required: false required: true
run_unit_tests: dotnet-version:
type: string
default: "10.x"
description: ".NET version to target"
run-unit-tests:
type: boolean type: boolean
description: "Skip running unit tests" description: "Whether to run unit tests prior to publishing."
required: false publish-r2r:
default: true type: boolean
description: "Whether to publish assemblies as ReadyToRun."
release:
type: boolean
description: "Whether this workflow is being called as a release"
retention-days:
type: number
description: "Number of days the artifacts are to be retained."
jobs: jobs:
windows: windows:
uses: ./.github/workflows/build-windows.yml uses: ./.github/workflows/build-windows.yml
with: with:
version_override: ${{ inputs.version_override }} libation-version: ${{ inputs.libation-version }}
run_unit_tests: ${{ inputs.run_unit_tests }} dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
macOS:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-mac.yml
with:
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
secrets: inherit
linux: linux:
strategy: strategy:
@@ -30,20 +59,11 @@ jobs:
architecture: [x64, arm64] architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml uses: ./.github/workflows/build-linux.yml
with: with:
version_override: ${{ inputs.version_override }} libation-version: ${{ inputs.libation-version }}
runs_on: ubuntu-latest dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }} OS: ${{ matrix.OS }}
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}
macos:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: macos-latest
OS: MacOS
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}
+67
View File
@@ -0,0 +1,67 @@
name: Deploy VitePress site to Pages
on:
# Runs on pushes targeting the `main` branch. Change this to `master` if you're
# using the `master` branch as the default branch.
push:
branches: [master]
paths:
- .github/workflows/deploy.yml
- docs/**
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: pages
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # Not needed if lastUpdated is not enabled
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
# with:
# version: 9 # Not needed if you've set "packageManager" in package.json
# - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: npm ci # or pnpm install / yarn install / bun install
- name: Build with VitePress
run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: .vitepress/dist
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4
+1 -1
View File
@@ -26,7 +26,7 @@ jobs:
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v6
- name: Set up QEMU - name: Set up QEMU
uses: docker/setup-qemu-action@v3 uses: docker/setup-qemu-action@v3
+6 -4
View File
@@ -8,7 +8,7 @@ on:
- "v*" - "v*"
jobs: jobs:
prerelease: prerelease:
runs-on: ubuntu-latest runs-on: ubuntu-slim
outputs: outputs:
version: ${{ steps.get_version.outputs.version }} version: ${{ steps.get_version.outputs.version }}
steps: steps:
@@ -31,16 +31,18 @@ jobs:
build: build:
needs: [prerelease] needs: [prerelease]
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
secrets: inherit
with: with:
version_override: ${{ needs.prerelease.outputs.version }} libation-version: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false publish-r2r: true
release: true
release: release:
needs: [prerelease, build] needs: [prerelease, build]
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Download artifacts - name: Download artifacts
uses: actions/download-artifact@v4 uses: actions/download-artifact@v7
with: with:
path: artifacts path: artifacts
pattern: "*(Classic-)Libation.*" pattern: "*(Classic-)Libation.*"
@@ -17,6 +17,6 @@ jobs:
container: container:
image: ghcr.io/flathub/flatpak-builder-lint:latest image: ghcr.io/flathub/flatpak-builder-lint:latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- name: Check the MetaInfo file - name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
validate-desktop-file: validate-desktop-file:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v6
- run: sudo apt --yes install desktop-file-utils - run: sudo apt --yes install desktop-file-utils
- name: Check the desktop file - name: Check the desktop file
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
+26 -1
View File
@@ -6,16 +6,41 @@ name: validate
on: on:
push: push:
branches: [master] branches: [master]
paths:
- Source/**
- .github/workflows/**
pull_request: pull_request:
branches: [master] branches: [master]
paths:
- Source/**
- .github/workflows/**
jobs: jobs:
get_version:
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get version
id: get_version
run: |
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
build: build:
needs: [get_version]
uses: ./.github/workflows/build.yml uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.get_version.outputs.version }}
retention-days: 14
run-unit-tests: true
docker: docker:
needs: [get_version]
uses: ./.github/workflows/docker.yml uses: ./.github/workflows/docker.yml
with: with:
version: ${GITHUB_SHA} version: ${{ needs.get_version.outputs.version }}
release: false release: false
secrets: secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }} docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
+12 -1
View File
@@ -370,4 +370,15 @@ FodyWeavers.xsd
/__TODO.txt /__TODO.txt
/DataLayer/LibationContext.db /DataLayer/LibationContext.db
*/bin-Avalonia */bin-Avalonia
# macOS Directory Info
.DS_Store
# JetBrains Rider Settings
**/.idea/
# VitePress
node_modules
.vitepress/cache
.vitepress/dist
+9 -8
View File
@@ -1,10 +1,11 @@
{ {
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip", "WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip", "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.deb", "WindowsAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-arm64\\.zip",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-amd64\\.rpm", "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-x64\\.tgz", "LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.deb", "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay-arm64\\.rpm", "LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+-macOS-chardonnay-arm64\\.tgz" "LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
} }
+90
View File
@@ -0,0 +1,90 @@
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Libation",
description: "Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
cleanUrls: true,
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
logo: {
light: "/libation_logo_light.svg",
dark: "/libation_logo_dark.svg",
},
footer: {
message: "Released under the GPLv3 License",
},
editLink: {
pattern: "https://github.com/rmcrackan/Libation/edit/main/:path",
},
lastUpdated: true,
nav: [
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "Docs", link: "/docs/index" },
{ text: "Download", link: "https://github.com/rmcrackan/Libation/releases/latest" },
{ text: "Issues & Requests", link: "https://github.com/rmcrackan/Libation/issues" },
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
],
sidebar: [
{
items: [
{ text: "Overview", link: "/docs/index"},
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "FAQ", link: "/docs/frequently-asked-questions" },
{
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
],
},
{
text: "Installation",
collapsed: false,
items: [
{ text: "Linux", link: "/docs/installation/linux" },
{ text: "Mac", link: "/docs/installation/mac" },
{ text: "Docker", link: "/docs/installation/docker" },
],
},
{
text: "Features",
collapsed: false,
items: [
{ text: "Audio File Formats", link: "/docs/features/audio-file-formats" },
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
{
text: "Searching & Filtering",
link: "/docs/features/searching-and-filtering",
},
],
},
{
text: "Advanced",
collapsed: false,
items: [
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
{
text: "Linux Development Setup",
link: "/docs/advanced/linux-development-setup-using-nix",
},
],
},
],
outline: {
level: "deep",
},
socialLinks: [{ icon: "github", link: "https://github.com/rmcrackan/Libation" }],
search: {
provider: "local",
},
},
});
+15
View File
@@ -0,0 +1,15 @@
/* Custom styles for Libation documentation */
/* Hide certain nav items on tablet devices to prevent horizontal scroll */
@media (min-width: 640px) and (max-width: 959px) {
/* Target specific nav items by their position */
/* Hide "Issues & Requests" and "Donate" links on tablet */
.VPNav .VPNavBar .nav .VPNavBarMenu .VPMenu:nth-child(3) {
display: none;
}
/* Alternative: Use a more specific selector if needed */
.VPNavBarMenuLink[href*="issues"] {
display: none;
}
}
+4
View File
@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme
+12 -1
View File
@@ -6,7 +6,7 @@
"configurations": [ "configurations": [
{ {
"name": ".NET Core Launch (console)", "name": ".NET Core Launch (console) Windows",
"type": "coreclr", "type": "coreclr",
"request": "launch", "request": "launch",
"preLaunchTask": "build", "preLaunchTask": "build",
@@ -15,6 +15,17 @@
"cwd": "${workspaceFolder}", "cwd": "${workspaceFolder}",
"stopAtEntry": false, "stopAtEntry": false,
"console": "internalConsole" "console": "internalConsole"
},
{
"name": ".NET Core Launch (console) Linux",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build_linux",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
} }
] ]
+17
View File
@@ -37,6 +37,23 @@
//"reveal": "silent" //"reveal": "silent"
}, },
"problemMatcher": "$msCompile" "problemMatcher": "$msCompile"
},
{
"label": "build_linux",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj",
"-p:TargetFramework=net9.0",
"-p:TargetFrameworks=net9.0",
"-p:RuntimeIdentifier=linux-x64"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
} }
] ]
} }
+5
View File
@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<EnableMSTestRunner>true</EnableMSTestRunner>
</PropertyGroup>
</Project>
+1 -1
View File
@@ -124,7 +124,7 @@ main() {
init_config_file Settings.json init_config_file Settings.json
info "loading settings" info "loading settings"
update_settings Settings.json Books /data update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
update_settings Settings.json InProgress /tmp update_settings Settings.json InProgress /tmp
info "loading database" info "loading database"
+6 -3
View File
@@ -1,16 +1,19 @@
# Dockerfile # Dockerfile
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH ARG TARGETARCH
COPY Source /Source COPY Source /Source
RUN dotnet publish \ RUN dotnet publish \
/Source/LibationCli/LibationCli.csproj \ /Source/LibationCli/LibationCli.csproj \
--os linux \
--arch ${TARGETARCH} \ --arch ${TARGETARCH} \
--configuration Release \ --configuration Release \
--output /Source/bin/Publish/Linux-chardonnay \ --output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml -p:PublishProtocol=FileSystem \
-p:PublishReadyToRun=true \
-p:SelfContained=true
FROM mcr.microsoft.com/dotnet/runtime:9.0 FROM mcr.microsoft.com/dotnet/runtime:10.0
ARG USER_UID=1001 ARG USER_UID=1001
ARG USER_GID=1001 ARG USER_GID=1001
-111
View File
@@ -1,111 +0,0 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Advanced: Table of Contents
- [Files and folders](#files-and-folders)
- [Settings](#settings)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Settings
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
* Adds the `TCOM` (`@wrt` in M4B files) metadata tag for the narrators.
* Sets the `©gen` metadata tag for the genres.
* Unescapes the copyright symbol (replace `&#169;` with `©`)
* Replaces the recording copyright `(P)` string with `℗`
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
```
help
libationcli --help
verb-specific help
libationcli scan --help
scan all libraries
libationcli scan
scan only libraries for specific accounts
libationcli scan nickname1 nickname2
convert all m4b files to mp3
libationcli convert
liberate all books and pdfs
libationcli liberate
liberate pdfs only
libationcli liberate --pdf
libationcli liberate -p
export library to file
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
#### Theme Editor Window
The theme editor has a list of style names and their currently assigned colors. To change a style color, click on the color swatch in the left-hand column to open the color editor for that style. Observe the color changes in real-time on the built-in preview panel on the right-hand side of the theme editor.
You may import or export themes using the buttons at the bottom-left of the theme editor.
"Cancel" or closing the window will revert any changes you've made in the theme editor.
"Reset" will reset any changes you've made in the theme editor.
"Defaults" will restore the application default colors for the active theme ("Light" or "Dark")
"Save" will save the theme colors to the ChardonnayTheme.json file and close the editor.
Note: you may only edit the currently applied theme ("Light" or "Dark").
#### Video Walkthrough
The below video demonstrates using the theme editor to make changes to the Dark theme color pallet.
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
-42
View File
@@ -1,42 +0,0 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Frequently Asked Questions
## Q: What's the difference between 'Classic' and 'Chardonnay'?
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
* **Classic** is Windows only. Its older 'grey boxes' look has a compact design which allows for more information on the screen. Notably, Classic was written using an older, more mature technology which has built-in support for screenreaders.
* **Chardonnay** is available for Windows, Mac, and Linux. Its modern design has a more open look and feel.
## Q: Now that I've downloaded my books, how can I listen to them?
**A:** You can use any app which plays m4b files (or mp3 files if you used that setting). Here are just a few ideas. Disclaimer: I have no affiliation with any of these companies:
* iOS: [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
* iOS: [Bound](https://apps.apple.com/us/app/bound-audiobook-player/id1041727137)
* Android: [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer&hl=en_US&gl=US)
* Android: [Listen](https://play.google.com/store/apps/details?id=ru.litres.android.audio&hl=en_US&gl=US)
* Desktop: [VLC](https://www.videolan.org/)
* Windows Desktop: [Audibly](https://github.com/rstewa/Audibly) -- a desktop player build specifically for audiobooks
Self-hosting online:
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
## Q: I'm having trouble loggin into my Brazil account.
For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
## Q: How do I use Libation with a South Africa account?
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.
(Not exactly a *frequently* asked question but it's come up more than once)
-67
View File
@@ -1,67 +0,0 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Packaging status
[![Packaging status](https://repology.org/badge/vertical-allrepos/libation.svg)](https://repology.org/project/libation/versions)
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the Libation repository's releases page.
Run this command in your terminal to download and install Libation, replacing the url with the latest Libation package url:
### Debian
```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.deb &&
sudo apt install ./libation.deb
```
### Redhat and CentOS
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
sudo yum install ./libation.rpm
```
### Fedora
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay.rpm &&
sudo dnf5 install ./libation.rpm
```
---
### Arch Linux
```Console
yay -S libation
```
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
### NixOS
- Install via `nix-shell`
```Console
nix-shell -p libation
```
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
- Install via NixOS configuration
```Console
environment.systemPackages = [
pkgs.libation
];
```
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
- On NixOS via via `nix-env`
```Console
nix-env -iA nixos.libation
```
- On Non NixOS via `nix-env`
```Console
nix-env -iA nixpkgs.libation
```
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
If your desktop uses gtk, you should now see Libation among your applications.
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
Report bugs to https://github.com/rmcrackan/Libation/issues
-82
View File
@@ -1,82 +0,0 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Supports macOS 13 (Ventura) and above
## Install Libation
- Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder.
- Right-click on Libation and then click on open
- The first time, it will not immediately show you an option to open it. Just dismiss the dialog and do the same thing again (right-click -> open) then you will get an option to run the unsigned application. This takes about 10 seconds.
## If this doesn't work
You can add Libation as a safe app without touching Gatekeeper.
- Copy/paste/run the following command. Adjust the file path to the Libation.app on your computer if necessary.
```Console
xattr -r -d com.apple.quarantine ~/Downloads/Libation.app
```
- Close the terminal and use Libation!
## If this still doesn't work
- Copy/paste/run the following command (you'll be prompted to enter your Mac password)
```Console
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
```
* Close the terminal and use Libation!
## "Apple can't check app for malicious software"
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
* Go to Security, then click Open.
* Click Open Anyway. This button is available for about an hour after you try to open the app.
* Enter your login password, then click OK.
## Troubleshooting
If Libation fails to start after completing the above steps, try the following:
1. Right-click the Libation app in your applications folder and select _Show Package Contents_
2. Open the `Contents` folder and then the `MacOS` folder.
3. Find the file named `Libation`, right-click it, and then select _Open_.
Libation _should_ launch, and you should now be able to open Libation by just double-clicking the app bundle in your applications folder.
## Running Hangover
Libation comes with a recovery app called Hangover. You can start it by running this command:
```Console
open /Applications/Libation.app --args hangover
```
## Running LibationCli
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
```Console
open /Applications/Libation.app --args cli
```
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
Then use `./LibationCli` to execute a command.
## Get Libation running on Mac
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
-153
View File
@@ -1,153 +0,0 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Naming Templates
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
These templates apply to both GUI and CLI.
# Table of Contents
- [Template Tags](#template-tags)
- [Property Tags](#property-tags)
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Series Formatters](#series-formatters)
- [Series List Formatters](#series-list-formatters)
- [Name Formatters](#name-formatters)
- [Name List Formatters](#name-list-formatters)
- [Number Formatters](#number-formatters)
- [Date Formatters](#date-formatters)
# Template Tags
These are the naming template tags currently supported by Libation.
## Property Tags
These tags will be replaced in the template with the audiobook's values.
|Tag|Description|Type|
|-|-|-|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|\<first author\>|First author|[Name](#name-formatters)|
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|\<first series\>|First series|[Series](#series-formatters)|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|\<locale\>|Region/country|[Text](#text-formatters)|
|\<year\>|Year published|[Number](#number-formatters)|
|\<language\>|Book's language|[Text](#text-formatters)|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
**†** Does not support custom formatting
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
## Conditional Tags
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|Tag|Description|Type|
|-|-|-|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
# Tag Formatters
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
## Text Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Series Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1, B08376S3R2|
## Series List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<author[format({L}, {ID}) separator(; )]>`|Sherlock Holmes, 1; Some Other Series, 1<hr>herlock Holmes, B08376S3R2; Some Other Series, B000000000|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
## Name Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
## Name List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|format(\{T \| F \| M \| L \| S \| ID\})|Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above.|`<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
## Number Formatters
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|0|Replaces the zero with the corresponding digit if one<br>is present; otherwise, zero appears in the result string.|\<series#\[000.0\]\>|001.0|
|#|Replaces the "#" symbol with the corresponding digit if one<br> is present; otherwise, no digit appears in the result string|\<series#\[00.##\]\>|01|
## Date Formatters
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
### Standard DateTime Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
### Custom DateTime Formatters
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|yy|2-digit year|\<file date[yy]\>|23|
|MM|2-digit month|\<file date[MM]\>|02|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
Binary file not shown.
Binary file not shown.
Binary file not shown.
+22 -53
View File
@@ -3,71 +3,40 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest) ## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us) ### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**. ...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Getting started with Libation
All documentation has been moved to our new site: [getlibation.com](https://getlibation.com). Or jump to the important bits:
# Table of Contents * [Getting Started](https://getlibation.com/docs/getting-started)
- [Audible audiobook manager](#audible-audiobook-manager)
- [The good](#the-good)
- [The bad](#the-bad)
- [The ugly](#the-ugly)
- [Getting started](Documentation/GettingStarted.md)
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
- [Installation](Documentation/GettingStarted.md#installation)
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
- [Import your library](Documentation/GettingStarted.md#import-your-library)
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
- [Export your library](Documentation/GettingStarted.md#export-your-library)
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
- [Tags](Documentation/SearchingAndFiltering.md#tags)
- [Searches](Documentation/SearchingAndFiltering.md#searches)
- [Search examples](Documentation/SearchingAndFiltering.md#search-examples)
- [Filters](Documentation/SearchingAndFiltering.md#filters)
- [Advanced](Documentation/Advanced.md)
- [Files and folders](Documentation/Advanced.md#files-and-folders)
- [Settings](Documentation/Advanced.md#settings)
- [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
- [Docker](Documentation/Docker.md)
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
## Getting started
* [Download](https://github.com/rmcrackan/Libation/releases/latest) * [Download](https://github.com/rmcrackan/Libation/releases/latest)
* [Step-by-step walk-through](Documentation/GettingStarted.md) * [Issues, bugs, and requests](https://github.com/rmcrackan/Libation/issues)
* [Documentation](https://getlibation.com/docs/index)
## Audible audiobook manager ## Development
### The good ### Documentation
* Import library from audible, including cover art The documentation is built with [VitePress](https://vitepress.dev/) and located in the `docs` directory. For more information like [markdown syntax](https://vitepress.dev/guide/markdown#advanced-configuration) and [routing](https://vitepress.dev/guide/routing) or other features, refer [VitePress documentation](https://vitepress.dev/guide).
* Download and remove DRM from all books
* Download accompanying PDFs
* Add tags to books for better organization
* Powerful advanced search built on the Lucene search engine
* Customizable saved filters for common searches
* Open source
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
* Fully supported in Windows, Mac, and Linux
<a name="theBad"/> **Prerequisites**: Node.js 18+
### The bad **Commands**:
* Large file size ```bash
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows # Install dependencies
npm install
### The ugly # Start local dev server (http://localhost:5173)
npm run docs:dev
* Documentation? Yer lookin' at it # Build for production (output: docs/.vitepress/dist)
* This is a single-developer personal passion project. Support, response, updates, enhancements, bug fixes etc are as my free time allows npm run docs:build
* I have a full-time job, a life, and a finite attention span. Therefore a lot of time can potentially go by with no improvements of any kind
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk. # Preview production build
npm run docs:preview
```
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help. **Note**: New pages are automatically routed based on their folder structure (e.g., `docs/docs/index.md` maps to `/docs/index`). To add them to the sidebar, update the `sidebar` configuration in `.vitepress/config.js`.
+1 -8
View File
@@ -28,14 +28,6 @@ then
exit exit
fi fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/') ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb DEB_DIR=./deb
@@ -113,6 +105,7 @@ Essential: no
Priority: optional Priority: optional
Maintainer: github.com/rmcrackan Maintainer: github.com/rmcrackan
Description: liberate your audiobooks Description: liberate your audiobooks
Recommends: libgtk-3-0, libwebkit2gtk-4.1-0
" >> $FOLDER_DEBIAN/control " >> $FOLDER_DEBIAN/control
echo "Changing permissions for pre- and post-install files..." echo "Changing permissions for pre- and post-install files..."
+41 -16
View File
@@ -3,6 +3,7 @@
BIN_DIR=$1; shift BIN_DIR=$1; shift
VERSION=$1; shift VERSION=$1; shift
ARCH=$1; shift ARCH=$1; shift
SIGN_WITH_KEY=$1; shift
if [ -z "$BIN_DIR" ] if [ -z "$BIN_DIR" ]
then then
@@ -28,12 +29,9 @@ then
exit exit
fi fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac } if [ "$SIGN_WITH_KEY" != "true" ]
if ! contains "$BIN_DIR" $ARCH
then then
echo "This script must be called with a Libation binaries for ${ARCH}." echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
exit
fi fi
BUNDLE=./Libation.app BUNDLE=./Libation.app
@@ -74,6 +72,15 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..." echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
echo "Moving Libation_DS_Store file..."
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
echo "Moving background.png file..."
mv $BUNDLE_MACOS/background.png ./background.png
echo "Moving background.png file..."
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/') PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH" echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
@@ -81,27 +88,45 @@ sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION" echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json') delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}" for n in "${delfiles[@]}"
do do
echo "Deleting $n" echo "Deleting $n"
rm $BUNDLE_MACOS/$n rm $BUNDLE_MACOS/$n
done done
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
echo "Signing executables in: $BUNDLE" all_identities=$(security find-identity -v -p codesigning)
codesign --force --deep -s - $BUNDLE identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
echo "Creating app bundle: $APP_FILE" if [ "$SIGN_WITH_KEY" == "true" ]; then
tar -czvf $APP_FILE $BUNDLE echo "Signing executables in: $BUNDLE"
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
codesign --verify --verbose "$BUNDLE"
else
echo "Signing with empty key: $BUNDLE"
codesign --force --deep -s - $BUNDLE
fi
mkdir bundle echo "Creating app disk image: $DMG_FILE"
echo "moving to ./bundle/$APP_FILE" mkdir Libation
mv $APP_FILE ./bundle/$APP_FILE mv $BUNDLE ./Libation/$BUNDLE
mv Libation_DS_Store Libation/.DS_Store
mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
# Create a .DS_Store by:
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
# - Open the folder and edit it to your liking.
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
rm -r $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing $DMG_FILE"
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
fi
echo "Done!" echo "Done!"
+1 -9
View File
@@ -28,14 +28,6 @@ then
exit exit
fi fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
BASEDIR=$(pwd) BASEDIR=$(pwd)
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json') delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
@@ -62,7 +54,7 @@ License: GPLv3+
URL: https://github.com/rmcrackan/Libation URL: https://github.com/rmcrackan/Libation
Source0: https://github.com/rmcrackan/Libation Source0: https://github.com/rmcrackan/Libation
Requires: bash Requires: bash gtk3 webkit2gtk4.1
%define __os_install_post %{nil} %define __os_install_post %{nil}
+2 -2
View File
@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -13,7 +13,7 @@
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.0.1.2" /> <PackageReference Include="AAXClean.Codecs" Version="2.1.2.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+33 -18
View File
@@ -1,19 +1,21 @@
using AAXClean; using AAXClean;
using System; using System;
using System.IO;
using System.Linq; using System.Linq;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{ {
public event EventHandler<AppleTags> RetrievedMetadata; public event EventHandler<AppleTags>? RetrievedMetadata;
public Mp4File AaxFile { get; private set; } public Mp4File? AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; } protected Mp4Operation? AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { } : base(outDirectory, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary> /// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt) public override void SetCoverArt(byte[] coverArt)
@@ -25,18 +27,19 @@ namespace AaxDecrypter
public override async Task CancelAsync() public override async Task CancelAsync()
{ {
IsCanceled = true; await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask); await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
} }
private Mp4File Open() private Mp4File Open()
{ {
if (DownloadOptions.InputType is FileType.Dash) if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
else if (DownloadOptions.InputType is FileType.Dash)
{ {
//We may have multiple keys , so use the key whose key ID matches //We may have multiple keys , so use the key whose key ID matches
//the dash files default Key ID. //the dash files default Key ID.
var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray(); var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
var dash = new DashFile(InputFileStream); var dash = new DashFile(InputFileStream);
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID); var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
@@ -44,26 +47,38 @@ namespace AaxDecrypter
if (kidIndex == -1) if (kidIndex == -1)
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}"); throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex]; keys[0] = keys[kidIndex];
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1; var keyId = keys[kidIndex].KeyPart1;
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2; var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
dash.SetDecryptionKey(keyId, key); dash.SetDecryptionKey(keyId, key);
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
return dash; return dash;
} }
else if (DownloadOptions.InputType is FileType.Aax) else if (DownloadOptions.InputType is FileType.Aax)
{ {
var aax = new AaxFile(InputFileStream); var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1); var key = keys[0].KeyPart1;
aax.SetDecryptionKey(keys[0].KeyPart1);
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
return aax; return aax;
} }
else if (DownloadOptions.InputType is FileType.Aaxc) else if (DownloadOptions.InputType is FileType.Aaxc)
{ {
var aax = new AaxFile(InputFileStream); var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2); var key = keys[0].KeyPart1;
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
return aax; return aax;
} }
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown."); else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
void WriteKeyFile(string contents)
{
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
File.WriteAllText(keyFile, contents + Environment.NewLine);
OnTempFileCreated(new(keyFile));
}
} }
protected bool Step_GetMetadata() protected bool Step_GetMetadata()
@@ -111,15 +126,15 @@ namespace AaxDecrypter
if (DownloadOptions.SeriesName is string series) if (DownloadOptions.SeriesName is string series)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series); AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
if (DownloadOptions.SeriesNumber is float part) if (DownloadOptions.SeriesNumber is string part)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString()); AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
} }
OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged); OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor); OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
OnRetrievedNarrators(AaxFile.AppleTags.Narrator); OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
OnRetrievedCoverArt(AaxFile.AppleTags.Cover); OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
OnInitialized();
return !IsCanceled; return !IsCanceled;
} }
@@ -5,20 +5,20 @@ using System;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
{ {
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3); private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
private FileStream workingFileStream; private FileStream? workingFileStream;
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) : base(outDirectory, cacheDirectory, dlOptions)
{ {
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split"; AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata); AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
} }
protected override void OnInitialized() protected override void OnInitialized()
@@ -59,6 +59,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
*/ */
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync() protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{ {
if (AaxFile is null) return false;
var chapters = DownloadOptions.ChapterInfo.Chapters; var chapters = DownloadOptions.ChapterInfo.Chapters;
// Ensure split files are at least minChapterLength in duration. // Ensure split files are at least minChapterLength in duration.
@@ -83,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea
try try
{ {
await (AaxConversion = decryptMultiAsync(splitChapters)); await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
if (AaxConversion.IsCompletedSuccessfully) if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(workingFileStream?.Name); await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
return AaxConversion.IsCompletedSuccessfully; return AaxConversion.IsCompletedSuccessfully;
} }
@@ -97,17 +98,17 @@ That naming may not be desirable for everyone, but it's an easy change to instea
} }
} }
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters) private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
{ {
var chapterCount = 0; var chapterCount = 0;
return return
DownloadOptions.OutputFormat == OutputFormat.M4b DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMultiMp4aAsync ? aaxFile.ConvertToMultiMp4aAsync
( (
splitChapters, splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback) newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
) )
: AaxFile.ConvertToMultiMp3Async : aaxFile.ConvertToMultiMp3Async
( (
splitChapters, splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback), newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
@@ -116,33 +117,32 @@ That naming may not be desirable for everyone, but it's an easy change to instea
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback) void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
{ {
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
MultiConvertFileProperties props = new() MultiConvertFileProperties props = new()
{ {
OutputFileName = OutputFileName, OutputFileName = newTempFile.FilePath,
PartsPosition = currentChapter, PartsPosition = currentChapter,
PartsTotal = splitChapters.Count, PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title, Title = newSplitCallback.Chapter?.Title,
}; };
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props); newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props); newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
newSplitCallback.TrackNumber = currentChapter; newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count; newSplitCallback.TrackCount = splitChapters.Count;
OnFileCreated(workingFileStream.Name); OnTempFileCreated(newTempFile with { PartProperties = props });
} }
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties) FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{ {
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties); FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
FileUtility.SaferDelete(fileName); return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
} }
} }
private Mp4Operation moveMoovToBeginning(string filename) private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
{ {
if (DownloadOptions.OutputFormat is OutputFormat.M4b if (DownloadOptions.OutputFormat is OutputFormat.M4b
&& DownloadOptions.MoveMoovToBeginning && DownloadOptions.MoveMoovToBeginning
@@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
{ {
return Mp4File.RelocateMoovAsync(filename); return Mp4File.RelocateMoovAsync(filename);
} }
else return Mp4Operation.FromCompleted(AaxFile); else return Mp4Operation.FromCompleted(aaxFile);
} }
} }
} }
@@ -6,13 +6,16 @@ using System;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{ {
private readonly AverageSpeed averageSpeed = new(); private readonly AverageSpeed averageSpeed = new();
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) private TempFile? outputTempFile;
: base(outFileName, cacheDirectory, dlOptions)
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{ {
var step = 1; var step = 1;
@@ -21,7 +24,6 @@ namespace AaxDecrypter
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b) if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov; AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync; AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
} }
@@ -39,14 +41,16 @@ namespace AaxDecrypter
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync() protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{ {
FileUtility.SaferDelete(OutputFileName); if (AaxFile is null) return false;
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
FileUtility.SaferDelete(outputTempFile.FilePath);
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite); using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName); OnTempFileCreated(outputTempFile);
try try
{ {
await (AaxConversion = decryptAsync(outputFile)); await (AaxConversion = decryptAsync(AaxFile, outputFile));
return AaxConversion.IsCompletedSuccessfully; return AaxConversion.IsCompletedSuccessfully;
} }
@@ -58,14 +62,15 @@ namespace AaxDecrypter
private async Task<bool> Step_MoveMoov() private async Task<bool> Step_MoveMoov()
{ {
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName); if (outputTempFile is null) return false;
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate; AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion; await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate; AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
return AaxConversion.IsCompletedSuccessfully; return AaxConversion.IsCompletedSuccessfully;
} }
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e) private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
{ {
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds); averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
@@ -84,20 +89,20 @@ namespace AaxDecrypter
}); });
} }
private Mp4Operation decryptAsync(Stream outputFile) private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 => DownloadOptions.OutputFormat == OutputFormat.Mp3
? AaxFile.ConvertToMp3Async ? aaxFile.ConvertToMp3Async
( (
outputFile, outputFile,
DownloadOptions.LameConfig, DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo DownloadOptions.ChapterInfo
) )
: DownloadOptions.FixupFile : DownloadOptions.FixupFile
? AaxFile.ConvertToMp4aAsync ? aaxFile.ConvertToMp4aAsync
( (
outputFile, outputFile,
DownloadOptions.ChapterInfo DownloadOptions.ChapterInfo
) )
: AaxFile.ConvertToMp4aAsync(outputFile); : aaxFile.ConvertToMp4aAsync(outputFile);
} }
} }
+71 -105
View File
@@ -6,55 +6,60 @@ using System;
using System.IO; using System.IO;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
public enum OutputFormat { M4b, Mp3 } public enum OutputFormat { M4b, Mp3 }
public abstract class AudiobookDownloadBase public abstract class AudiobookDownloadBase
{ {
public event EventHandler<string> RetrievedTitle; public event EventHandler<string?>? RetrievedTitle;
public event EventHandler<string> RetrievedAuthors; public event EventHandler<string?>? RetrievedAuthors;
public event EventHandler<string> RetrievedNarrators; public event EventHandler<string?>? RetrievedNarrators;
public event EventHandler<byte[]> RetrievedCoverArt; public event EventHandler<byte[]?>? RetrievedCoverArt;
public event EventHandler<DownloadProgress> DecryptProgressUpdate; public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining; public event EventHandler<TimeSpan>? DecryptTimeRemaining;
public event EventHandler<string> FileCreated; public event EventHandler<TempFile>? TempFileCreated;
public bool IsCanceled { get; protected set; } public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new(); protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputFileName { get; } protected string OutputDirectory { get; }
public IDownloadOptions DownloadOptions { get; } public IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream; protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position; protected virtual long InputFilePosition
{
get
{
//Use try/catch instread of checking CanRead to avoid
//a race with the background download completing
//between the check and the Position call.
try { return InputFileStream.Position; }
catch { return InputFileStream.Length; }
}
}
private bool downloadFinished; private bool downloadFinished;
private readonly NetworkFileStreamPersister nfsPersister; private NetworkFileStreamPersister? m_nfsPersister;
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
private readonly DownloadProgress zeroProgress; private readonly DownloadProgress zeroProgress;
private readonly string jsonDownloadState; private readonly string jsonDownloadState;
private readonly string tempFilePath; private readonly string tempFilePath;
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions) protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
{ {
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName)); OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
var outDir = Path.GetDirectoryName(OutputFileName); if (!Directory.Exists(OutputDirectory))
if (!Directory.Exists(outDir)) Directory.CreateDirectory(OutputDirectory);
Directory.CreateDirectory(outDir);
if (!Directory.Exists(cacheDirectory)) if (!Directory.Exists(cacheDirectory))
Directory.CreateDirectory(cacheDirectory); Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json"))); jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc"); tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
nfsPersister = OpenNetworkFileStream();
zeroProgress = new DownloadProgress zeroProgress = new DownloadProgress
{ {
BytesReceived = 0, BytesReceived = 0,
@@ -65,24 +70,30 @@ namespace AaxDecrypter
OnDecryptProgressUpdate(zeroProgress); OnDecryptProgressUpdate(zeroProgress);
} }
protected TempFile GetNewTempFilePath(string extension)
{
extension = FileUtility.GetStandardizedExtension(extension);
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
return new(path, extension);
}
public async Task<bool> RunAsync() public async Task<bool> RunAsync()
{ {
await InputFileStream.BeginDownloadingAsync(); await InputFileStream.BeginDownloadingAsync();
var progressTask = Task.Run(reportProgress); var progressTask = Task.Run(reportProgress);
AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync(); (bool success, var elapsed) = await AsyncSteps.RunAsync();
//Stop the downloader so it doesn't keep running in the background. //Stop the downloader so it doesn't keep running in the background.
if (!success) if (!success)
nfsPersister.Dispose(); NfsPersister.Dispose();
await progressTask; await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed; var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime."); Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
nfsPersister.Dispose(); NfsPersister.Dispose();
return success; return success;
async Task reportProgress() async Task reportProgress()
@@ -120,54 +131,52 @@ namespace AaxDecrypter
} }
} }
public abstract Task CancelAsync(); public virtual Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync(); protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt) { } public virtual void SetCoverArt(byte[] coverArt) { }
protected void OnRetrievedTitle(string? title)
protected void OnRetrievedTitle(string title)
=> RetrievedTitle?.Invoke(this, title); => RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors) protected void OnRetrievedAuthors(string? authors)
=> RetrievedAuthors?.Invoke(this, authors); => RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators) protected void OnRetrievedNarrators(string? narrators)
=> RetrievedNarrators?.Invoke(this, narrators); => RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt) protected void OnRetrievedCoverArt(byte[]? coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt); => RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress) protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress); => DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining) protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining); => DecryptTimeRemaining?.Invoke(this, timeRemaining);
protected void OnFileCreated(string path) public void OnTempFileCreated(TempFile path)
=> FileCreated?.Invoke(this, path); => TempFileCreated?.Invoke(this, path);
protected virtual void FinalizeDownload() protected virtual void FinalizeDownload()
{ {
nfsPersister?.Dispose(); NfsPersister.Dispose();
downloadFinished = true; downloadFinished = true;
} }
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
{
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
}
protected async Task<bool> Step_CreateCueAsync() protected async Task<bool> Step_CreateCueAsync()
{ {
if (!DownloadOptions.CreateCueSheet) return !IsCanceled; if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
if (DownloadOptions.ChapterInfo.Count <= 1)
{
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
return !IsCanceled;
}
// not a critical step. its failure should not prevent future steps from running // not a critical step. its failure should not prevent future steps from running
try try
{ {
var path = Path.ChangeExtension(OutputFileName, ".cue"); var tempFile = GetNewTempFilePath(".cue");
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo)); await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
OnFileCreated(path); OnTempFileCreated(tempFile);
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -176,58 +185,9 @@ namespace AaxDecrypter
return !IsCanceled; return !IsCanceled;
} }
private async Task<bool> CleanupAsync()
{
if (IsCanceled) return false;
FileUtility.SaferDelete(jsonDownloadState);
if (DownloadOptions.DecryptionKeys != null &&
DownloadOptions.RetainEncryptedFile &&
DownloadOptions.InputType is AAXClean.FileType fileType)
{
//Write aax decryption key
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
FileUtility.SaferDelete(keyPath);
string aaxPath;
if (fileType is AAXClean.FileType.Aax)
{
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
}
else if (fileType is AAXClean.FileType.Aaxc)
{
await File.WriteAllTextAsync(keyPath,
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
}
else if (fileType is AAXClean.FileType.Dash)
{
await File.WriteAllTextAsync(keyPath,
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
}
else
throw new InvalidOperationException($"Unknown file type: {fileType}");
if (tempFilePath != aaxPath)
FileUtility.SaferMove(tempFilePath, aaxPath);
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(tempFilePath);
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream() private NetworkFileStreamPersister OpenNetworkFileStream()
{ {
NetworkFileStreamPersister nfsp = default; NetworkFileStreamPersister? nfsp = default;
try try
{ {
if (!File.Exists(jsonDownloadState)) if (!File.Exists(jsonDownloadState))
@@ -248,8 +208,14 @@ namespace AaxDecrypter
} }
finally finally
{ {
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent; //nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps; if (nfsp is not null)
{
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
OnTempFileCreated(new(jsonDownloadState));
}
} }
NetworkFileStreamPersister newNetworkFilePersister() NetworkFileStreamPersister newNetworkFilePersister()
+2 -7
View File
@@ -1,6 +1,5 @@
using AAXClean; using AAXClean;
using System; using System;
using System.Threading.Tasks;
#nullable enable #nullable enable
namespace AaxDecrypter namespace AaxDecrypter
@@ -16,6 +15,7 @@ namespace AaxDecrypter
KeyPart2 = keyPart2; KeyPart2 = keyPart2;
} }
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null) public KeyData(string keyPart1, string? keyPart2 = null)
{ {
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1)); ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
@@ -33,11 +33,8 @@ namespace AaxDecrypter
KeyData[]? DecryptionKeys { get; } KeyData[]? DecryptionKeys { get; }
TimeSpan RuntimeLength { get; } TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; } OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; } bool StripUnabridged { get; }
bool CreateCueSheet { get; } bool CreateCueSheet { get; }
bool DownloadClipsBookmarks { get; }
long DownloadSpeedBps { get; } long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; } ChapterInfo ChapterInfo { get; }
bool FixupFile { get; } bool FixupFile { get; }
@@ -47,14 +44,12 @@ namespace AaxDecrypter
string? Publisher { get; } string? Publisher { get; }
string? Language { get; } string? Language { get; }
string? SeriesName { get; } string? SeriesName { get; }
float? SeriesNumber { get; } string? SeriesNumber { get; }
NAudio.Lame.LameConfig? LameConfig { get; } NAudio.Lame.LameConfig? LameConfig { get; }
bool Downsample { get; } bool Downsample { get; }
bool MatchSourceBitrate { get; } bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; } bool MoveMoovToBeginning { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitle(MultiConvertFileProperties props); string GetMultipartTitle(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarksAsync(string fileName);
public FileType? InputType { get; } public FileType? InputType { get; }
} }
} }
+14 -3
View File
@@ -100,6 +100,12 @@ namespace AaxDecrypter
Position = WritePosition Position = WritePosition
}; };
if (_writeFile.Length < WritePosition)
{
_writeFile.Dispose();
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
}
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite); _readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
SetUriForSameFile(uri); SetUriForSameFile(uri);
@@ -203,6 +209,12 @@ namespace AaxDecrypter
} }
} }
} }
catch (Exception ex)
{
//Don't throw from DownloadTask.
//This task gets awaited in Dispose() and we don't want to have an unhandled exception there.
Serilog.Log.Error(ex, "An error was encountered during the download process.");
}
finally finally
{ {
_writeFile.Dispose(); _writeFile.Dispose();
@@ -300,7 +312,7 @@ namespace AaxDecrypter
if (WritePosition > endPosition) if (WritePosition > endPosition)
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}).");
} }
catch (TaskCanceledException) catch (OperationCanceledException)
{ {
Serilog.Log.Information("Download was cancelled"); Serilog.Log.Information("Download was cancelled");
} }
@@ -396,7 +408,7 @@ namespace AaxDecrypter
*/ */
protected override void Dispose(bool disposing) protected override void Dispose(bool disposing)
{ {
if (disposing && !disposed) if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
{ {
_cancellationSource.Cancel(); _cancellationSource.Cancel();
DownloadTask?.GetAwaiter().GetResult(); DownloadTask?.GetAwaiter().GetResult();
@@ -407,7 +419,6 @@ namespace AaxDecrypter
OnUpdate(waitForWrite: true); OnUpdate(waitForWrite: true);
} }
disposed = true;
base.Dispose(disposing); base.Dispose(disposing);
} }
+17
View File
@@ -0,0 +1,17 @@
using FileManager;
#nullable enable
namespace AaxDecrypter;
public record TempFile
{
public LongPath FilePath { get; init; }
public string Extension { get; }
public MultiConvertFileProperties? PartProperties { get; init; }
public TempFile(LongPath filePath, string? extension = null)
{
FilePath = filePath;
extension ??= System.IO.Path.GetExtension(filePath);
Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant();
}
}
@@ -1,27 +1,18 @@
using FileManager; using FileManager;
using System;
using System.Threading.Tasks; using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter namespace AaxDecrypter
{ {
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
{ {
protected override long InputFilePosition => InputFileStream.WritePosition; protected override long InputFilePosition => InputFileStream.WritePosition;
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic) public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic) : base(outDirectory, cacheDirectory, dlLic)
{ {
AsyncSteps.Name = "Download Unencrypted Audiobook"; AsyncSteps.Name = "Download Unencrypted Audiobook";
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync; AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync; AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
}
public override Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
} }
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync() protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
@@ -33,8 +24,9 @@ namespace AaxDecrypter
else else
{ {
FinalizeDownload(); FinalizeDownload();
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName); var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
OnFileCreated(OutputFileName); FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
OnTempFileCreated(tempFile);
return true; return true;
} }
} }
+4 -5
View File
@@ -1,16 +1,15 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
<Version>12.4.2.2</Version> <Version>13.0.0.1</Version>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" /> <PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks --> <!-- Do not remove unused Serilog.Sinks -->
<!-- Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. --> <!-- Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" /> <PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" /> <PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
<PackageReference Include="Serilog.Sinks.ZipFile" Version="3.1.1" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" /> <ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
+91 -36
View File
@@ -24,7 +24,8 @@ namespace AppScaffolding
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64, LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64, MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64, LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64 MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64,
WindowsAvalonia_Arm64 = OS.Windows | Variety.Chardonnay | Architecture.Arm64,
} }
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it // I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
@@ -79,8 +80,18 @@ namespace AppScaffolding
} }
/// <summary>most migrations go in here</summary> /// <summary>most migrations go in here</summary>
public static void RunPostConfigMigrations(Configuration config) public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
{ {
if (ephemeralSettings)
{
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
config.LoadEphemeralSettings(settings);
}
else
{
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
}
DeleteOpenSqliteFiles(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists(); AudibleApiStorage.EnsureAccountsSettingsFileExists();
// //
@@ -93,6 +104,39 @@ namespace AppScaffolding
Migrations.migrate_to_v12_0_1(config); Migrations.migrate_to_v12_0_1(config);
} }
/// <summary>
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
/// These file may or may not cause libation to hang on CreateContext,
/// so try our luck by swallowing any exceptions and continuing.
/// </summary>
private static void DeleteOpenSqliteFiles(Configuration config)
{
var walFile = SqliteStorage.DatabasePath + "-wal";
var shmFile = SqliteStorage.DatabasePath + "-shm";
if (File.Exists(walFile))
{
try
{
FileManager.FileUtility.SaferDelete(walFile);
}
catch(Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {@WalFile}", walFile);
}
}
if (File.Exists(shmFile))
{
try
{
FileManager.FileUtility.SaferDelete(shmFile);
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {@ShmFile}", shmFile);
}
}
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary> /// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Variety variety, Configuration config) public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
{ {
@@ -115,11 +159,22 @@ namespace AppScaffolding
{ {
if (config.GetObject("Serilog") is JObject serilog) if (config.GetObject("Serilog") is JObject serilog)
{ {
if (serilog["WriteTo"] is JArray sinks && sinks.FirstOrDefault(s => s["Name"].Value<string>() is "File") is JToken fileSink) bool fileChanged = false;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
{ {
fileSink["Name"] = "ZipFile"; zipFileSink["Name"] = "File";
config.SetNonString(serilog.DeepClone(), "Serilog"); fileChanged = true;
} }
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
{
fileSinkArgs["hooks"] = hooks;
fileChanged = true;
}
if (fileChanged)
config.SetNonString(serilog.DeepClone(), "Serilog");
return; return;
} }
@@ -129,17 +184,17 @@ namespace AppScaffolding
{ "WriteTo", new JArray { "WriteTo", new JArray
{ {
// ABOUT SINKS // ABOUT SINKS
// Only ZipFile sink is currently used. By user request (June 2024) others packages are included for experimental use. // Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved // new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
new JObject new JObject
{ {
{ "Name", "ZipFile" }, { "Name", "File" },
{ "Args", { "Args",
new JObject new JObject
{ {
// for this sink to work, a path must be provided. we override this below // for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "_Log.log") }, { "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
{ "rollingInterval", "Month" }, { "rollingInterval", "Month" },
// Serilog template formatting examples // Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -147,7 +202,8 @@ namespace AppScaffolding
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}"; // - 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 // 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 // {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}" } { "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
} }
} }
} }
@@ -222,6 +278,7 @@ namespace AppScaffolding
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers"); //Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
} }
#nullable enable
private static void logStartupState(Configuration config) private static void logStartupState(Configuration config)
{ {
#if DEBUG #if DEBUG
@@ -235,9 +292,11 @@ namespace AppScaffolding
// begin logging session with a form feed // begin logging session with a form feed
Log.Logger.Information("\r\n\f"); Log.Logger.Information("\r\n\f");
static int fileCount(FileManager.LongPath longPath) static int fileCount(FileManager.LongPath? longPath)
{ {
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); } if (longPath is null)
return -1;
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
catch { return -1; } catch { return -1; }
} }
@@ -274,11 +333,11 @@ namespace AppScaffolding
disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value), disableIPv6 = AppContext.TryGetSwitch("System.Net.DisableIPv6", out bool disableIPv6Value),
}); });
if (InteropFactory.InteropFunctionsType is null) if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null"); Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
} }
#nullable restore
private static void wireUpSystemEvents(Configuration configuration) private static void wireUpSystemEvents(Configuration configuration)
{ {
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks) LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks); => SearchEngineCommands.FullReIndex(libraryBooks);
@@ -290,33 +349,24 @@ namespace AppScaffolding
public static UpgradeProperties GetLatestRelease() public static UpgradeProperties GetLatestRelease()
{ {
// timed out // timed out
(var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10)); (var version, var latest, var zip) = getLatestRelease(TimeSpan.FromSeconds(10));
if (latest is null || zip is null) if (version is null || latest is null || zip is null)
return null;
var latestVersionString = latest.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var latestRelease))
return null;
// we're up to date
if (latestRelease <= BuildVersion)
return null; return null;
// we have an update // we have an update
var zipUrl = zip?.BrowserDownloadUrl; var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new Log.Logger.Information("Update available: {@DebugInfo}", new
{ {
latestRelease = latestRelease.ToString(), latestRelease = version.ToString(),
latest.HtmlUrl, latest.HtmlUrl,
zipUrl zipUrl
}); });
return new(zipUrl, latest.HtmlUrl, zip.Name, latestRelease, latest.Body); return new(zipUrl, latest.HtmlUrl, zip.Name, version, latest.Body);
} }
private static (Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout) private static (Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset) getLatestRelease(TimeSpan timeout)
{ {
try try
{ {
@@ -330,15 +380,23 @@ namespace AppScaffolding
{ {
Log.Logger.Error(aggEx, "Checking for new version too often"); Log.Logger.Error(aggEx, "Checking for new version too often");
} }
return (null, null); return (null, null, null);
} }
private static async System.Threading.Tasks.Task<(Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease() private static async System.Threading.Tasks.Task<(Version releaseVersion, Octokit.Release, Octokit.ReleaseAsset)> getLatestRelease()
{ {
const string ownerAccount = "rmcrackan"; const string ownerAccount = "rmcrackan";
const string repoName = "Libation"; const string repoName = "Libation";
var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName)); var gitHubClient = new Octokit.GitHubClient(new Octokit.ProductHeaderValue(repoName));
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
//Ensure that latest release is greater than the current version
var latestVersionString = latestRelease.TagName.Trim('v');
if (!Version.TryParse(latestVersionString, out var releaseVersion) || releaseVersion <= BuildVersion)
return (null, null, null);
//Download the release index //Download the release index
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json"); var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts)); var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
@@ -356,10 +414,7 @@ namespace AppScaffolding
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase); var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
//https://docs.github.com/en/rest/releases/releases?apiVersion=2022-11-28#get-the-latest-release return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
var latestRelease = await gitHubClient.Repository.Release.GetLatest(ownerAccount, repoName);
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
} }
} }
@@ -425,8 +480,8 @@ namespace AppScaffolding
const string FILENAME_V1 = "FileLocations.json"; const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json"; const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1); var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2); var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1)) if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{ {
+25 -26
View File
@@ -7,6 +7,7 @@ using LibationFileManager;
using Newtonsoft.Json; using Newtonsoft.Json;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
#nullable enable
namespace AppScaffolding namespace AppScaffolding
{ {
/// <summary> /// <summary>
@@ -20,21 +21,21 @@ namespace AppScaffolding
/// </summary> /// </summary>
public static class UNSAFE_MigrationHelper public static class UNSAFE_MigrationHelper
{ {
public static string SettingsDirectory public static string? SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null => !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
? null ? null
: value; : value;
#region appsettings.json #region appsettings.json
public static bool APPSETTINGS_TryGet(string key, out string value) public static bool APPSETTINGS_TryGet(string key, out string? value)
{ {
bool success = false; bool success = false;
JToken val = null; JToken? val = null;
process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false); process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null; value = success ? val?.Value<string>() : null;
return success; return success;
} }
@@ -59,7 +60,10 @@ namespace AppScaffolding
/// <param name="save">True: save if contents changed. False: no not attempt save</param> /// <param name="save">True: save if contents changed. False: no not attempt save</param>
private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true) private static void process_APPSETTINGS_Json(Action<JObject> action, bool save = true)
{ {
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile); if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
return;
var startingContents = File.ReadAllText(appSettingsFile);
JObject jObj; JObject jObj;
try try
@@ -82,40 +86,37 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact)) if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return; return;
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented); File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100); System.Threading.Thread.Sleep(100);
} }
#endregion #endregion
#region Settings.json #region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON); public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static bool Settings_TryGet(string key, out string value) public static bool Settings_TryGet(string key, out string? value)
{ {
bool success = false; bool success = false;
JToken val = null; JToken? val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false); process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null; value = success ? val?.Value<string>() : null;
return success; return success;
} }
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType) public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{ {
JToken val = null; JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false); process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType; return val?.Type == jTokenType;
} }
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value) public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
{ {
JToken val = null; JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false); process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
@@ -157,10 +158,10 @@ namespace AppScaffolding
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array)) if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false; return false;
JArray array = null; JArray? array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath)); process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
length = array.Count; length = array?.Count ?? 0;
return true; return true;
} }
@@ -171,8 +172,7 @@ namespace AppScaffolding
process_SettingsJson(jObj => process_SettingsJson(jObj =>
{ {
var array = (JArray)jObj.SelectToken(jsonPath); (jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
array.Add(newValue);
}); });
} }
@@ -200,8 +200,7 @@ namespace AppScaffolding
process_SettingsJson(jObj => process_SettingsJson(jObj =>
{ {
var array = (JArray)jObj.SelectToken(jsonPath); if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
if (position < array.Count)
array.RemoveAt(position); array.RemoveAt(position);
}); });
} }
@@ -228,7 +227,7 @@ namespace AppScaffolding
private static void process_SettingsJson(Action<JObject> action, bool save = true) private static void process_SettingsJson(Action<JObject> action, bool save = true)
{ {
// only insert if not exists // only insert if not exists
if (!SettingsJson_Exists) if (!File.Exists(SettingsJsonPath))
return; return;
var startingContents = File.ReadAllText(SettingsJsonPath); var startingContents = File.ReadAllText(SettingsJsonPath);
@@ -260,7 +259,7 @@ namespace AppScaffolding
#endregion #endregion
#region LibationContext.db #region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db"; public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT); public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile); public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion #endregion
} }
@@ -1,17 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="CsvHelper" Version="33.0.1" /> <PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="NPOI" Version="2.7.3" /> <PackageReference Include="ClosedXML" Version="0.105.0" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" /> <ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" /> <ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
<ProjectReference Include="..\DataLayer.Postgres\DataLayer.Postgres.csproj" />
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
</ItemGroup> </ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -69,10 +69,10 @@ namespace ApplicationServices
return Count; return Count;
} }
public void Execute() public async Task ExecuteAsync()
{ {
foreach (var a in actionSets) foreach (var a in actionSets)
a.LibraryBooks.UpdateBookStatus(a.newStatus); await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
} }
} }
} }
+31 -12
View File
@@ -1,21 +1,40 @@
using System; using DataLayer;
using System.Collections.Generic;
using DataLayer;
using LibationFileManager; using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices namespace ApplicationServices
{ {
public static class DbContexts public static class DbContexts
{ {
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary> /// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext() public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString); {
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
context.Database.Migrate();
return context;
}
/// <summary>Use for full library querying. No lazy loading</summary> /// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false) public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{ {
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
}
public static List<LibraryBook> GetDeletedLibraryBooks()
{
using var context = GetContext();
return context.GetDeletedLibraryBooks();
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
{
using var context = GetContext(); using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents); return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
} }
} }
} }
+147 -152
View File
@@ -1,9 +1,4 @@
using System; using AudibleApi;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AudibleApi;
using AudibleUtilities; using AudibleUtilities;
using DataLayer; using DataLayer;
using Dinah.Core; using Dinah.Core;
@@ -11,8 +6,14 @@ using Dinah.Core.Logging;
using DtoImporterService; using DtoImporterService;
using FileManager; using FileManager;
using LibationFileManager; using LibationFileManager;
using Microsoft.Extensions.DependencyModel;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using Serilog; using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static DtoImporterService.PerfLogger; using static DtoImporterService.PerfLogger;
#nullable enable #nullable enable
@@ -70,7 +71,7 @@ namespace ApplicationServices
} }
catch (AudibleApi.Authentication.LoginFailedException lfEx) catch (AudibleApi.Authentication.LoginFailedException lfEx)
{ {
lfEx.SaveFiles(Configuration.Instance.LibationFiles); lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// 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:
@@ -141,16 +142,16 @@ namespace ApplicationServices
return default; return default;
Log.Logger.Information("Begin long-running import"); Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}"); logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await importIntoDbAsync(importItems); newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(importIntoDbAsync)}"); logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}"); Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount); return (totalCount, newCount);
} }
catch (AudibleApi.Authentication.LoginFailedException lfEx) catch (AudibleApi.Authentication.LoginFailedException lfEx)
{ {
lfEx.SaveFiles(Configuration.Instance.LibationFiles); lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// 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:
@@ -180,7 +181,8 @@ namespace ApplicationServices
} }
} }
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
{ {
ArgumentValidator.EnsureNotNull(item, "item"); ArgumentValidator.EnsureNotNull(item, "item");
ArgumentValidator.EnsureNotNull(accountId, "accountId"); ArgumentValidator.EnsureNotNull(accountId, "accountId");
@@ -203,35 +205,23 @@ namespace ApplicationServices
return 0; return 0;
} }
using var context = DbContexts.GetContext(); return DoDbSizeChangeOperation(ctx =>
{
var bookImporter = new BookImporter(ctx);
bookImporter.Import(importItems);
var book = ctx.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId);
var bookImporter = new BookImporter(context); if (book is null)
await Task.Run(() => bookImporter.Import(importItems)); {
var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId)); book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
ctx.LibraryBooks.Add(book);
if (book is null) }
{ else
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId); {
context.LibraryBooks.Add(book); book.AbsentFromLastScan = false;
} }
else });
{ }
book.AbsentFromLastScan = false;
}
try
{
int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0)
await Task.Run(() => finalizeLibrarySizeChange(context));
return qtyChanged;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
return 0;
}
}
private static LogArchiver? openLogArchive(string? archivePath) private static LogArchiver? openLogArchive(string? archivePath)
{ {
@@ -268,7 +258,7 @@ namespace ApplicationServices
await using LogArchiver? archiver await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled() = Log.Logger.IsDebugEnabled()
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip")) ? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip"))
: default; : default;
archiver?.DeleteAllButNewestN(20); archiver?.DeleteAllButNewestN(20);
@@ -309,7 +299,7 @@ namespace ApplicationServices
try try
{ {
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes); var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}"); logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
@@ -347,23 +337,21 @@ namespace ApplicationServices
} }
} }
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems) private static async Task<int> ImportIntoDbAsync(List<ImportItem> importItems) => await Task.Run(() => importIntoDb(importItems));
private static int importIntoDb(List<ImportItem> importItems)
{ {
logTime("importIntoDbAsync -- pre db"); logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext();
var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
// this is any changes at all to the database, not just new books int newCount = 0;
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange(context));
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return newCount; DoDbSizeChangeOperation(ctx =>
} {
var libraryBookImporter = new LibraryBookImporter(ctx);
newCount = libraryBookImporter.Import(importItems);
logTime("importIntoDbAsync -- post Import()");
});
return newCount;
}
public static int SaveContext(LibationContext context) public static int SaveContext(LibationContext context)
{ {
@@ -389,57 +377,58 @@ namespace ApplicationServices
#region remove/restore books #region remove/restore books
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove)); public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks) private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
{ {
try if (removeLibraryBooks is null || !removeLibraryBooks.Any())
{ return 0;
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges() // Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks) foreach (var lb in removeLibraryBooks)
{ {
lb.IsDeleted = true; lb.IsDeleted = true;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
} }
});
}
var qtyChanges = context.SaveChanges(); public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
if (qtyChanges > 0) private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks)
finalizeLibrarySizeChange(context); {
if (libraryBooks is null || !libraryBooks.Any())
return qtyChanges; return 0;
} try
{
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
}
catch (Exception ex) catch (Exception ex)
{ {
Log.Logger.Error(ex, "Error removing books"); Log.Logger.Error(ex, "Error restoring books");
throw; throw;
} }
} }
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks) public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
{ private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
try {
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{ {
if (libraryBooks is null || !libraryBooks.Any()) return DoDbSizeChangeOperation(ctx =>
return 0; {
ctx.LibraryBooks.RemoveRange(libraryBooks);
using var context = DbContexts.GetContext(); ctx.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
});
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
return qtyChanges;
} }
catch (Exception ex) catch (Exception ex)
{ {
@@ -448,36 +437,40 @@ namespace ApplicationServices
} }
} }
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks) static int DoDbSizeChangeOperation(Action<LibationContext> action)
{ {
try try
{ {
if (libraryBooks is null || !libraryBooks.Any()) int qtyChanges;
return 0; List<LibraryBook>? library;
using var context = DbContexts.GetContext(); using (var context = DbContexts.GetContext())
{
action?.Invoke(context);
context.LibraryBooks.RemoveRange(libraryBooks); qtyChanges = SaveContext(context);
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book)); logTime("importIntoDbAsync -- post SaveChanges");
library = qtyChanges == 0 ? null : context.GetLibrary_Flat_NoTracking(includeParents: true);
}
var qtyChanges = context.SaveChanges(); if (library is not null)
if (qtyChanges > 0) finalizeLibrarySizeChange(library);
finalizeLibrarySizeChange(context); logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return qtyChanges; return qtyChanges;
} }
catch (Exception ex) catch (Exception ex)
{ {
Log.Logger.Error(ex, "Error restoring books"); Log.Logger.Error(ex, "Error performing DB Size change operation");
throw; throw;
} }
} }
#endregion #endregion
// call this whenever books are added or removed from library // call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange(LibationContext context) private static void finalizeLibrarySizeChange(List<LibraryBook> library)
{ {
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
LibrarySizeChanged?.Invoke(null, library); LibrarySizeChanged?.Invoke(null, library);
} }
@@ -490,21 +483,21 @@ namespace ApplicationServices
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted; public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details #region Update book details
public static int UpdateUserDefinedItem( public static async Task<int> UpdateUserDefinedItemAsync(
this LibraryBook lb, this LibraryBook lb,
string? tags = null, string? tags = null,
LiberatedStatus? bookStatus = null, LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null, LiberatedStatus? pdfStatus = null,
Rating? rating = null) Rating? rating = null)
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating); => await UpdateUserDefinedItemAsync([lb], tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem( public static async Task<int> UpdateUserDefinedItemAsync(
this IEnumerable<LibraryBook> lb, this IEnumerable<LibraryBook> lb,
string? tags = null, string? tags = null,
LiberatedStatus? bookStatus = null, LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null, LiberatedStatus? pdfStatus = null,
Rating? rating = null) Rating? rating = null)
=> updateUserDefinedItem( => await UpdateUserDefinedItemAsync(
lb, lb,
udi => { udi => {
// blank tags are expected. null tags are not // blank tags are expected. null tags are not
@@ -521,52 +514,54 @@ namespace ApplicationServices
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating); udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
}); });
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion) public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); }); => await lb.UpdateUserDefinedItemAsync(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus) public static async Task<int> UpdateBookStatusAsync(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus); => await libraryBook.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus) public static async Task<int> UpdateBookStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus); => await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus) public static async Task<int> UpdatePdfStatusAsync(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus)); => await libraryBook.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus) public static async Task<int> UpdatePdfStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus)); => await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdateTags(this LibraryBook libraryBook, string tags) public static async Task<int> UpdateTagsAsync(this LibraryBook libraryBook, string tags)
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags); => await libraryBook.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags) public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags); => await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) public static async Task<int> UpdateUserDefinedItemAsync(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> libraryBook.updateUserDefinedItem(action); => await UpdateUserDefinedItemAsync([libraryBook], action);
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> libraryBooks.updateUserDefinedItem(action);
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action); public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action) => Task.Run(() => libraryBooks.updateUserDefinedItem(action));
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
{ {
try try
{ {
if (libraryBooks is null || !libraryBooks.Any()) if (libraryBooks is null || !libraryBooks.Any())
return 0; return 0;
using var context = DbContexts.GetContext(); int qtyChanges;
using (var context = DbContexts.GetContext())
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{ {
action?.Invoke(book.Book.UserDefinedItem); // Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{
action?.Invoke(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem); var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified; udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry) if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified; ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
} }
var qtyChanges = context.SaveChanges(); qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0) if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks); BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
@@ -582,7 +577,7 @@ namespace ApplicationServices
// must be here instead of in db layer due to AaxcExists // must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book) public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus => book.AudioExists ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload : AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated; : LiberatedStatus.NotLiberated;
@@ -591,7 +586,7 @@ namespace ApplicationServices
// 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 record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable) public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable, IEnumerable<LibraryBook> LibraryBooks)
{ {
public int PendingBooks => booksNoProgress + booksDownloadedOnly; public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0; public bool HasPendingBooks => PendingBooks > 0;
@@ -650,7 +645,7 @@ namespace ApplicationServices
var pdfResults = libraryBooks var pdfResults = libraryBooks
.AsParallel() .AsParallel()
.Where(lb => lb.Book.HasPdf()) .Where(lb => lb.Book.HasPdf)
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) }) .Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
.ToList(); .ToList();
@@ -660,7 +655,7 @@ namespace ApplicationServices
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable }); Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable); return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
} }
} }
} }
+107 -107
View File
@@ -1,11 +1,12 @@
using System; using ClosedXML.Excel;
using System.Collections.Generic;
using System.Linq;
using CsvHelper; using CsvHelper;
using CsvHelper.Configuration.Attributes; using CsvHelper.Configuration.Attributes;
using DataLayer; using DataLayer;
using NPOI.XSSF.UserModel; using Newtonsoft.Json;
using Serilog; using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ApplicationServices namespace ApplicationServices
{ {
@@ -115,7 +116,29 @@ namespace ApplicationServices
[Name("IsFinished")] [Name("IsFinished")]
public bool IsFinished { get; set; } public bool IsFinished { get; set; }
}
[Name("IsSpatial")]
public bool IsSpatial { get; set; }
[Name("Last Downloaded File Version")]
public string LastDownloadedFileVersion { get; set; }
[Ignore /* csv ignore */]
public AudioFormat LastDownloadedFormat { get; set; }
[Name("Last Downloaded Codec"), JsonIgnore]
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
[Name("Last Downloaded Sample rate"), JsonIgnore]
public int? SampleRate => LastDownloadedFormat?.SampleRate;
[Name("Last Downloaded Audio Channels"), JsonIgnore]
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
[Name("Last Downloaded Bitrate"), JsonIgnore]
public int? BitRate => LastDownloadedFormat?.BitRate;
}
public static class LibToDtos public static class LibToDtos
{ {
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library) public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
@@ -127,24 +150,24 @@ namespace ApplicationServices
Locale = a.Book.Locale, Locale = a.Book.Locale,
Title = a.Book.Title, Title = a.Book.Title,
Subtitle = a.Book.Subtitle, Subtitle = a.Book.Subtitle,
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, Description = a.Book.Description,
Publisher = a.Book.Publisher, Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf(), HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames(), SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {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.ZeroIsNull(),
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating, CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
CommunityRatingStory = a.Book.Rating?.StoryRating, CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
PictureId = a.Book.PictureId, PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged, IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished, DatePublished = a.Book.DatePublished,
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()), CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating, MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating, MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating, MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
MyLibationTags = a.Book.UserDefinedItem.Tags, MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(), BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(), PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
@@ -152,8 +175,13 @@ namespace ApplicationServices
Language = a.Book.Language, Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded, LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "", LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
IsFinished = a.Book.UserDefinedItem.IsFinished IsFinished = a.Book.UserDefinedItem.IsFinished,
}).ToList(); IsSpatial = a.Book.IsSpatial,
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
}).ToList();
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
} }
public static class LibraryExporter public static class LibraryExporter
{ {
@@ -162,7 +190,6 @@ namespace ApplicationServices
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any()) if (!dtos.Any())
return; return;
using var writer = new System.IO.StreamWriter(saveFilePath); using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture); using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
@@ -174,7 +201,7 @@ namespace ApplicationServices
public static void ToJson(string saveFilePath) public static void ToJson(string saveFilePath)
{ {
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented); var json = JsonConvert.SerializeObject(dtos, Formatting.Indented);
System.IO.File.WriteAllText(saveFilePath, json); System.IO.File.WriteAllText(saveFilePath, json);
} }
@@ -182,19 +209,11 @@ namespace ApplicationServices
{ {
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos(); var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var workbook = new XSSFWorkbook(); using var workbook = new XLWorkbook();
var sheet = workbook.CreateSheet("Library"); var sheet = workbook.AddWorksheet("Library");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers // headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new[] { var columns = new[] {
nameof(ExportDto.Account), nameof(ExportDto.Account),
nameof(ExportDto.DateAdded), nameof(ExportDto.DateAdded),
@@ -227,98 +246,79 @@ namespace ApplicationServices
nameof(ExportDto.Language), nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded), nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion), nameof(ExportDto.LastDownloadedVersion),
nameof(ExportDto.IsFinished) nameof(ExportDto.IsFinished),
nameof(ExportDto.IsSpatial),
nameof(ExportDto.LastDownloadedFileVersion),
nameof(ExportDto.CodecString),
nameof(ExportDto.SampleRate),
nameof(ExportDto.ChannelCount),
nameof(ExportDto.BitRate)
}; };
var col = 0;
int rowIndex = 1, col = 1;
var headerRow = sheet.Row(rowIndex++);
foreach (var c in columns) foreach (var c in columns)
{ {
var cell = row.CreateCell(col++); var headerCell = headerRow.Cell(col++);
var name = ExportDto.GetName(c); headerCell.Value = ExportDto.GetName(c);
cell.SetCellValue(name); headerCell.Style.Font.Bold = true;
cell.CellStyle = detailSubtotalCellStyle;
} }
var dateFormat = workbook.CreateDataFormat(); var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
rowIndex++;
// Add data rows // Add data rows
foreach (var dto in dtos) foreach (var dto in dtos)
{ {
col = 0; col = 1;
var row = sheet.Row(rowIndex++);
row = sheet.CreateRow(rowIndex); row.Cell(col++).Value = dto.Account;
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
row.CreateCell(col++).SetCellValue(dto.Account); row.Cell(col++).Value = dto.AudibleProductId;
row.Cell(col++).Value = dto.Locale;
var dateCell = row.CreateCell(col++); row.Cell(col++).Value = dto.Title;
dateCell.CellStyle = dateStyle; row.Cell(col++).Value = dto.Subtitle;
dateCell.SetCellValue(dto.DateAdded); row.Cell(col++).Value = dto.AuthorNames;
row.Cell(col++).Value = dto.NarratorNames;
row.CreateCell(col++).SetCellValue(dto.AudibleProductId); row.Cell(col++).Value = dto.LengthInMinutes;
row.CreateCell(col++).SetCellValue(dto.Locale); row.Cell(col++).Value = dto.Description;
row.CreateCell(col++).SetCellValue(dto.Title); row.Cell(col++).Value = dto.Publisher;
row.CreateCell(col++).SetCellValue(dto.Subtitle); row.Cell(col++).Value = dto.HasPdf;
row.CreateCell(col++).SetCellValue(dto.AuthorNames); row.Cell(col++).Value = dto.SeriesNames;
row.CreateCell(col++).SetCellValue(dto.NarratorNames); row.Cell(col++).Value = dto.SeriesOrder;
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes); row.Cell(col++).Value = dto.CommunityRatingOverall;
row.CreateCell(col++).SetCellValue(dto.Description); row.Cell(col++).Value = dto.CommunityRatingPerformance;
row.CreateCell(col++).SetCellValue(dto.Publisher); row.Cell(col++).Value = dto.CommunityRatingStory;
row.CreateCell(col++).SetCellValue(dto.HasPdf); row.Cell(col++).Value = dto.PictureId;
row.CreateCell(col++).SetCellValue(dto.SeriesNames); row.Cell(col++).Value = dto.IsAbridged;
row.CreateCell(col++).SetCellValue(dto.SeriesOrder); row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
row.Cell(col++).Value = dto.CategoriesNames;
col = createCell(row, col, dto.CommunityRatingOverall); row.Cell(col++).Value = dto.MyRatingOverall;
col = createCell(row, col, dto.CommunityRatingPerformance); row.Cell(col++).Value = dto.MyRatingPerformance;
col = createCell(row, col, dto.CommunityRatingStory); row.Cell(col++).Value = dto.MyRatingStory;
row.Cell(col++).Value = dto.MyLibationTags;
row.CreateCell(col++).SetCellValue(dto.PictureId); row.Cell(col++).Value = dto.BookStatus;
row.CreateCell(col++).SetCellValue(dto.IsAbridged); row.Cell(col++).Value = dto.PdfStatus;
row.Cell(col++).Value = dto.ContentType;
var datePubCell = row.CreateCell(col++); row.Cell(col++).Value = dto.Language;
datePubCell.CellStyle = dateStyle; row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
if (dto.DatePublished.HasValue) row.Cell(col++).Value = dto.LastDownloadedVersion;
datePubCell.SetCellValue(dto.DatePublished.Value); row.Cell(col++).Value = dto.IsFinished;
else row.Cell(col++).Value = dto.IsSpatial;
datePubCell.SetCellValue(""); row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.CodecString;
row.CreateCell(col++).SetCellValue(dto.CategoriesNames); row.Cell(col++).Value = dto.SampleRate;
row.Cell(col++).Value = dto.ChannelCount;
col = createCell(row, col, dto.MyRatingOverall); row.Cell(col++).Value = dto.BitRate;
col = createCell(row, col, dto.MyRatingPerformance);
col = createCell(row, col, dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue)
{
dateCell = row.CreateCell(col);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.LastDownloaded.Value);
}
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
row.CreateCell(++col).SetCellValue(dto.IsFinished);
rowIndex++;
} }
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); workbook.SaveAs(saveFilePath);
workbook.Write(fileData);
} }
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
{ {
if (nullableFloat.HasValue) cell.Value = value;
row.CreateCell(col++).SetCellValue(nullableFloat.Value); cell.Style.DateFormat.Format = dateFormat;
else
row.CreateCell(col++).SetCellValue("");
return col;
} }
} }
} }
+26 -38
View File
@@ -1,10 +1,11 @@
using AudibleApi.Common; using AudibleApi.Common;
using ClosedXML.Excel;
using CsvHelper; using CsvHelper;
using DataLayer; using DataLayer;
using Newtonsoft.Json.Linq; using Newtonsoft.Json.Linq;
using NPOI.XSSF.UserModel;
using System; using System;
using System.Collections.Generic; using System.Collections.Generic;
using System.Globalization;
using System.Linq; using System.Linq;
namespace ApplicationServices namespace ApplicationServices
@@ -16,19 +17,10 @@ namespace ApplicationServices
if (!records.Any()) if (!records.Any())
return; return;
using var workbook = new XSSFWorkbook(); using var workbook = new XLWorkbook();
var sheet = workbook.CreateSheet("Records"); var worksheet = workbook.AddWorksheet("Records");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers // headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new List<string> var columns = new List<string>
{ {
nameof(Type.Name), nameof(Type.Name),
@@ -49,56 +41,52 @@ namespace ApplicationServices
if (records.OfType<Clip>().Any()) if (records.OfType<Clip>().Any())
columns.Add(nameof(Clip.Title)); columns.Add(nameof(Clip.Title));
var col = 0; int rowIndex = 1, col = 1;
var headerRow = worksheet.Row(rowIndex++);
foreach (var c in columns) foreach (var c in columns)
{ {
var cell = row.CreateCell(col++); var headerCell = headerRow.Cell(col++);
cell.SetCellValue(c); headerCell.Value = c;
cell.CellStyle = detailSubtotalCellStyle; headerCell.Style.Font.Bold = true;
} }
var dateFormat = workbook.CreateDataFormat(); var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
// Add data rows // Add data rows
foreach (var record in records) foreach (var record in records)
{ {
col = 0; col = 1;
var row = worksheet.Row(rowIndex++);
row = sheet.CreateRow(++rowIndex); row.Cell(col++).Value = record.GetType().Name;
row.Cell(col++).SetDate(record.Created.DateTime, dateFormat);
row.CreateCell(col++).SetCellValue(record.GetType().Name); row.Cell(col++).Value = record.Start.TotalMilliseconds;
var dateCreatedCell = row.CreateCell(col++);
dateCreatedCell.CellStyle = dateStyle;
dateCreatedCell.SetCellValue(record.Created.DateTime);
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
if (record is IAnnotation annotation) if (record is IAnnotation annotation)
{ {
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
var lastModifiedCell = row.CreateCell(col++); row.Cell(col++).Value = annotation.AnnotationId;
lastModifiedCell.CellStyle = dateStyle; row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat);
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
if (annotation is IRangeAnnotation rangeAnnotation) if (annotation is IRangeAnnotation rangeAnnotation)
{ {
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds); row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text); row.Cell(col++).Value = rangeAnnotation.Text;
if (rangeAnnotation is Clip clip) if (rangeAnnotation is Clip clip)
row.CreateCell(col++).SetCellValue(clip.Title); row.Cell(col++).Value = clip.Title;
} }
} }
} }
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create); workbook.SaveAs(saveFilePath);
workbook.Write(fileData);
} }
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
{
cell.Value = value;
cell.Style.DateFormat.Format = dateFormat;
}
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records) public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
{ {
if (!records.Any()) if (!records.Any())
+14 -18
View File
@@ -18,68 +18,64 @@ namespace AudibleUtilities
public string AccountId { get; } public string AccountId { get; }
// user-friendly, non-canonical name. mutable // user-friendly, non-canonical name. mutable
private string _accountName;
public string AccountName public string AccountName
{ {
get => _accountName; get => field;
set set
{ {
if (string.IsNullOrWhiteSpace(value)) if (string.IsNullOrWhiteSpace(value))
return; return;
var v = value.Trim(); var v = value.Trim();
if (v == _accountName) if (v == field)
return; return;
_accountName = v; field = v;
update(); update();
} }
} }
// whether to include this account when scanning libraries. // whether to include this account when scanning libraries.
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here // technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
private bool _libraryScan = true;
public bool LibraryScan public bool LibraryScan
{ {
get => _libraryScan; get => field;
set set
{ {
if (value == _libraryScan) if (value == field)
return; return;
_libraryScan = value; field = value;
update(); update();
} }
} }
private string _decryptKey = "";
/// <summary>aka: activation bytes</summary> /// <summary>aka: activation bytes</summary>
public string DecryptKey public string DecryptKey
{ {
get => _decryptKey; get => field ?? "";
set set
{ {
var v = (value ?? "").Trim(); var v = (value ?? "").Trim();
if (v == _decryptKey) if (v == field)
return; return;
_decryptKey = v; field = v;
update(); update();
} }
} }
private Identity _identity;
public Identity IdentityTokens public Identity IdentityTokens
{ {
get => _identity; get => field;
set set
{ {
if (_identity is null && value is null) if (field is null && value is null)
return; return;
if (_identity is not null) if (field is not null)
_identity.Updated -= update; field.Updated -= update;
if (value is not null) if (value is not null)
value.Updated += update; value.Updated += update;
_identity = value; field = value;
update(); update();
} }
} }
+7 -6
View File
@@ -6,6 +6,7 @@ using AudibleApi.Authorization;
using Dinah.Core; using Dinah.Core;
using Newtonsoft.Json; using Newtonsoft.Json;
#nullable enable
namespace AudibleUtilities 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
@@ -14,8 +15,8 @@ namespace AudibleUtilities
// JSON : Array (properties on the collection will not be serialized) // JSON : Array (properties on the collection will not be serialized)
public class AccountsSettings : IUpdatable public class AccountsSettings : IUpdatable
{ {
public event EventHandler Updated; public event EventHandler? Updated;
private void update(object sender = null, EventArgs e = null) private void update(object? sender = null, EventArgs? e = null)
{ {
foreach (var account in Accounts) foreach (var account in Accounts)
validate(account); validate(account);
@@ -48,9 +49,9 @@ namespace AudibleUtilities
} }
} }
private string _cdm; private string? _cdm;
[JsonProperty] [JsonProperty]
public string Cdm public string? Cdm
{ {
get => _cdm; get => _cdm;
set set
@@ -68,7 +69,7 @@ namespace AudibleUtilities
#endregion #endregion
#region de/serialize #region de/serialize
public static AccountsSettings FromJson(string json) public static AccountsSettings? FromJson(string json)
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings()); => JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
public string ToJson(Formatting formatting = Formatting.Indented) public string ToJson(Formatting formatting = Formatting.Indented)
@@ -107,7 +108,7 @@ namespace AudibleUtilities
account.Updated += update; account.Updated += update;
} }
public Account GetAccount(string accountId, string locale) public Account? GetAccount(string accountId, string? locale)
{ {
if (locale is null) if (locale is null)
return null; return null;
+13 -8
View File
@@ -10,6 +10,7 @@ using Dinah.Core;
using Polly; using Polly;
using Polly.Retry; using Polly.Retry;
using System.Threading; using System.Threading;
using LibationFileManager;
#nullable enable #nullable enable
namespace AudibleUtilities namespace AudibleUtilities
@@ -72,16 +73,16 @@ namespace AudibleUtilities
// 2 retries == 3 total // 2 retries == 3 total
.RetryAsync(2); .RetryAsync(2);
public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions, bool importEpisodes = true) public Task<List<Item>> GetLibraryValidatedAsync(LibraryOptions libraryOptions)
{ {
// 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 // 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: // 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 }); // 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 // i don't want to incur the cost of making a full dummy call every time because it fails sometimes
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions, importEpisodes)); return policy.ExecuteAsync(() => getItemsAsync(libraryOptions));
} }
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions, bool importEpisodes) private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions)
{ {
Serilog.Log.Logger.Debug("Beginning library scan."); Serilog.Log.Logger.Debug("Beginning library scan.");
@@ -95,12 +96,12 @@ namespace AudibleUtilities
//Scan the library for all added books. //Scan the library for all added books.
//Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried. //Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried.
await foreach (var item in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore)) await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
{ {
if (importEpisodes) if (Configuration.Instance.ImportEpisodes)
{ {
var episodes = item.Where(i => i.IsEpisodes).ToList(); var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
var series = item.Where(i => i.IsSeriesParent).ToList(); var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
var parentAsins = episodes var parentAsins = episodes
.SelectMany(i => i.Relationships) .SelectMany(i => i.Relationships)
@@ -119,7 +120,11 @@ namespace AudibleUtilities
items.AddRange(series); items.AddRange(series);
} }
items.AddRange(item.Where(i => !i.IsSeriesParent && !i.IsEpisodes)); var booksInBatch
= itemsBatch
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
items.AddRange(booksInBatch);
} }
sw.Stop(); sw.Stop();
+1 -1
View File
@@ -25,7 +25,7 @@ namespace AudibleUtilities
public static class AudibleApiStorage public static class AudibleApiStorage
{ {
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json"); public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError; public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;
@@ -28,10 +28,11 @@ namespace AudibleUtilities
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 null or blank {nameof(Item.ProductId)}", nameof(items))); exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items)));
if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1))) //// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date'
exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items))); //if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
// exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
return exceptions; return exceptions;
} }
} }
public class BookValidator : IValidator public class BookValidator : IValidator
@@ -91,7 +92,7 @@ namespace AudibleUtilities
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(Series.SeriesId)}", nameof(items))); exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
//// unfortunately, a user has a series with no name //// unfortunately, an actual user has a series with no name
//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(Series.SeriesName)}", nameof(items))); // exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk"> <Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup> <PropertyGroup>
<TargetFramework>net9.0</TargetFramework> <TargetFramework>net10.0</TargetFramework>
</PropertyGroup> </PropertyGroup>
<ItemGroup> <ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.1.1" /> <PackageReference Include="AudibleApi" Version="10.1.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.30.2" /> <PackageReference Include="Google.Protobuf" Version="3.33.2" />
</ItemGroup> </ItemGroup>
<ItemGroup> <ItemGroup>
+87 -5
View File
@@ -1,5 +1,6 @@
using System; using System;
using System.IO; using System.IO;
using System.Numerics;
using System.Security.Cryptography; using System.Security.Cryptography;
#nullable enable #nullable enable
@@ -56,18 +57,99 @@ internal class Device
public byte[] SignMessage(byte[] message) public byte[] SignMessage(byte[] message)
{ {
using var sha1 = SHA1.Create(); var digestion = SHA1.HashData(message);
var digestion = sha1.ComputeHash(message); return PssSha1Signer.SignHash(CdmKey, digestion);
return CdmKey.SignHash(digestion, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
} }
public bool VerifyMessage(byte[] message, byte[] signature) public bool VerifyMessage(byte[] message, byte[] signature)
{ {
using var sha1 = SHA1.Create(); var digestion = SHA1.HashData(message);
var digestion = sha1.ComputeHash(message);
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss); return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
} }
public byte[] DecryptSessionKey(byte[] sessionKey) public byte[] DecryptSessionKey(byte[] sessionKey)
=> CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1); => CdmKey.Decrypt(sessionKey, RSAEncryptionPadding.OaepSHA1);
/// <summary>
/// Completely managed implementation of RSASSA-PSS using SHA-1.
/// https://github.com/bcgit/bc-csharp/blob/master/crypto/src/crypto/signers/PssSigner.cs
///
/// Absolutely nobody anywhere should use this RSASSA-PSS implementation in anything where they care about security at all. We completely skipped the random salt part of it because libation doesn't need security; it only needs to satisfy Audible server's challenge-response requirements.
/// </summary>
private static class PssSha1Signer
{
private const int Sha1DigestSize = 20;
private const int Trailer = 0xBC;
public static byte[] SignHash(RSA rsa, ReadOnlySpan<byte> hash)
{
ArgumentOutOfRangeException.ThrowIfNotEqual(hash.Length, Sha1DigestSize);
var parameters = rsa.ExportParameters(true);
var Modulus = new BigInteger(parameters.Modulus, isUnsigned: true, isBigEndian: true);
var Exponent = new BigInteger(parameters.D, isUnsigned: true, isBigEndian: true);
var emBits = rsa.KeySize - 1;
var block = new byte[(emBits + 7) / 8];
var firstByteMask = (byte)(0xFFU >> ((block.Length * 8) - emBits));
Span<byte> mDash = new byte[8 + 2 * Sha1DigestSize];
hash.CopyTo(mDash.Slice(8));
var h = SHA1.HashData(mDash);
block[^(2 * (Sha1DigestSize + 1))] = 1;
byte[] dbMask = MaskGeneratorFunction1(h, 0, h.Length, block.Length - Sha1DigestSize - 1);
for (int i = 0; i != dbMask.Length; i++)
block[i] ^= dbMask[i];
h.CopyTo(block, block.Length - Sha1DigestSize - 1);
block[0] &= firstByteMask;
block[^1] = Trailer;
var input = new BigInteger(block, isUnsigned: true, isBigEndian: true);
var result = BigInteger.ModPow(input, Exponent, Modulus);
return result.ToByteArray(isUnsigned: true, isBigEndian: true);
}
private static byte[] MaskGeneratorFunction1(byte[] Z, int zOff, int zLen, int length)
{
byte[] mask = new byte[length];
byte[] hashBuf = new byte[Sha1DigestSize];
byte[] C = new byte[4];
int counter = 0;
using var sha = SHA1.Create();
for (; counter < (length / Sha1DigestSize); counter++)
{
ItoOSP(counter, C);
sha.TransformBlock(Z, zOff, zLen, null, 0);
sha.TransformFinalBlock(C, 0, C.Length);
sha.Hash!.CopyTo(mask, counter * Sha1DigestSize);
}
if ((counter * Sha1DigestSize) < length)
{
ItoOSP(counter, C);
sha.TransformBlock(Z, zOff, zLen, null, 0);
sha.TransformFinalBlock(C, 0, C.Length);
Array.Copy(sha.Hash!, 0, mask, counter * Sha1DigestSize, mask.Length - (counter * Sha1DigestSize));
}
return mask;
}
private static void ItoOSP(int i, byte[] sp)
{
sp[0] = (byte)((uint)i >> 24);
sp[1] = (byte)((uint)i >> 16);
sp[2] = (byte)((uint)i >> 8);
sp[3] = (byte)((uint)i >> 0);
}
}
} }
@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>
@@ -0,0 +1,494 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20251027224441_InitialPostgres")]
partial class InitialPostgres
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("integer");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("integer");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
b.Property<string>("AudibleProductId")
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsAbridged")
.HasColumnType("boolean");
b.Property<bool>("IsSpatial")
.HasColumnType("boolean");
b.Property<string>("Language")
.HasColumnType("text");
b.Property<int>("LengthInMinutes")
.HasColumnType("integer");
b.Property<string>("Locale")
.HasColumnType("text");
b.Property<string>("PictureId")
.HasColumnType("text");
b.Property<string>("PictureLarge")
.HasColumnType("text");
b.Property<string>("Subtitle")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("CategoryLadderId")
.HasColumnType("integer");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
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("smallint");
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<string>("AudibleCategoryId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
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<bool>("AbsentFromLastScan")
.HasColumnType("boolean");
b.Property<string>("Account")
.HasColumnType("text");
b.Property<DateTime>("DateAdded")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
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("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
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<bool>("IsFinished")
.HasColumnType("boolean");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("timestamp without time zone");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("text");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("bigint");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("text");
b1.Property<int?>("PdfStatus")
.HasColumnType("integer");
b1.Property<string>("Tags")
.HasColumnType("text");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
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("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
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.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("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,372 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class InitialPostgres : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Books",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleProductId = table.Column<string>(type: "text", nullable: true),
Title = table.Column<string>(type: "text", nullable: true),
Subtitle = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
LengthInMinutes = table.Column<int>(type: "integer", nullable: false),
ContentType = table.Column<int>(type: "integer", nullable: false),
Locale = table.Column<string>(type: "text", nullable: true),
PictureId = table.Column<string>(type: "text", nullable: true),
PictureLarge = table.Column<string>(type: "text", nullable: true),
IsAbridged = table.Column<bool>(type: "boolean", nullable: false),
IsSpatial = table.Column<bool>(type: "boolean", nullable: false),
DatePublished = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
Language = table.Column<string>(type: "text", nullable: true),
Rating_OverallRating = table.Column<float>(type: "real", nullable: true),
Rating_PerformanceRating = table.Column<float>(type: "real", nullable: true),
Rating_StoryRating = table.Column<float>(type: "real", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Books", x => x.BookId);
});
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
CategoryId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleCategoryId = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.CategoryId);
});
migrationBuilder.CreateTable(
name: "CategoryLadders",
columns: table => new
{
CategoryLadderId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
});
migrationBuilder.CreateTable(
name: "Contributors",
columns: table => new
{
ContributorId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: true),
AudibleContributorId = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contributors", x => x.ContributorId);
});
migrationBuilder.CreateTable(
name: "Series",
columns: table => new
{
SeriesId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleSeriesId = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Series", x => x.SeriesId);
});
migrationBuilder.CreateTable(
name: "LibraryBooks",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
DateAdded = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
Account = table.Column<string>(type: "text", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
AbsentFromLastScan = table.Column<bool>(type: "boolean", nullable: false),
IncludedUntil = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LibraryBooks", x => x.BookId);
table.ForeignKey(
name: "FK_LibraryBooks_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Supplement",
columns: table => new
{
SupplementId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BookId = table.Column<int>(type: "integer", nullable: false),
Url = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Supplement", x => x.SupplementId);
table.ForeignKey(
name: "FK_Supplement_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserDefinedItem",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
LastDownloaded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
LastDownloadedVersion = table.Column<string>(type: "text", nullable: true),
LastDownloadedFormat = table.Column<long>(type: "bigint", nullable: true),
LastDownloadedFileVersion = table.Column<string>(type: "text", nullable: true),
Tags = table.Column<string>(type: "text", nullable: true),
Rating_OverallRating = table.Column<float>(type: "real", nullable: true),
Rating_PerformanceRating = table.Column<float>(type: "real", nullable: true),
Rating_StoryRating = table.Column<float>(type: "real", nullable: true),
BookStatus = table.Column<int>(type: "integer", nullable: false),
PdfStatus = table.Column<int>(type: "integer", nullable: true),
IsFinished = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserDefinedItem", x => x.BookId);
table.ForeignKey(
name: "FK_UserDefinedItem_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BookCategory",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
CategoryLadderId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
table.ForeignKey(
name: "FK_BookCategory_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
column: x => x.CategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CategoryCategoryLadder",
columns: table => new
{
_categoriesCategoryId = table.Column<int>(type: "integer", nullable: false),
_categoryLaddersCategoryLadderId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
table.ForeignKey(
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
column: x => x._categoriesCategoryId,
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCate~",
column: x => x._categoryLaddersCategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BookContributor",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
ContributorId = table.Column<int>(type: "integer", nullable: false),
Role = table.Column<int>(type: "integer", nullable: false),
Order = table.Column<byte>(type: "smallint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookContributor", x => new { x.BookId, x.ContributorId, x.Role });
table.ForeignKey(
name: "FK_BookContributor_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookContributor_Contributors_ContributorId",
column: x => x.ContributorId,
principalTable: "Contributors",
principalColumn: "ContributorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SeriesBook",
columns: table => new
{
SeriesId = table.Column<int>(type: "integer", nullable: false),
BookId = table.Column<int>(type: "integer", nullable: false),
Order = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SeriesBook", x => new { x.SeriesId, x.BookId });
table.ForeignKey(
name: "FK_SeriesBook_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SeriesBook_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "SeriesId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "Contributors",
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
values: new object[] { -1, null, "" });
migrationBuilder.CreateIndex(
name: "IX_BookCategory_BookId",
table: "BookCategory",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookCategory_CategoryLadderId",
table: "BookCategory",
column: "CategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_BookContributor_BookId",
table: "BookContributor",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookContributor_ContributorId",
table: "BookContributor",
column: "ContributorId");
migrationBuilder.CreateIndex(
name: "IX_Books_AudibleProductId",
table: "Books",
column: "AudibleProductId");
migrationBuilder.CreateIndex(
name: "IX_Categories_AudibleCategoryId",
table: "Categories",
column: "AudibleCategoryId");
migrationBuilder.CreateIndex(
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
table: "CategoryCategoryLadder",
column: "_categoryLaddersCategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_Contributors_Name",
table: "Contributors",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_Series_AudibleSeriesId",
table: "Series",
column: "AudibleSeriesId");
migrationBuilder.CreateIndex(
name: "IX_SeriesBook_BookId",
table: "SeriesBook",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_SeriesBook_SeriesId",
table: "SeriesBook",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_Supplement_BookId",
table: "Supplement",
column: "BookId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookCategory");
migrationBuilder.DropTable(
name: "BookContributor");
migrationBuilder.DropTable(
name: "CategoryCategoryLadder");
migrationBuilder.DropTable(
name: "LibraryBooks");
migrationBuilder.DropTable(
name: "SeriesBook");
migrationBuilder.DropTable(
name: "Supplement");
migrationBuilder.DropTable(
name: "UserDefinedItem");
migrationBuilder.DropTable(
name: "Contributors");
migrationBuilder.DropTable(
name: "Categories");
migrationBuilder.DropTable(
name: "CategoryLadders");
migrationBuilder.DropTable(
name: "Series");
migrationBuilder.DropTable(
name: "Books");
}
}
}
@@ -0,0 +1,491 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
[DbContext(typeof(LibationContext))]
partial class LibationContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("integer");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("integer");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
b.Property<string>("AudibleProductId")
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsAbridged")
.HasColumnType("boolean");
b.Property<bool>("IsSpatial")
.HasColumnType("boolean");
b.Property<string>("Language")
.HasColumnType("text");
b.Property<int>("LengthInMinutes")
.HasColumnType("integer");
b.Property<string>("Locale")
.HasColumnType("text");
b.Property<string>("PictureId")
.HasColumnType("text");
b.Property<string>("PictureLarge")
.HasColumnType("text");
b.Property<string>("Subtitle")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("CategoryLadderId")
.HasColumnType("integer");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
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("smallint");
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<string>("AudibleCategoryId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
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<bool>("AbsentFromLastScan")
.HasColumnType("boolean");
b.Property<string>("Account")
.HasColumnType("text");
b.Property<DateTime>("DateAdded")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
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("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
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");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
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<bool>("IsFinished")
.HasColumnType("boolean");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("timestamp without time zone");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("text");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("bigint");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("text");
b1.Property<int?>("PdfStatus")
.HasColumnType("integer");
b1.Property<string>("Tags")
.HasColumnType("text");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
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("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
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.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("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}
@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore.Design;
namespace DataLayer.Postgres
{
public class PostgresContextFactory : IDesignTimeDbContextFactory<LibationContext>
{
public LibationContext CreateDbContext(string[] args)
{
return LibationContextFactory.CreatePostgres(string.Empty);
}
}
}
@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
</Project>
Loaded 100 of 590 files, more files were not shown because too many files have changed in this diff. Show more