Compare commits

..

1005 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
MBucari
35862d619a Increment version 2025-05-09 21:10:38 -06:00
Mbucari
ac2c67985d Merge pull request #1253 from Mbucari/master
Fix download error (#1252 )

I'm merging this one and releasing ASAP.
2025-05-09 21:07:59 -06:00
MBucari
f8ae303417 Fix download error (#1252 ) 2025-05-09 21:07:01 -06:00
rmcrackan
0d24caeac2 incr ver 2025-05-09 21:10:19 -04:00
rmcrackan
7f1b357c52 Merge pull request #1250 from Mbucari/master
Bug fixes and a change to license request logic
2025-05-09 21:08:19 -04:00
Michael Bucari-Tovo
ef67ae9d6a Ask users to clear the accounts when enabling widevine (#1249) 2025-05-09 17:52:14 -06:00
Michael Bucari-Tovo
f35c82d59d Change ApiExtended to always allow provide login option
Previously, only some calls to ApiExtended.CreateAsync() would prompt users to login if necessary. Other calls would only work if the account already had a valid identity, and they would throw exceptions otherwise.

Changed ApiExtended so that the UI registers a static ILoginChoiceEager factory delegate that ApiExtended will use in the event that a login is required.
2025-05-09 17:32:12 -06:00
Michael Bucari-Tovo
10c01f4147 Fix occasional error of audio downloads hanging. 2025-05-09 16:32:59 -06:00
Michael Bucari-Tovo
9366b3baca Default to E-AC-3 spatial audio format. 2025-05-09 13:39:59 -06:00
Michael Bucari-Tovo
20e792c589 Always change the last chapter's length to coincide with the end of the audio file. 2025-05-09 13:36:07 -06:00
Michael Bucari-Tovo
dfb63d3275 Add contributor 2025-05-09 13:15:18 -06:00
Michael Bucari-Tovo
19db226f5a Use Libation settings to decide which DRM is downloaded. 2025-05-09 13:13:39 -06:00
Mbucari
203ab00865 Merge branch 'rmcrackan:master' into master 2025-05-08 12:15:26 -06:00
MBucari
b11a4887d7 Pad final chapter to prevent tuncation from incorrect chapter info (#1246) 2025-05-08 12:13:55 -06:00
rmcrackan
e73fc5e1eb Merge pull request #1247 from starry-shivam/patch-2
Small typo fix in DownloadOptions.Factory.cs
2025-05-08 07:12:42 -04:00
Stɑrry Shivɑm
8561a15061 Small typo fix in DownloadOptions.Factory.cs 2025-05-08 10:48:02 +05:30
MBucari
28ba62aead Fix dash files not being saved (#1236) 2025-05-07 23:15:44 -06:00
rmcrackan
176294cc55 Merge pull request #1245 from Mbucari/master
Minor bugfixes
2025-05-07 19:28:45 -04:00
Michael Bucari-Tovo
152b0e362d Update message box icons 2025-05-07 16:10:03 -06:00
Michael Bucari-Tovo
4600d029dc Re-add converter resource inadvertantly removed in 0df17a22 2025-05-07 14:23:58 -06:00
Michael Bucari-Tovo
1a5684799c Update Hangover styles and behaviors 2025-05-07 13:16:44 -06:00
Michael Bucari-Tovo
0df17a2296 Remove retired ItemsRepeater control 2025-05-07 13:12:12 -06:00
Michael Bucari-Tovo
45472abd1f Update dependencies 2025-05-07 11:15:32 -06:00
Mbucari
f2ea4539f2 Merge branch 'rmcrackan:master' into master 2025-05-07 11:13:32 -06:00
Michael Bucari-Tovo
52d3b9cb67 Disable warning 2025-05-07 11:13:26 -06:00
rmcrackan
3d87f2cd9b Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-07 12:39:10 -04:00
rmcrackan
e4a3d2ac79 better logging for api errors #1240 2025-05-07 12:39:02 -04:00
Michael Bucari-Tovo
8aa157f2f6 Re-add completed audiobooks to queue (#1219) 2025-05-06 15:43:58 -06:00
Michael Bucari-Tovo
5ab6c1fe70 Update AAXClean to fix metadata reader (#1243 ) 2025-05-06 15:33:38 -06:00
Michael Bucari-Tovo
b23c46f79f Fix incorrect chapters in some audiobooks (#1210) 2025-05-06 15:32:59 -06:00
Mbucari
0e987eef00 Fix error in download speed throttle (#1242) 2025-05-06 14:48:40 -06:00
rmcrackan
ace3d80e41 Merge pull request #1241 from cherez/patch-1
Fixed doubled first name in templates
2025-05-06 16:29:29 -04:00
Mbucari
4bfb4e73ce Fix aax file getting inadvertently deleted (#1236) 2025-05-06 12:45:43 -06:00
Steven Wallace
7805a3ef11 Fixed broken single word name test
This expected the name duplication that the previous commit fixed to be the behavior, changed to expect the single word to be the last name.
2025-05-06 09:58:09 -05:00
Steven Wallace
08ca2a2db3 Fixed doubled first name in templates
v12.3.0 caused a regression with contributors with a single word name, causing the name to be doubled. This was caused by using that name as both the first and last name, so swap the first name with the (blank) last name rather than duplicate them.
2025-05-05 10:37:28 -05:00
rmcrackan
64a85b6aab Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-05-02 22:15:36 -04:00
rmcrackan
1a38273d5f incr ver 2025-05-02 22:15:32 -04:00
rmcrackan
303dd7c471 Merge pull request #1233 from Mbucari/master
Bugfixes and Feature Requests
2025-05-02 22:14:33 -04:00
MBucari
313e3846c3 Remove AudioFormat from library book exporter (5f455182) 2025-05-02 15:39:47 -06:00
Michael Bucari-Tovo
422c86345e Add logging 2025-05-02 14:50:33 -06:00
Michael Bucari-Tovo
ce952417fb Don't replace library properties in queued item with null/empty 2025-05-02 13:07:53 -06:00
Michael Bucari-Tovo
5f4551822b Remove Book.AudioFormat property
This property was set to the highest quality returned by the library scan. Since adding quality option settings, it is no longer guaranteed to reflect the file that is downloaded. Also, the library scan qualities don't contain spatial audio or widevine-specific qualities., only ADRM.
2025-05-02 12:39:12 -06:00
Michael Bucari-Tovo
3aebc7c885 Improve download performance. 2025-05-02 12:19:32 -06:00
Michael Bucari-Tovo
3982edd0f1 Add codec tag and use real bitrate/samplerate (#1227) 2025-05-02 11:20:58 -06:00
Michael Bucari-Tovo
f4dafac28f Try to solve #1226 2025-05-01 13:19:03 -06:00
Michael Bucari-Tovo
1090d29f74 Add fine-grained options for downloading widevine content 2025-05-01 13:03:03 -06:00
rmcrackan
1c336e1fe9 bug fix 2025-04-28 18:55:20 -04:00
rmcrackan
c7e9e9ac1e incr ver 2025-04-28 13:36:26 -04:00
rmcrackan
8232b2b5e5 Merge pull request #1223 from Mbucari/master
New features, including spatial audio support
2025-04-28 13:34:40 -04:00
MBucari
9ca879cc3d Revert "Allow re-adding completed queued items"
This reverts commit e2aae85fd7.
2025-04-27 14:31:21 -06:00
MBucari
ece48eb6d7 Add spatial audio support 2025-04-27 14:31:14 -06:00
MBucari
bffaea6026 Add CDM API url list 2025-04-27 13:15:50 -06:00
MBucari
e2aae85fd7 Allow re-adding completed queued items 2025-04-25 19:54:19 -06:00
MBucari
1777dc5a7e Update AAXClean.Codecs and dependencies 2025-04-25 19:52:51 -06:00
Mbucari
2dfe00f428 Merge branch 'rmcrackan:master' into master 2025-04-15 00:36:11 -06:00
rmcrackan
2cd0a022ff bug fix #1212 : fix window title 2025-04-03 08:20:31 -04:00
Michael Bucari-Tovo
5d7ac699e6 Mark unreleased books as unavailable (#1079) 2025-03-25 12:35:18 -06:00
Michael Bucari-Tovo
7d806e0f3e Increase tag template options for contributor and series types
- Add template tag support for multiple series
- Add series ID and contributor ID to template tags
- <first author> and <first narrator> are now name types with name formatter support
- Properly import contributor IDs into database
- Updated docs
2025-03-25 09:34:57 -06:00
Michael Bucari-Tovo
0a9e489f48 Move contributors to UI Base 2025-03-24 13:29:02 -06:00
rmcrackan
17612dacd2 incr ver 2025-03-22 06:35:30 -04:00
rmcrackan
e61ad41d5a Merge pull request #1202 from Mbucari/master
Add multiselect feature and a bugfix
2025-03-22 06:31:33 -04:00
Michael Bucari-Tovo
c77f2e2162 Add multi-select context menu support (rmcrackan/Libation#1195) 2025-03-21 16:49:21 -06:00
Michael Bucari-Tovo
bfcd226795 Fix libation hanging on first inport of large libraries 2025-03-21 11:08:36 -06:00
rmcrackan
0af7c4d90a fix ver 2025-03-21 09:19:03 -04:00
rmcrackan
e4826388be incr ver 2025-03-21 09:18:48 -04:00
rmcrackan
98a1fa4dda Merge pull request #1193 from Mbucari/master
Add support for custom themes in chardonnay
2025-03-21 09:12:35 -04:00
Michael Bucari-Tovo
81e9ab7fb2 Fix theme not resetting properly
Change button foreground color
2025-03-20 16:30:08 -06:00
Mbucari
9c82d34ba4 Merge branch 'rmcrackan:master' into master 2025-03-20 15:30:18 -06:00
Mbucari
a384bceab0 Update Readme for Chardonnay Themes 2025-03-20 15:29:46 -06:00
Michael Bucari-Tovo
545540d9a4 Improve Libation glass icons for use with dark mode. 2025-03-20 15:04:22 -06:00
MBucari
f402912a92 Mark resource as dynamic and delete unused resource 2025-03-19 22:43:50 -06:00
Michael Bucari-Tovo
aab4f1d9d6 Add theme import and export function 2025-03-19 21:47:24 -06:00
Michael Bucari-Tovo
f183b587b8 Revert all changes if window is closed by user. 2025-03-19 16:38:58 -06:00
Michael Bucari-Tovo
733a091ebd Add theme preview dialog 2025-03-19 16:26:14 -06:00
Mbucari
9043ea6334 Merge branch 'rmcrackan:master' into master 2025-03-19 14:21:51 -06:00
Michael Bucari-Tovo
40890f242a Fix spelling errors 2025-03-19 14:16:32 -06:00
rmcrackan
6c03f525bf Update InstallOnMac.md -- minimum OS supported 2025-03-19 16:01:40 -04:00
Michael Bucari-Tovo
dcda1a0cc2 Add contributors to about page 2025-03-18 21:18:25 -06:00
Michael Bucari-Tovo
e509f842e4 Remove unused windows forms buttons and streamline dialogs 2025-03-18 21:18:25 -06:00
Mbucari
faa2e04b9f Merge branch 'rmcrackan:master' into master 2025-03-18 21:17:30 -06:00
Robert McRackan
71afb5c9f4 incr ver 2025-03-18 21:09:27 -04:00
Michael Bucari-Tovo
d90ef3f4d4 Mark IconFill as a dynamic resource 2025-03-18 12:33:01 -06:00
Michael Bucari-Tovo
f84bb753e9 Revert custom window border on Windows 2025-03-13 16:44:16 -06:00
Michael Bucari-Tovo
b34970bd47 Add support for custom themes in chardonnay 2025-03-13 16:05:32 -06:00
Robert McRackan
a37eb383cd Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-03-10 19:11:22 -04:00
Robert McRackan
614965e1ab incr ver 2025-03-10 19:09:44 -04:00
rmcrackan
52d611a74c Merge pull request #1189 from Mbucari/master
Minor bug fixes.
2025-03-10 17:30:24 -04:00
Michael Bucari-Tovo
653381b1df Fix Auto download not working sometimes (#1183) 2025-03-10 12:57:52 -06:00
Michael Bucari-Tovo
4e067f5b5b Remove inadvertently committed debugging code 2025-03-10 10:46:36 -06:00
Michael Bucari-Tovo
ee05ca4eb2 Handle corrupted LibraryScans.zip file (#1185) 2025-03-10 09:49:22 -06:00
Michael Bucari-Tovo
65e12d9a8f Add default window dimensions 2025-03-10 09:07:35 -06:00
rmcrackan
5dc1bafb94 Update Docker.md 2025-03-07 10:12:05 -05:00
rmcrackan
3010f80834 Merge pull request #1181 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.7
Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
2025-03-06 20:49:34 -05:00
dependabot[bot]
6c20b85200 Bump SixLabors.ImageSharp from 3.1.6 to 3.1.7 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.6 to 3.1.7.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.6...v3.1.7)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-03-06 22:29:43 +00:00
rmcrackan
bf87180fe9 Merge pull request #1179 from Mbucari/master
UI tweak and Linux command updates
2025-03-05 17:41:47 -05:00
Michael Bucari-Tovo
ae9aac789f Use polkit (#1176) and dnf5/dnf (#1177) 2025-03-05 14:22:51 -07:00
Mbucari
e6cd182872 Merge branch 'rmcrackan:master' into master 2025-03-05 11:25:50 -07:00
Michael Bucari-Tovo
7eeb2dcd7f Move encoding options above to the mp3 settings column (#764)
Additionally, add more tool tips for cryptic options.
2025-03-05 11:17:47 -07:00
rmcrackan
71bb0571d1 Merge pull request #1178 from RokeJulianLockhart/patch-1
Add instructions for Fedora to `InstallOnLinux.md`.
2025-03-05 11:32:10 -05:00
Mr. Beedell, Roke Julian Lockhart
7bb5b2968e Add instructions for Fedora to InstallOnLinux.md
Partially improves upon https://github.com/rmcrackan/Libation/issues/1177#issue-2897597653.
2025-03-05 15:17:49 +00:00
rmcrackan
b051283fca Update bug_report.md 2025-03-05 09:30:43 -05:00
rmcrackan
53af2ee39e Update feature_request.md 2025-03-05 09:30:18 -05:00
rmcrackan
fab32a1744 Update bug_report.md 2025-03-05 09:29:04 -05:00
rmcrackan
e2dabc8a53 Update feature_request.md 2025-03-05 09:26:47 -05:00
rmcrackan
fd82f7ae5a Update feature_request.md 2025-03-05 09:26:19 -05:00
rmcrackan
df9535a83d Update FrequentlyAskedQuestions.md 2025-03-05 08:20:42 -05:00
rmcrackan
85b6792468 Merge pull request #1175 from Mbucari/master
Additional null safety
2025-03-04 21:32:35 -05:00
Michael Bucari-Tovo
e37abbf276 Fix dark theme text color in DataGridTextColumn 2025-03-04 16:18:06 -07:00
Michael Bucari-Tovo
c3938c49a9 Additional null safety 2025-03-04 15:41:26 -07:00
Michael Bucari-Tovo
7658f21d7c Fix tags font color in dark mode 2025-03-04 15:07:37 -07:00
Michael Bucari-Tovo
c4827fc761 Add error logging 2025-03-04 12:54:05 -07:00
rmcrackan
649b52af1d Merge pull request #1174 from Mbucari/master
Null safety & UI tweak
2025-03-04 13:10:51 -05:00
Michael Bucari-Tovo
da06511951 Only show buttons on mouse over 2025-03-04 10:34:09 -07:00
Michael Bucari-Tovo
88d3e5ff0c Null safety checks. 2025-03-04 10:33:29 -07:00
rmcrackan
5f99e594d8 Merge pull request #1172 from Mbucari/master
Thread safety and AccountSettings.json error handling
2025-03-03 19:27:45 -05:00
Michael Bucari-Tovo
981a183992 Add a border around dialogs with CanResize=true 2025-03-03 15:20:58 -07:00
Michael Bucari-Tovo
ac036f65f1 Handle and notify users of invalid account settings file 2025-03-03 14:41:56 -07:00
Michael Bucari-Tovo
b36e110b49 Add thread safety and comments re threading 2025-03-03 10:11:17 -07:00
Robert McRackan
ef3c71a939 Incr ver 2025-03-02 20:34:45 -05:00
rmcrackan
b2af93bed9 Merge pull request #1169 from Mbucari/master
12.0.1 Bug Fixes
2025-03-02 20:33:59 -05:00
Mbucari
1f427919e6 Try to solve threadding issues (#1168) 2025-03-02 11:59:35 -07:00
Mbucari
c9c5bbb687 Fix errorre rmoving entries from the cache (#1167) 2025-03-02 10:55:26 -07:00
Robert McRackan
efbefa2784 Increm ver 2025-03-01 10:44:49 -05:00
rmcrackan
51aabe5dd4 Merge pull request #1161 from Mbucari/master
Performance and UI tweaks
2025-02-28 22:48:49 -05:00
Michael Bucari-Tovo
1c668adff8 Deliminated category names in library exports with semicolon (#1107) 2025-02-28 17:35:58 -07:00
Michael Bucari-Tovo
4170dcc1d5 Chardonnay UI bug fixes and improvements
- Theme changes do not require restart
- Fix some text appearing black in dark mode
- Fix dialog boxes not appearing correctly on Windows
- Fix queue vertical scroll bar overlapping items
2025-02-28 17:34:55 -07:00
Michael Bucari-Tovo
68cfae1d58 Fix ever-widening Form1 when form size is restored. 2025-02-28 12:04:49 -07:00
Michael Bucari-Tovo
a790c7535c Changes to default directories for file storage (#1112)
- Add My Music and Local Application Data to known directories
- Make %localappdata%\Libation the default settings folder on *nix machines
- Make %MyMusic%\Libation\Books the default books folder on *nix machines
2025-02-28 12:01:58 -07:00
Michael Bucari-Tovo
3b7d5a354f Re-add books to queue that failed or were cancelled. 2025-02-28 10:47:26 -07:00
MBucari
a9375f1520 Improve file cache performance and add migration
LibraryCommands.GetCounts hits the file cache hard. The previous cache implementation was linear list, so finding an entry by ID was (n). When you consider that each book may have many files, the number of cache entries could grow to many multiples of the library size.

The new cache uses a dictionary with the ID as its key, and a CacheEntry list as its value.
2025-02-28 10:07:45 -07:00
Michael Bucari-Tovo
47c9fcb883 Improve LibrarySizeChanged performance 2025-02-27 22:56:30 -07:00
rmcrackan
5f5c9f65ed Merge pull request #1160 from Mbucari/master
Fixed stack overflow crash when movifying large databases
2025-02-27 21:41:33 -05:00
MBucari
1417a4b992 Improve library load performance 2025-02-27 19:16:36 -07:00
Michael Bucari-Tovo
2d6120f0c4 Get full library in LibrarySizeChanged event and pass as EventArgs
There are multiple subscribers to LibraryCommands.LibrarySizeChanged, and each one calls GetLibrary_Flat_NoTracking(). Passing the full library as an event argument speeds up all operations which happen after the library size changes.

Fix initial backup counts
2025-02-27 13:11:28 -07:00
Michael Bucari-Tovo
2a25b7e0ad Load MainWindow before library finishes loading like Classic 2025-02-27 11:17:18 -07:00
Michael Bucari-Tovo
4766ea7372 Improve NRE safety for quick filters 2025-02-27 10:10:26 -07:00
Michael Bucari-Tovo
d195dd07dc Remove upload-release-assets package (out of support) 2025-02-27 09:30:08 -07:00
Michael Bucari-Tovo
bcbb7610ad Refactor GetAllEntries methods for clarity and maintainability
d
2025-02-27 09:00:26 -07:00
Michael Bucari-Tovo
6c5773df24 Fix stack overflow exception when updating large databases (#1158) 2025-02-26 14:41:59 -07:00
rmcrackan
211f15af25 Update InstallOnMac.md 2025-02-25 10:03:11 -05:00
Robert McRackan
e3b0f80016 incr ver: 12.0 2025-02-24 12:05:24 -05:00
Robert McRackan
7b2c7e49e5 fix unit tests 2025-02-23 11:11:25 -05:00
Robert McRackan
6a40d19393 Fix unit tests 2025-02-23 10:28:09 -05:00
Robert McRackan
3167744111 #1151 : default character replacement for colon is _ 2025-02-23 10:13:15 -05:00
Robert McRackan
b6a3ba335a Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-22 22:54:12 -05:00
Robert McRackan
fa1ddc726a #1151 - unicode colon is causing issues with Windows Chardonnay 2025-02-22 22:54:00 -05:00
rmcrackan
b3b0662dec Update InstallOnMac.md
Apple can’t check app for malicious software
2025-02-19 17:09:26 -05:00
Robert McRackan
5cb22cfd24 Update AAXClean.Codecs 2025-02-17 20:07:50 -05:00
Michael Bucari-Tovo
e911344850 Workaround for DataGridView filtering internal NullReferenceException 2025-02-10 10:20:18 -07:00
Michael Bucari-Tovo
8ec7e5a9d2 Revert "DataGridView filtering internal NullReferenceException **HACK**"
This reverts commit e1f749c3da.
2025-02-10 10:19:24 -07:00
Robert McRackan
e1f749c3da DataGridView filtering internal NullReferenceException **HACK** 2025-02-10 11:23:27 -05:00
Robert McRackan
ba060d15aa Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-02-10 09:12:32 -05:00
Robert McRackan
93fde236c8 format for clarity 2025-02-10 09:12:29 -05:00
Michael Bucari-Tovo
13aad1a7cb Restrict audio sample rate settings to allowed values (#1116) 2025-01-16 10:35:58 -07:00
rmcrackan
65c64c4504 Update Docker.md 2025-01-13 09:28:55 -05:00
rmcrackan
14ba04c28b Update Docker.md 2025-01-13 09:26:52 -05:00
rmcrackan
96e886d207 Update FrequentlyAskedQuestions.md
Brazil login
2025-01-08 10:04:55 -05:00
Robert McRackan
c7279574a9 Upgrade avalonia 2025-01-07 08:00:35 -05:00
rmcrackan
a522e1ff7e Merge pull request #1072 from shuvashish76/patch-1
Update InstallOnLinux.md with repology badge
2024-12-08 08:56:32 -05:00
shuvashish76
9ebc4444bd Update InstallOnLinux.md 2024-12-07 06:24:34 +00:00
shuvashish76
525afdf050 Update InstallOnLinux.md 2024-12-07 06:03:46 +00:00
shuvashish76
983cdf6ad5 Update InstallOnLinux.md with repology badge 2024-12-07 05:38:35 +00:00
Robert McRackan
09bb32a435 incr ver 2024-12-06 13:10:37 -05:00
rmcrackan
817ef33fbd Merge pull request #1071 from pixil98/master
update docker image to dotnet 9
2024-12-06 13:07:39 -05:00
Aaron Reisman
be52b496a6 Update docker image to .net 9 2024-12-06 10:29:47 -06:00
Robert McRackan
c0c99db6fa Incr ver 2024-12-05 14:54:16 -05:00
rmcrackan
a1c8fb5921 Merge pull request #1066 from Mbucari/master
Fix chardonnay window closing (#1065)
2024-12-05 14:53:11 -05:00
MBucari
4576c0e193 Updata Avalonia to 11.2.2 2024-12-04 19:12:59 -07:00
MBucari
d592e9435e Fix main window closing when dialog is closed (#1065) 2024-12-04 19:11:36 -07:00
Robert McRackan
9ce6cb54ab incr ver 2024-11-22 17:18:39 -05:00
Robert McRackan
c15d49fc64 publish profiles => .net 2024-11-22 17:04:08 -05:00
Robert McRackan
99be869aa9 yaml => .net9 2024-11-22 16:56:16 -05:00
Robert McRackan
a0e875a79c Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-11-22 16:49:09 -05:00
Robert McRackan
6134becc70 Upgrade to .net9 2024-11-22 16:47:59 -05:00
rmcrackan
eadf7cff79 Merge pull request #1050 from stickystyle/patch-1
set maxdepth to prevent traversal into subdirectories
2024-11-20 13:13:58 -05:00
Ryan Parrish
87ca76f9cb set maxdepth to prevent traversal into subdirectories 2024-11-20 11:32:02 -05:00
Robert McRackan
43d1019059 * Bug fix #1048: docker: Error when using SLEEP_TIME - "integer expression expected"
Thanks @charltonstanley !
2024-11-19 06:40:51 -05:00
Robert McRackan
ed87ded77a Docker deployment fix 2024-11-14 22:51:02 -05:00
rmcrackan
56d4205360 Merge pull request #1042 from pixil98/master
Exclude docker build report artifact from release packing
2024-11-14 22:50:26 -05:00
Aaron Reisman
1f839606ae Add a pattern to exclude docker build artifact from release 2024-11-14 16:33:30 -06:00
Robert McRackan
6eebe652d4 Docker changes => pre-release 2024-11-14 15:22:28 -05:00
rmcrackan
5fff22a0e1 Merge pull request #1011 from pixil98/master
Run docker image as non-root user
2024-11-14 15:19:36 -05:00
Aaron Reisman
cd7040cdc7 pretty up the workflows 2024-11-14 11:18:16 -06:00
Aaron Reisman
97b792868f Update documentation with new envvars 2024-11-14 10:54:35 -06:00
pixil98
984f931f67 Merge branch 'rmcrackan:master' into master 2024-11-14 01:04:16 -06:00
Aaron Reisman
e0dd9b845a rework database handling 2024-11-14 00:59:28 -06:00
rmcrackan
f1c8b320c2 Merge pull request #1039 from jwillikers/fix-metadata
Fix metainfo validation
2024-11-13 21:09:45 -05:00
Jordan Williams
9b7d0cd909 Fix metainfo validation 2024-11-13 15:14:14 -06:00
rmcrackan
99592ff84e Merge pull request #1036 from jwillikers/flatpak-files
Add some necessary files for the Flatpak
2024-11-13 15:36:18 -05:00
Jordan Williams
f97cfe77f9 Add CI to validate the desktop file and appstream metainfo 2024-11-10 15:09:56 -06:00
Jordan Williams
2954cb961b Add metainfo and screenshots 2024-11-10 14:05:24 -06:00
Jordan Williams
1e29b98b82 Add categories and keywords to the desktop file 2024-11-10 14:05:24 -06:00
Robert McRackan
8b76da0dbe incr ver 2024-10-25 15:05:08 -04:00
Mbucari
0a749d2d88 Update Bundle_MacOS.sh 2024-10-25 10:51:56 -06:00
Aaron Reisman
9ed6c1fd0d cleanup 2024-10-22 10:07:37 -05:00
Aaron Reisman
9825e2b552 Build both platforms in one action 2024-10-22 09:27:00 -05:00
Aaron Reisman
011efe3676 remove unused configure step 2024-10-22 00:39:35 -05:00
Aaron Reisman
2bdcc221f5 Specify platform(?) 2024-10-22 00:28:27 -05:00
pixil98
21bedca367 Merge branch 'rmcrackan:master' into master 2024-10-21 23:55:31 -05:00
Aaron Reisman
074fe79ded Update docker workflow to try building on validate 2024-10-21 17:13:08 -05:00
Aaron Reisman
ac8c090c4c Rework run script to support db mount better 2024-10-21 14:02:52 -05:00
Aaron Reisman
ade693bebb Update docker readme 2024-10-19 01:54:37 -05:00
Aaron Reisman
9bc53e45cd large overhaul of docker run script 2024-10-19 01:31:03 -05:00
Aaron Reisman
7d4eaa11e7 Run docker image as non-root user 2024-10-18 00:13:19 -05:00
Robert McRackan
4521c5d5ed incr ver 2024-10-16 12:04:04 -04:00
rmcrackan
eb39f994e1 Merge pull request #1006 from cbordeman/Friendly-name-filters-995
Fixed bug in main view quick filter binding.
2024-10-16 08:03:44 -04:00
Chris Bordeman
c19833b34e Fixed bug in main view quick filter binding. 2024-10-15 21:46:22 -04:00
Robert McRackan
6dcf456d06 fix ver 2024-10-15 13:20:58 -04:00
rmcrackan
8a87462cf5 Merge pull request #997 from cbordeman/Friendly-name-filters-995
Implemented Name on Quick Filters.
2024-10-15 13:09:38 -04:00
Chris Bordeman
9da2a44eff Small fix 2024-10-15 00:13:40 -04:00
Chris Bordeman
7af8d8aa70 Remove some warnings. 2024-10-15 00:07:57 -04:00
Chris Bordeman
4801f37e7c Moved QuickFilters migration to AppScaffolding. 2024-10-14 23:59:05 -04:00
rmcrackan
4f5df44d40 Merge pull request #999 from cbordeman/Add-default-theme-setting
Implemented "System" option for theme.
2024-10-14 15:09:21 -04:00
Chris Bordeman
63e28b13c1 Implemented "System" option for theme. 2024-10-12 02:17:35 -04:00
Chris Bordeman
f92b2b65b2 Implemented Name on Quick Filters. 2024-10-11 05:45:04 -04:00
rmcrackan
f7a4a95e3b Merge pull request #994 from cbordeman/master
Disable shrink/expand on mouseover for all scrollbars.
2024-10-10 07:38:24 -04:00
Chris Bordeman
71b8e9e51c Disable shrink/expand on mouseover for all scrollbars. 2024-10-10 02:22:36 -04:00
Robert McRackan
c6788ccb48 typo 2024-09-25 10:12:01 -04:00
Robert McRackan
0503ee1404 incr ver 2024-09-18 08:17:35 -04:00
rmcrackan
303192c6c3 Merge pull request #978 from muchtall/allow-UseCoverAsFolderIcon-on-linux
Allow Non-Windows installations to populate Windows folder artwork
2024-09-18 08:15:05 -04:00
muchtall
6e21e96aa2 Allow Non-Windows installations to populate Windows folder artwork (https://github.com/rmcrackan/Libation/issues/977) 2024-09-16 19:42:26 +00:00
Robert McRackan
d1d0a7e487 db migration for IsFinished 2024-09-11 07:56:18 -04:00
Robert McRackan
2fd8ea91e1 New search field: Finished/IsFinished.
All work complete except db migration
2024-09-11 07:45:37 -04:00
Robert McRackan
92ee0b2e6d update dependencies 2024-09-11 07:20:54 -04:00
Robert McRackan
f0b5ae1cdc New filter option: IsInSeries, InSeries 2024-09-09 14:22:05 -04:00
Robert McRackan
eb659cc7d7 typo 2024-09-09 08:31:55 -04:00
Robert McRackan
cdd5f229d3 incr ver 2024-09-05 09:12:19 -04:00
Robert McRackan
29edfb7c3f Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-09-05 09:07:54 -04:00
Robert McRackan
c0cb454d45 Make recursive file enumerations safer 2024-09-05 09:07:47 -04:00
rmcrackan
970a77c9e9 Merge pull request #946 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.5
Bump SixLabors.ImageSharp from 3.1.4 to 3.1.5 in /Source/LibationUiBase
2024-07-22 14:05:40 -04:00
dependabot[bot]
3488c8e0f5 Bump SixLabors.ImageSharp from 3.1.4 to 3.1.5 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.4 to 3.1.5.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.4...v3.1.5)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-07-22 17:45:08 +00:00
rmcrackan
5133720cc8 InstallOnMac.md updated instructions 2024-06-30 07:22:38 -04:00
rmcrackan
4150746f45 InstallOnMac.md typo 2024-06-29 08:08:57 -04:00
Robert McRackan
3a95e1e72f incr ver 2024-06-26 08:00:48 -04:00
rmcrackan
c74e26e1af Update release.yml
Release titles: don't include "v" in front of version number
2024-06-26 07:54:11 -04:00
rmcrackan
ae54c95d46 Update docker.yml
Rolling back docker build push action to attempt to fix build
2024-06-26 07:51:00 -04:00
Robert McRackan
56e6bd164b By user request, other Serilog.Sink packages are included for experimental use 2024-06-26 07:07:49 -04:00
rmcrackan
9b28bdceaa Merge pull request #923 from rmcrackan/dependabot/github_actions/docker/build-push-action-6
Bump docker/build-push-action from 5 to 6
2024-06-17 16:33:34 -04:00
dependabot[bot]
b7f7d9004d Bump docker/build-push-action from 5 to 6
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 5 to 6.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v5...v6)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-06-17 14:23:33 +00:00
rmcrackan
1be0991e62 Add Audibly to FAQ 2024-06-07 13:27:18 -04:00
rmcrackan
ff8a2e59c5 Update FrequentlyAskedQuestions.md 2024-05-27 11:48:48 -04:00
Robert McRackan
453904261b Bug fix. Audible's api acts weird when page count gets to ~400 2024-05-16 07:53:19 -04:00
Robert McRackan
f9340db90a update references. audible api bugfix 2024-05-15 06:55:25 -04:00
Robert McRackan
5cd329dd26 refactor 2024-05-13 15:23:37 -04:00
Robert McRackan
b2a882b79d Bug fix #904 -- navigation bug with new Accessibility feature 2024-05-13 15:17:17 -04:00
Robert McRackan
75df78a2f7 Add OSVersion to logs 2024-05-13 14:01:34 -04:00
Robert McRackan
3ad52cbecc docker to .net8 2024-05-09 10:01:38 -04:00
Robert McRackan
27b2fe741c Add accessibility 2024-05-07 10:39:30 -04:00
Robert McRackan
d19fe2250c Add accessibility text to grid stoplight buttons 2024-05-06 21:56:00 -04:00
Robert McRackan
d16d0c8de2 Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-05-05 16:24:07 -04:00
Robert McRackan
c213d5d9f6 Attempt to solve networking issue by disabling ipv6 2024-05-05 16:24:00 -04:00
rmcrackan
c73a023572 Update FrequentlyAskedQuestions.md 2024-04-30 22:42:34 -04:00
rmcrackan
67389917fd Merge pull request #879 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.4
Bump SixLabors.ImageSharp from 3.1.3 to 3.1.4 in /Source/LibationUiBase
2024-04-15 17:03:13 -04:00
dependabot[bot]
b3264d5f42 Bump SixLabors.ImageSharp from 3.1.3 to 3.1.4 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.3 to 3.1.4.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.3...v3.1.4)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-04-15 20:27:24 +00:00
Robert McRackan
962d9b550f "Unable to load module" should just be a warning, not error 2024-04-15 08:03:49 -04:00
Robert McRackan
91ce7272ae Bug fix #874 : Chardonnay wasn't allowing custom directories ending in "Books" 2024-04-04 08:13:37 -04:00
rmcrackan
2f64ca6856 Merge pull request #870 from ScubyG/patch-1
Update InstallOnLinux.md
2024-03-22 07:37:00 -04:00
Davy Jones
cfe5db436c Update InstallOnLinux.md
Fixed typo
2024-03-22 01:23:52 +00:00
rmcrackan
3653fc8094 Merge pull request #863 from rmcrackan/dependabot/github_actions/softprops/action-gh-release-2
Bump softprops/action-gh-release from 1 to 2
2024-03-11 10:45:44 -04:00
dependabot[bot]
662c0ec871 Bump softprops/action-gh-release from 1 to 2
Bumps [softprops/action-gh-release](https://github.com/softprops/action-gh-release) from 1 to 2.
- [Release notes](https://github.com/softprops/action-gh-release/releases)
- [Changelog](https://github.com/softprops/action-gh-release/blob/master/CHANGELOG.md)
- [Commits](https://github.com/softprops/action-gh-release/compare/v1...v2)

---
updated-dependencies:
- dependency-name: softprops/action-gh-release
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-11 14:20:12 +00:00
rmcrackan
44d39eabdb Merge pull request #861 from rmcrackan/dependabot/nuget/Source/LibationUiBase/SixLabors.ImageSharp-3.1.3
Bump SixLabors.ImageSharp from 3.1.2 to 3.1.3 in /Source/LibationUiBase
2024-03-05 11:45:59 -05:00
dependabot[bot]
a0550d5c97 Bump SixLabors.ImageSharp from 3.1.2 to 3.1.3 in /Source/LibationUiBase
Bumps [SixLabors.ImageSharp](https://github.com/SixLabors/ImageSharp) from 3.1.2 to 3.1.3.
- [Release notes](https://github.com/SixLabors/ImageSharp/releases)
- [Commits](https://github.com/SixLabors/ImageSharp/compare/v3.1.2...v3.1.3)

---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2024-03-05 16:38:00 +00:00
Robert McRackan
14eaca6d45 Update dependency. Security update 2024-03-05 11:36:36 -05:00
Robert McRackan
93ccc206ef Update dependencies 2024-03-04 14:02:42 -05:00
Robert McRackan
d3f0fd711e Remove validation check: SeriesName is null . At least 1 exception found in the wild 2024-02-29 14:44:41 -05:00
rmcrackan
3f4604e877 Update FrequentlyAskedQuestions.md
reformat
2024-02-29 10:15:47 -05:00
rmcrackan
c316709af8 Update FrequentlyAskedQuestions.md
Classic vs Chardonnay
2024-02-29 10:15:01 -05:00
Robert McRackan
221d5c7f1c paypal link 2024-02-19 10:32:26 -05:00
Robert McRackan
5a86a1a27b Typo 2024-02-13 21:42:24 -05:00
rmcrackan
dc5e55de68 Merge pull request #848 from patienttruth/master
Update Docker.md
2024-02-13 07:22:33 -05:00
patienttruth
ee37864a42 Update Docker.md
Included an explanation that you may have to manually input the InProgress variable.
2024-02-13 06:49:33 +00:00
Robert McRackan
efe347667c Better error reporting in audible api -- incl. inner exceptions 2024-01-20 21:44:29 -05:00
Robert McRackan
f27a18bdbb Better error reporting in audible api 2024-01-19 09:30:48 -05:00
Robert McRackan
d1834659d9 incr ver 2024-01-12 09:08:14 -05:00
Robert McRackan
7842b521d7 Update dependencies. Notes about upgrading 2024-01-12 07:44:40 -05:00
Robert McRackan
0822f0229d Revert Avalonia. Bug in 11.0.6 2024-01-04 10:03:33 -05:00
Robert McRackan
26aee4d29d Merge branch 'master' of https://github.com/rmcrackan/Libation 2024-01-03 11:43:59 -05:00
Robert McRackan
17a80a23a8 AAXClean upgrade to .net8 2024-01-03 11:43:45 -05:00
rmcrackan
e26fc9ca62 Merge pull request #808 from rmcrackan/dependabot/github_actions/actions/download-artifact-4
Bump actions/download-artifact from 3 to 4
2023-12-15 09:50:25 -05:00
rmcrackan
a03ccf1143 Merge pull request #807 from rmcrackan/dependabot/github_actions/actions/upload-artifact-4
Bump actions/upload-artifact from 3 to 4
2023-12-15 09:50:11 -05:00
dependabot[bot]
bb8dd615db Bump actions/download-artifact from 3 to 4
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 14:08:26 +00:00
dependabot[bot]
9022a2889f Bump actions/upload-artifact from 3 to 4
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-15 14:08:20 +00:00
rmcrackan
ef049a3b02 Merge pull request #805 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-4
Bump actions/setup-dotnet from 3 to 4
2023-12-05 10:01:34 -05:00
dependabot[bot]
77409750aa Bump actions/setup-dotnet from 3 to 4
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 3 to 4.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-12-05 14:14:17 +00:00
Robert McRackan
1702130b01 Upgrade to .net8 2023-11-15 19:57:39 -05:00
Robert McRackan
b6d1a7e3ba Upgrade to .net8 2023-11-15 19:53:26 -05:00
Robert McRackan
2907ba5c13 Add FAQ 2023-10-20 08:27:20 -04:00
Robert McRackan
6df6c79ac8 New locale: Brazil 2023-10-18 22:33:24 -04:00
rmcrackan
3a9ca5d827 Merge pull request #779 from rmcrackan/dependabot/github_actions/dwenegar/upload-release-assets-2
Bump dwenegar/upload-release-assets from 1 to 2
2023-10-13 14:41:26 -04:00
dependabot[bot]
e1e663e327 Bump dwenegar/upload-release-assets from 1 to 2
Bumps [dwenegar/upload-release-assets](https://github.com/dwenegar/upload-release-assets) from 1 to 2.
- [Release notes](https://github.com/dwenegar/upload-release-assets/releases)
- [Commits](https://github.com/dwenegar/upload-release-assets/compare/v1...v2)

---
updated-dependencies:
- dependency-name: dwenegar/upload-release-assets
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-10-13 14:37:55 +00:00
Robert McRackan
4b00d5fd84 incr ver 2023-09-18 13:58:23 -04:00
rmcrackan
02dbf8aad0 Merge pull request #751 from Mbucari/master
Minor Bug Fixes
2023-09-18 13:56:53 -04:00
rmcrackan
8326389f5c Merge pull request #732 from rmcrackan/dependabot/github_actions/actions/checkout-4
Bump actions/checkout from 3 to 4
2023-09-18 13:56:33 -04:00
dependabot[bot]
34535b3ce1 Bump actions/checkout from 3 to 4
Bumps [actions/checkout](https://github.com/actions/checkout) from 3 to 4.
- [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/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 17:33:43 +00:00
rmcrackan
7e5366ab95 Merge pull request #738 from rmcrackan/dependabot/github_actions/docker/setup-buildx-action-3
Bump docker/setup-buildx-action from 2 to 3
2023-09-18 13:30:24 -04:00
dependabot[bot]
690de9bc5c Bump docker/setup-buildx-action from 2 to 3
Bumps [docker/setup-buildx-action](https://github.com/docker/setup-buildx-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-buildx-action/releases)
- [Commits](https://github.com/docker/setup-buildx-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-buildx-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-18 17:25:20 +00:00
rmcrackan
c976aa2bb2 Merge pull request #739 from rmcrackan/dependabot/github_actions/docker/login-action-3
Bump docker/login-action from 2 to 3
2023-09-18 13:25:07 -04:00
rmcrackan
27f659285d Merge pull request #740 from rmcrackan/dependabot/github_actions/docker/build-push-action-5
Bump docker/build-push-action from 4 to 5
2023-09-18 13:24:55 -04:00
rmcrackan
423a5e7720 Merge pull request #741 from rmcrackan/dependabot/github_actions/docker/setup-qemu-action-3
Bump docker/setup-qemu-action from 2 to 3
2023-09-18 13:24:44 -04:00
Michael Bucari-Tovo
9152e12fe1 Fix #748 2023-09-18 10:14:54 -06:00
Michael Bucari-Tovo
f471c53139 Fix #734 2023-09-18 10:08:15 -06:00
dependabot[bot]
66d055bb90 Bump docker/setup-qemu-action from 2 to 3
Bumps [docker/setup-qemu-action](https://github.com/docker/setup-qemu-action) from 2 to 3.
- [Release notes](https://github.com/docker/setup-qemu-action/releases)
- [Commits](https://github.com/docker/setup-qemu-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/setup-qemu-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:49 +00:00
dependabot[bot]
2bbb35363a Bump docker/build-push-action from 4 to 5
Bumps [docker/build-push-action](https://github.com/docker/build-push-action) from 4 to 5.
- [Release notes](https://github.com/docker/build-push-action/releases)
- [Commits](https://github.com/docker/build-push-action/compare/v4...v5)

---
updated-dependencies:
- dependency-name: docker/build-push-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:46 +00:00
dependabot[bot]
1d3687cf9e Bump docker/login-action from 2 to 3
Bumps [docker/login-action](https://github.com/docker/login-action) from 2 to 3.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v2...v3)

---
updated-dependencies:
- dependency-name: docker/login-action
  dependency-type: direct:production
  update-type: version-update:semver-major
...

Signed-off-by: dependabot[bot] <support@github.com>
2023-09-12 14:50:42 +00:00
Mbucari
daf925157f Update AppScaffolding.csproj 2023-09-02 07:59:30 -06:00
Mbucari
40eec9e674 Merge pull request #730 from Mbucari/master
Fix #729
2023-09-01 18:46:42 -06:00
MBucari
6f0782053e Restore PDF functionality (#729) 2023-09-01 16:30:35 -06:00
Michael Bucari-Tovo
04ad033ba0 Remove appsettings.json from program files dir 2023-08-31 09:36:38 -06:00
Robert McRackan
f12f8ba3ee incr ver 2023-08-30 15:00:20 -04:00
rmcrackan
cc6feb21ff Merge pull request #728 from Mbucari/master
Minor Bug fixes
2023-08-30 14:46:16 -04:00
Michael Bucari-Tovo
c4f2ec428d Allow Settings form resizing 2023-08-30 12:38:28 -06:00
Michael Bucari-Tovo
59689cb647 Update Dependencies 2023-08-30 11:59:40 -06:00
Michael Bucari-Tovo
d7f3758ebc Add support for environment variables and unix shortcuts in appsettings.json path. 2023-08-30 11:47:50 -06:00
Mbucari
49775b019c Update InstallOnMac.md 2023-08-30 09:06:57 -06:00
Michael Bucari-Tovo
e55e969349 Fix issue with save button being disabled. 2023-08-30 08:59:20 -06:00
Mbucari
42a93bfac1 Update AAXClean 2023-08-27 19:47:32 -06:00
Mbucari
f86c77a546 Create directory before trying to create appsettings.json 2023-08-27 19:47:00 -06:00
MBucari
88c35e2a56 Add scrolling view to settings window panels (#720) 2023-08-23 20:25:30 -06:00
MBucari
b405e8b6b2 Remove unised response groups 2023-08-20 11:40:24 -06:00
MBucari
92d283187d Add support for locating mp3 audiobooks 2023-08-20 11:38:43 -06:00
Robert McRackan
51b8cfe71f incr ver 2023-08-17 14:03:40 -04:00
rmcrackan
c80da5357b Merge pull request #716 from Mbucari/master
Fix NRE when item has no category ladders (#715)
2023-08-17 14:02:43 -04:00
Michael Bucari-Tovo
736d7c4a5f Fix NRE when item has no category ladders (#715) 2023-08-17 11:55:28 -06:00
Robert McRackan
f175b7592e incr major ver 2023-08-12 21:45:11 -04:00
rmcrackan
415e6e7bc6 Merge pull request #710 from Mbucari/master
Batch of Fixxed Issues
2023-08-12 21:20:37 -04:00
MBucari
d6a413e8d9 Update Avalonia to 11.0.3 2023-08-12 17:30:08 -06:00
Mbucari
3049de6246 Merge branch 'rmcrackan:master' into master 2023-08-12 17:20:09 -06:00
MBucari
fb9b4eb77e Update audio duration on library scan (#707) 2023-08-12 17:19:56 -06:00
MBucari
e65b6c76a8 Add ability to preview templates on user's books (#700)
Add template editor menu items to main grid context menu
2023-08-12 17:12:50 -06:00
Mbucari
167a021eb1 Change glass icon shading 2023-08-11 10:55:31 -06:00
Mbucari
ff3ac2d6fd Fix MessageBox crash when UI has no Screens (#708) 2023-08-11 09:04:05 -06:00
Robert McRackan
f733079a49 remove Moq 2023-08-10 15:24:43 -04:00
Mbucari
893d68190d Lazy load cover to improve startup time 2023-08-10 10:18:28 -06:00
Mbucari
97f94d8782 Add custom column widths to chardonnay 2023-08-07 15:54:50 -06:00
Mbucari
4b2ce0c2d1 Brighter stoplight colors (#702) 2023-08-07 11:31:22 -06:00
Mbucari
ee00417c6f Add white glow to libation_glass.svg (#701) 2023-08-07 11:31:03 -06:00
Robert McRackan
768afd8ecd incr ver 2023-08-04 11:57:15 -04:00
rmcrackan
32c3fa85ce Merge pull request #699 from Mbucari/master
Fix broken template editor (#698)
2023-08-04 11:55:30 -04:00
Mbucari
6986c8f018 Fix broken template editor (#698) 2023-08-04 09:26:51 -06:00
Robert McRackan
f69c2b1cfc incr ver 2023-08-02 21:55:19 -04:00
rmcrackan
b11675c36a Merge pull request #696 from Mbucari/master
Bug fixes
2023-08-02 21:52:57 -04:00
Mbucari
379c2ed62d Fix account nickname retrieval (#629) 2023-08-02 13:54:49 -06:00
Mbucari
7c8489b52f Fix walkthrough causing freeze (#695) 2023-08-02 13:15:58 -06:00
rmcrackan
c61a863edd Merge pull request #694 from Mbucari/master
Fix DPI scaling bug (#692)
2023-08-01 15:03:21 -04:00
Mbucari
1d54f32ef3 Fix DPI scaling bug (#692) 2023-08-01 11:55:23 -06:00
rmcrackan
fabe4afd94 Merge pull request #691 from Mbucari/master
Fix #686 and enable nullable in FileManager and LibationFileManager
2023-07-30 20:22:55 -04:00
MBucari
61efa3c0c1 Update dependencies 2023-07-30 14:00:12 -06:00
MBucari
fe70daf0bc Update avalonia to v11.0.1 2023-07-30 13:54:44 -06:00
MBucari
34033e7947 Enable Nullable 2023-07-30 13:31:57 -06:00
MBucari
e8c63e9a6e Fix UI control overlapping label (#686) 2023-07-30 13:15:43 -06:00
Robert McRackan
9315165f80 update dependencies 2023-07-21 21:21:48 -04:00
Robert McRackan
ce624399ba incr ver 2023-07-19 07:48:11 -04:00
rmcrackan
63e9700c4a Merge pull request #682 from Mbucari/master
Fix #680 and Add category ladders
2023-07-19 07:46:05 -04:00
Mbucari
914e574bf8 Improve GridView
- Remove LongDescription
- Description has the full description
- Better MyRating updating
2023-07-18 16:18:01 -06:00
Mbucari
b94f9bbc15 Fix grid update bug 2023-07-18 16:11:22 -06:00
Mbucari
4e34834c35 Fix category search indexing 2023-07-18 16:00:06 -06:00
Mbucari
3211b2dc85 Improved Category Ladders 2023-07-18 15:51:02 -06:00
Mbucari
ea6adeb58f Add category ladders 2023-07-17 16:50:45 -06:00
Mbucari
90eccbf2f6 Fix FilePathCache NRE (#680) 2023-07-17 08:55:55 -06:00
rmcrackan
668cd7dba8 Merge pull request #679 from Mbucari/master
Mp3 embedded cuesheet and raw metadata
2023-07-16 14:32:49 -04:00
MBucari
c08b2b575c UI Tweak 2023-07-16 10:57:17 -06:00
MBucari
07eaa48e10 Save raw json metadata 2023-07-16 10:54:05 -06:00
MBucari
3cf5fc1d99 Add mp3 embedded cuesheet (#677) 2023-07-15 10:44:31 -06:00
Robert McRackan
15ad753fa1 update dependencies 2023-07-14 20:58:26 -04:00
rmcrackan
75b984bdb2 Merge pull request #678 from Mbucari/master
Fix quick filter not being applied on startup
2023-07-14 20:53:57 -04:00
Mbucari
f586d1d59f Fix quick filter not being applied on startup 2023-07-13 11:00:05 -06:00
Mbucari
cb91a591f0 inc ver 2023-07-13 09:58:45 -06:00
Mbucari
0c0c556c6a Merge pull request #674 from Mbucari/master
Fix #673
2023-07-13 09:31:28 -06:00
MBucari
ff63b73c09 Fix #673 2023-07-13 09:30:02 -06:00
Mbucari
c1d56adbd2 Add groupbox title 2023-07-12 21:29:00 -06:00
rmcrackan
bcd99fd208 Merge pull request #670 from Mbucari/master
Add products grid scaling setting
2023-07-12 21:10:51 -04:00
Mbucari
d1df10d060 Add products grid scaling setting
- Add Grid Scaling Settings
- Add WinForms DPI migration to remove stored form sizes
- Add textbox clear button
2023-07-12 15:32:37 -06:00
Mbucari
1fa415628f Update ProductsGrid.cs 2023-07-10 11:39:33 -06:00
rmcrackan
a83fe9e532 Merge pull request #667 from Mbucari/master
Fix setting Panel2MinSize min width bug (#666)
2023-07-10 11:19:45 -04:00
Mbucari
f85462ffec Fix setting Panel2MinSize min width bug (#666) 2023-07-10 09:11:38 -06:00
Robert McRackan
156349c293 incr ver 2023-07-10 09:26:26 -04:00
rmcrackan
5976706e40 Merge pull request #664 from Mbucari/startup-2
New settings, context menu, and performance improvements
2023-07-10 09:25:13 -04:00
Mbucari
1e40180f0c Fix unit test 2023-07-09 16:42:08 -06:00
Mbucari
7d09728e6b Add Re-download context menu item 2023-07-09 16:26:58 -06:00
Mbucari
4899ef3007 Add new settings and settings dialog help tips
Add CombineNestedChapterTitles setting (#663)
Add SaveMetadataToFile setting
Add extended setting descriptions for select options
2023-07-09 16:07:13 -06:00
Mbucari
296c2b43eb Remove extra library load and move comments to Main 2023-07-09 10:10:00 -06:00
Mbucari
932472cb91 Add full context menu to call columns 2023-07-09 09:53:28 -06:00
Mbucari
1bf86b05ec Download high quality cover art 2023-07-09 09:35:40 -06:00
Mbucari
5d5e3a6671 improve startup time 2023-07-09 09:23:58 -06:00
Robert McRackan
9720a573c7 incr ver 2023-07-07 20:27:57 -04:00
rmcrackan
1cf01aa92a Merge pull request #660 from Mbucari/master
Crash logging to chardonnay
2023-07-07 20:27:09 -04:00
Mbucari
4df9e5abbf Add unhandled error handling and crash logging to chardonnay 2023-07-07 14:14:12 -06:00
Mbucari
9243aa47e7 Upgrade Avalonia to v11.0.0 2023-07-07 14:13:54 -06:00
rmcrackan
c69f41a2a6 Merge pull request #659 from Mbucari/master
Fix classic scaling on high dpi displays
2023-07-07 08:06:22 -04:00
Mbucari
27c74e52ca Fix classic scaling on high dpi displays 2023-07-06 21:34:29 -06:00
Robert McRackan
bfa7f5cca9 Bug fix #657 : Settings dialog size was recently changed. Save and Cancel buttons were pushed outside of the dialog's bounds 2023-07-06 09:27:52 -04:00
rmcrackan
22a3dcbc1f Merge pull request #656 from Mbucari/master
Fix query parsing tags with underscores (#655)
2023-07-06 09:16:20 -04:00
Mbucari
ec9d11cf52 Fix query parsing tags with underscores (#655) 2023-07-05 15:47:37 -06:00
Mbucari
fbc29dfb0a Set Variety correctly 2023-07-04 09:58:39 -06:00
Robert McRackan
03d30ff6af incr. ver. 2023-07-03 22:06:00 -04:00
rmcrackan
ecfe0dc033 Merge pull request #651 from Mbucari/master
Overhaul LibationCli and add Download Quality Option
2023-07-03 21:57:04 -04:00
Mbucari
f2d475a9b0 Add audiobookshelf tags for m4b and mp3
Fix the following tag fields so they are correctly parsed and displayed in audiobookshelf:
Language
Publisher
Series name and number
ASIN
2023-07-03 15:57:11 -06:00
Mbucari
86124fc609 Address comments 2023-07-03 10:01:25 -06:00
Mbucari
db2b10d2a4 Performance improvement 2023-07-03 07:04:29 -06:00
Mbucari
83402028fd Update Avalonia 2023-07-02 19:27:58 -06:00
Mbucari
423b5312f7 Add setting to choose downloaded audio quality ((#648) 2023-07-02 19:19:28 -06:00
Mbucari
3be7d8e825 Minor cli edits and fix potential deadlock 2023-07-02 18:29:36 -06:00
Mbucari
29803c6ba0 Overhaul LibationCli
Add version verb with option to check for upgrade
Add Search verb to search the library
Add export file type inference
Add more set-status options
Add console progress bar and ETA
Add processable option to liberate specific book IDs
Scan accounts by nickname or account ID
Improve startup performance for halp and on parsing error
More useful error messages
2023-07-02 15:01:10 -06:00
Mbucari
bb05847b25 Improve finding audio file by ID 2023-07-02 14:08:27 -06:00
Robert McRackan
5219ad53e1 incr ver 2023-07-01 21:34:36 -04:00
Mbucari
30aa691aae Merge pull request #646 from Alanoll/feat-add-book-subtitles
feat: add Book subtitle capturing so TitleShort reflects titles better
2023-07-01 12:47:03 -05:00
Mbucari
83fa73cef5 Integrate new Title and Subtitle properties into Libation 2023-06-29 21:06:54 -06:00
Alanoll
2195574422 feat: add Book subtitle capturing so TitleShort reflects titles better 2023-06-26 12:18:15 -05:00
Robert McRackan
74ce408c8b incr ver 2023-06-25 21:27:59 -04:00
rmcrackan
85be15b843 Merge pull request #642 from Mbucari/master
Bug fixes and minor features
2023-06-25 21:26:24 -04:00
MBucari
b4b85cd485 Change the default file timestamp source 2023-06-25 17:28:26 -06:00
Mbucari
0093968537 Merge branch 'rmcrackan:master' into master 2023-06-25 15:25:52 -06:00
MBucari
1b09b1fd48 Remove multispace instances from template filenames (#637) 2023-06-25 15:14:10 -06:00
MBucari
ac87d70613 Add options to set file created/modified timestamps (#638) 2023-06-25 14:07:39 -06:00
MBucari
a5d98364fa Enable auto-downloading (#636) 2023-06-25 11:12:52 -06:00
MBucari
ca0e639a19 Commit account edits before saving (#639) 2023-06-25 11:11:58 -06:00
Robert McRackan
b0e3022988 incr ver 2023-06-15 21:40:35 -04:00
rmcrackan
6765c2bfa7 Merge pull request #633 from Mbucari/master
User series order float (#632)
2023-06-15 21:38:02 -04:00
Mbucari
94d3742317 Update NamingTemplates.md 2023-06-15 12:33:58 -06:00
Mbucari
bd3e833dc1 Use series order float (#632)
Add decimal formatter to number tag types
2023-06-15 10:42:36 -06:00
rmcrackan
a386ace0e6 Update NamingTemplates.md
Add \<account nickname\>
2023-06-14 14:06:21 -04:00
rmcrackan
8221d7e202 Merge pull request #631 from Mbucari/master
Add features #626 and #627 and Fix #628
2023-06-14 14:03:24 -04:00
Robert McRackan
fa92946d20 incr ver 2023-06-14 14:02:50 -04:00
Mbucari
6d13325c4f Add <account nickname> tag (#629) 2023-06-14 11:56:38 -06:00
Mbucari
7a9c6720c7 Fix Stupid 2023-06-14 11:35:11 -06:00
Mbucari
697f797509 Remove debug code 2023-06-14 11:16:53 -06:00
Mbucari
ec9854212a Write error info to StdErr (#626) 2023-06-14 10:58:37 -06:00
Mbucari
46f6ba1710 Add feature #627 and fix bug #628
- Feature: Option to overwrite existing audio files when moving to Books
- Bugfix: Do not set liberated status if moving files fails.
2023-06-14 10:51:43 -06:00
rmcrackan
7347244f0a Merge pull request #630 from CLHatch/patch-1
Update Advanced.md
2023-06-14 07:17:45 -04:00
CLHatch
c29c4c470c Update Advanced.md
M4B files use the `@wrt` instead of `TCOM` tag for "composer".
2023-06-14 02:33:49 -05:00
rmcrackan
ee51fd9da6 Merge pull request #625 from Mbucari/master
Refactor LibationSearchEngine
2023-06-13 12:39:42 -04:00
Mbucari
2c4705de6e Address #625 comments and refactor 2023-06-13 09:05:17 -06:00
Mbucari
b4aa220051 Refactor LibationSearchEngine 2023-06-12 14:02:55 -06:00
Robert McRackan
4ab6da132b Bug fix #621 2023-06-12 10:13:40 -04:00
Mbucari
b006429a53 Fix #621 (#624) 2023-06-11 21:05:42 -06:00
Robert McRackan
54d157d244 another tag fail. incr ver 2023-06-11 17:07:03 -04:00
Robert McRackan
a4dfdf80e4 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-06-11 17:03:55 -04:00
Robert McRackan
d8c90bc745 incr ver 2023-06-11 17:03:35 -04:00
Mbucari
46accddd2d Merge pull request #623 from Mbucari/master
Redesign query sanitizer (#618)
2023-06-11 11:51:13 -06:00
Mbucari
f40ecbc07e Merge branch 'rmcrackan:master' into master 2023-06-11 11:33:28 -06:00
Mbucari
536982cb5f Remove obsolete code 2023-06-11 09:44:30 -06:00
Mbucari
ea3d96329b Add query sanitization unit tests 2023-06-11 09:44:21 -06:00
rmcrackan
e87fcbb16f Update Settings documentation 2023-06-11 10:04:00 -04:00
Mbucari
541cf79b6f Redesign query sanitizer (#618) 2023-06-10 15:08:50 -06:00
Robert McRackan
55fa82f92e New incr ver. Previous Tag attempt did generate builds; did not draft a new release 2023-06-09 11:49:59 -04:00
Robert McRackan
4a0c2b2180 Bug fix #618 2023-06-09 11:27:40 -04:00
Mbucari
c77fe5d561 Add Asin query tokenizer 2023-06-08 14:23:39 -06:00
Robert McRackan
359d082ffd incr ver 2023-06-03 15:06:12 -04:00
rmcrackan
017bdba404 Merge pull request #616 from Mbucari/master
Fix #612 and update Avalonia to v11-rc1
2023-06-03 15:04:56 -04:00
Mbucari
d4bf13b3fd Update Hangover Avalonia to v11-rc1 2023-06-03 00:30:02 -06:00
Mbucari
87b695b2de Merge branch 'rmcrackan:master' into master 2023-06-03 00:01:10 -06:00
Mbucari
222b16113e Update NamingTemplates.md 2023-06-03 00:00:01 -06:00
Mbucari
75c07c3209 Fix SavePodcastsToParentFolder setting (#612) 2023-06-02 23:54:32 -06:00
Mbucari
e640edee7f Use proper key name 2023-06-02 23:53:48 -06:00
Mbucari
6c48fc1f5e Update avalonia ro v11-RC1 2023-06-02 23:39:16 -06:00
Mbucari
e5708a382b Use new synchronous UI invoker 2023-06-02 23:21:55 -06:00
Robert McRackan
da9cb3371f incr ver 2023-05-23 13:06:09 -04:00
rmcrackan
91d0f8020e Merge pull request #606 from Mbucari/master
Corectly read and write locales
2023-05-23 13:04:35 -04:00
Mbucari
156726ca95 Corectly read and write locales 2023-05-23 09:41:28 -06:00
rmcrackan
3dad4c194b Update README.md 2023-05-20 23:12:23 -04:00
Mbucari
6025a7538a Merge pull request #604 from Mbucari/master
Fix rpm upgrade
2023-05-19 16:39:04 -06:00
Mbucari
824f65baae Fix rpm upgrade 2023-05-19 16:37:00 -06:00
Mbucari
9372a7318b inc ver 2023-05-19 13:46:26 -06:00
Mbucari
ddd032c16d Fix null string in integer fields 2023-05-19 13:36:22 -06:00
Mbucari
9aaf523240 Update InstallOnMac.md 2023-05-19 13:07:33 -06:00
Mbucari
8cbdeb38fa Update InstallOnLinux.md 2023-05-19 13:05:29 -06:00
Mbucari
a9258a1811 Update GettingStarted.md 2023-05-19 13:01:25 -06:00
Robert McRackan
0dbc42c407 incr ver 2023-05-19 14:43:17 -04:00
rmcrackan
2c91de1b3b Merge pull request #603 from Mbucari/master
New searches, new linux release, new Avalonia build
2023-05-19 14:23:05 -04:00
Mbucari
607cd07b74 Change IInteropFunctions.ReleaseIdentifier to ReleaseIdString 2023-05-19 12:08:22 -06:00
Mbucari
64d080336c Use correct package manager 2023-05-19 11:30:09 -06:00
Mbucari
fd510861c6 Add AbsentFromLastScan (#601) LastDownloaded (#602) search 2023-05-19 11:09:57 -06:00
Mbucari
3fdfbb9e26 Improve episode sequence detection (#600) 2023-05-19 11:07:42 -06:00
Mbucari
3e74898dac Merge branch 'rmcrackan:master' into master 2023-05-19 09:31:47 -06:00
Mbucari
d6fe3013ab RPM build 2023-05-19 09:30:11 -06:00
Robert McRackan
265794bae0 update dependencies 2023-05-19 11:24:47 -04:00
Mbucari
7586f7a159 Upgrade Avalonia to v11.0.0-preview8 2023-05-15 12:58:45 -06:00
Mbucari
5dfddfb549 Use avres DataGrid theme and only replace DataGridColumnHeader 2023-05-15 12:51:46 -06:00
Mbucari
98bb06378a Update Avalonia to v11.0.0-preview8 2023-05-15 10:54:56 -06:00
Robert McRackan
429367d21c incr ver 2023-04-17 21:39:18 -04:00
Mbucari
ea9e36fd76 Merge pull request #588 from Mbucari/master
Use old activation bytes if present.
2023-04-17 19:34:26 -06:00
MBucari
fe534b335b Update dependencies 2023-04-17 19:32:52 -06:00
MBucari
6db3a8fbf3 Use old activation bytes if present. 2023-04-17 16:09:47 -06:00
Robert McRackan
48c69a1339 Can log to zip files with new ZipFile sink 2023-04-15 15:55:53 -04:00
rmcrackan
1ab882f327 Merge pull request #587 from Mbucari/master
Serilog log to zip file
2023-04-15 15:53:41 -04:00
MBucari
019b110a8a Fix #585 2023-04-15 13:43:50 -06:00
MBucari
9e14169e15 Update dependencies 2023-04-15 13:39:43 -06:00
MBucari
e08a68219d Add Serilog.Sinks.ZipFile to write logs into a zip file 2023-04-15 12:45:20 -06:00
Mbucari
af24c6e07b Merge branch 'rmcrackan:master' into master 2023-04-15 10:58:06 -06:00
Robert McRackan
e31847e669 Incr. ver. 2023-04-14 14:38:45 -04:00
Mbucari
c4f55d2ad1 Change "Click here" link verbiage 2023-04-14 11:37:22 -06:00
rmcrackan
1439e38cb0 Merge pull request #584 from Mbucari/master
Web Browser Login for Windows
2023-04-14 13:33:23 -04:00
Mbucari
4456432116 Add WebLoginDialog for Windows Chardonnay 2023-04-13 19:16:32 -06:00
Mbucari
df2936e0b6 Use WebLoginDialog as primary login method on Win10+ 2023-04-13 09:10:13 -06:00
Mbucari
53b5c1b902 Fix rare bug where episode may not sort beneath its parent 2023-04-11 14:43:01 -06:00
Mbucari
82fba7e752 Grid refresh performance and behavior improvements 2023-04-11 14:33:45 -06:00
rmcrackan
1a95f2923b Merge pull request #579 from Mbucari/master
Bug fixes and more shared code moved to UI base
2023-04-10 22:47:24 -04:00
Mbucari
1939aae81c Simplify and comment 2023-04-10 19:50:30 -06:00
Mbucari
9a663fda15 Filtering bugfix 2023-04-10 17:08:09 -06:00
Mbucari
84b2996102 Merge branch 'rmcrackan:master' into master 2023-04-10 16:17:24 -06:00
Mbucari
af8e1cd5ef Change episode default sorting to SeriesOrder descending 2023-04-10 16:17:10 -06:00
Mbucari
8a1b375f0d Fix #574 (for realsies this time) 2023-04-10 15:00:32 -06:00
Mbucari
6800986f25 Update GridEntryBindingList to behave move like Chardonnay 2023-04-10 14:10:50 -06:00
Mbucari
6110b08d16 Fix typo 2023-04-10 13:05:50 -06:00
Mbucari
666b5d83df Move filter query and RowComparer into UI base 2023-04-10 13:05:38 -06:00
rmcrackan
7db5a34f1b Merge pull request #577 from Mbucari/master
Fixed your issues
2023-04-10 13:14:05 -04:00
Mbucari
e52772826a Merge branch 'rmcrackan:master' into master 2023-04-09 17:45:38 -06:00
Mbucari
8ea9b2abc6 Fix #574 2023-04-09 17:41:24 -06:00
Mbucari
c10bb276f5 Fix #575 2023-04-09 17:41:10 -06:00
Mbucari
9dcb3b3a25 Slight chardonnay refactor and UI tweak 2023-04-09 17:39:31 -06:00
Robert McRackan
d857882220 Bug fix: logins 2023-04-07 19:58:13 -04:00
rmcrackan
d731db4036 Update Docker.md 2023-04-05 08:02:42 -04:00
rmcrackan
ca5b40b176 Merge pull request #571 from Mbucari/master
Chardonnay UI Refinements and Refactor
2023-04-05 08:00:44 -04:00
MBucari
b29ec26f63 Remove useless interface 2023-04-04 22:38:02 -06:00
MBucari
7569b01bd0 MacOS Compatibility 2023-04-04 22:26:13 -06:00
MBucari
6465b0a885 Fix possible NRE 2023-04-04 19:17:43 -06:00
Mbucari
5e99cb6f02 Refine dialog layouts and presentation 2023-04-04 19:08:52 -06:00
Mbucari
d737cd2199 Improve LinkLabel control 2023-04-04 12:10:23 -06:00
Mbucari
2d2907e076 Refactor settings dialog 2023-04-04 11:18:28 -06:00
Mbucari
05c454dce4 Fix directory select controls 2023-04-04 10:49:27 -06:00
rmcrackan
e64a9d2adf Merge pull request #566 from Mbucari/master
Reattach event handlers
2023-04-03 16:23:10 -04:00
Mbucari
6252f015b3 Reattach event handlers 2023-04-03 14:09:22 -06:00
rmcrackan
7ada0082a9 Merge pull request #565 from Mbucari/master
About Dialog, mac menus, and hotkeys
2023-04-03 15:54:40 -04:00
Mbucari
826e53c9cb Remove assemblies add acknowledgements to About 2023-04-03 13:34:20 -06:00
Mbucari
2248d7b24e Sort episodes by column beneath their parents 2023-04-02 21:28:55 -06:00
Mbucari
69918c2587 Cleanup 2023-04-02 21:28:37 -06:00
Michael Bucari-Tovo
1991bf5b4d Add more info to About dialog 2023-04-02 18:16:01 -06:00
MBucari
756d387238 UI Tweaks and new application hotkeys 2023-04-02 15:08:03 -06:00
MBucari
8d73f5cc7e Add About dialog 2023-04-02 13:27:51 -06:00
MBucari
4a65d6bbd3 Add native menu for mac and refactor MainWindow 2023-04-01 23:58:22 -06:00
Robert McRackan
10a1b56b3c incr ver 2023-03-31 16:39:13 -04:00
Robert McRackan
66fb392b7f Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-31 16:16:23 -04:00
Robert McRackan
49ef96055c update dependencies 2023-03-31 16:16:21 -04:00
rmcrackan
cb4a209f69 Merge pull request #564 from Mbucari/master
Fix #563 and probably fix #534
2023-03-31 14:27:43 -04:00
Mbucari
255e18eb5e Fix external login failure error (#563) 2023-03-31 12:00:20 -06:00
Mbucari
7e1ec47b46 Tweak AccessKeyHandler 2023-03-31 11:59:48 -06:00
MBucari
40c725b8c2 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-30 19:58:19 -06:00
MBucari
5d0937dc48 Add support for custom access keys 2023-03-30 19:57:39 -06:00
Robert McRackan
bff81bfc4b update paypal links 2023-03-30 09:44:20 -04:00
MBucari
aa7c159985 Define window dimensions 2023-03-29 19:44:33 -06:00
Robert McRackan
012d94a146 incr ver 2023-03-29 18:02:33 -04:00
Mbucari
22bd1ed121 Fix autoscan bug 2023-03-29 15:54:46 -06:00
Mbucari
c832f26b08 Merge pull request #561 from Mbucari/master
Try fix #560
2023-03-29 15:40:52 -06:00
Mbucari
efd73d334e inv ver 2023-03-29 15:39:25 -06:00
Mbucari
0db3ee6fd7 Fix library scan bug 2023-03-29 15:38:57 -06:00
Robert McRackan
6aaf4f63d1 incr major ver 2023-03-29 15:58:57 -04:00
rmcrackan
ab392a9285 Merge pull request #558 from Mbucari/master
Refined Walkthrough
2023-03-29 15:54:15 -04:00
Mbucari
efc9ff4bd8 Disable buttons on new row 2023-03-29 13:31:39 -06:00
Mbucari
a52b466c85 Fix QuickFilter Walkthrough 2023-03-29 13:17:31 -06:00
Mbucari
5611431abf Quick Filters display moveup and movedown buttons appropriately 2023-03-29 13:06:18 -06:00
Mbucari
a75932d1f4 Refine Walkthrough 2023-03-29 11:35:17 -06:00
Mbucari
6c8464b650 Use HashSet 2023-03-29 11:32:07 -06:00
rmcrackan
ba4a1c5a51 Merge pull request #554 from Mbucari/master
Bug fixes and guided tour
2023-03-28 16:49:49 -04:00
Mbucari
3681c0f18f Final walkthrough tweaks 2023-03-28 14:08:51 -06:00
Mbucari
e365ba7296 Use AvaloniaList properties 2023-03-28 13:29:07 -06:00
Mbucari
2afb5365dd Add search and quick filters to walkthrough 2023-03-28 12:30:05 -06:00
Mbucari
00cf7693d5 Add code comments 2023-03-28 10:02:22 -06:00
MBucari
dac6877a06 Fix #556 2023-03-28 07:09:46 -06:00
MBucari
36005508a1 Allow users to cancel walkthrough 2023-03-27 20:24:15 -06:00
MBucari
d9e27fd32e Bring cover viewer to front 2023-03-27 19:56:50 -06:00
MBucari
d86bcbb414 Add usings 2023-03-27 19:52:26 -06:00
MBucari
00cbab5b58 Update window title 2023-03-27 19:51:10 -06:00
MBucari
807725f6ff Replace editable DataGridTextColumn with TextBox (#552) 2023-03-27 19:40:23 -06:00
MBucari
ec9356b36e Do not import orphaned episodes (#553) 2023-03-27 18:58:43 -06:00
MBucari
add31024da Improve book availability detection (#551) 2023-03-27 17:53:25 -06:00
MBucari
27d2ada5a4 Don't warn for blank password with external login 2023-03-27 17:23:46 -06:00
Mbucari
702219ee69 Add guided walkthrough 2023-03-27 16:18:21 -06:00
Mbucari
cdf1a01457 Do not launch settings dialog after installation 2023-03-27 13:18:37 -06:00
Mbucari
a71ccbac6e Add Series Order column 2023-03-27 12:13:56 -06:00
Mbucari
f8c6b836c3 Merge branch 'rmcrackan:master' into master 2023-03-27 11:15:19 -06:00
Michael Bucari-Tovo
090871f50d More migrations to Avalonia 11.0.0-preview6 2023-03-27 11:14:54 -06:00
Robert McRackan
e62f01d2a3 Incr ver so bug fixes can be released. New features will also be announced in upcoming new major ver 2023-03-27 08:48:53 -04:00
Mbucari
68af6a5ebb Merge branch 'rmcrackan:master' into master 2023-03-26 21:04:53 -06:00
Michael Bucari-Tovo
8bba8538d5 Recheck for partially downloaded files. 2023-03-26 20:54:29 -06:00
rmcrackan
2cd9b86930 Merge pull request #549 from Mbucari/master
Lots of Bug Fixes and 2 New Features.
2023-03-26 22:54:08 -04:00
MBucari
b876d90964 Remove AudibleApi from solution 2023-03-26 08:43:33 -06:00
Mbucari
49c91c273b Merge branch 'rmcrackan:master' into master 2023-03-26 08:08:36 -06:00
MBucari
c07bc88493 Update AudibleApi 2023-03-25 21:18:38 -06:00
MBucari
397a516dc1 Fix (#548) 2023-03-25 21:18:38 -06:00
Robert McRackan
1c2b51aa83 update dependencies 2023-03-25 22:25:37 -04:00
Mbucari
fc6f494f0d Add dark mode support 2023-03-25 16:33:11 -06:00
Mbucari
7289459170 Migrate to Avalonia 11.0.0-preview6 2023-03-22 13:44:25 -06:00
Mbucari
ed6f741a65 Fix SettingsFileIsValid 2023-03-22 11:46:11 -06:00
Mbucari
1783da3e2d Ensure series and episode DateAdded is never default (#543) 2023-03-22 11:02:57 -06:00
Mbucari
e7eac7bed3 Log DTO items even if validation fails 2023-03-22 11:00:51 -06:00
MBucari
9ae1f0399b Add SeriesViewDialog 2023-03-22 08:28:20 -06:00
MBucari
784ab73a36 Add context menu to Series grid entries (#536) 2023-03-19 12:04:51 -06:00
MBucari
99687e968e Create books directory if not found (#542) 2023-03-19 10:19:38 -06:00
MBucari
565c84c4ab Add series # to grid display (#529) 2023-03-17 22:11:04 -06:00
MBucari
18cf20ecad All books that pass the filter are counted as "visible" (#536) 2023-03-17 19:52:47 -06:00
MBucari
2725340994 Suppress VisibleCountChanged firing when updating grid 2023-03-17 17:51:39 -06:00
MBucari
56de1e7659 Preserve "expanded" status when updating library 2023-03-17 17:47:27 -06:00
Mbucari
fd16e97632 When book is unavailable, check other accounts (#535) 2023-03-17 14:06:02 -06:00
Robert McRackan
36076242a7 Bug fix #532 : Possible rull ref exception for pre-amazon germany 2023-03-15 13:49:11 -04:00
Robert McRackan
718e6c14d0 update dependency 2023-03-14 22:10:55 -04:00
rmcrackan
eb61ba3d69 Merge pull request #531 from Mbucari/master
Bug fixes and performance improvements
2023-03-14 07:53:25 -04:00
MBucari
defabf7356 Use new AudibleApi methods 2023-03-13 21:00:25 -06:00
Mbucari
1149c10cf1 Merge branch 'rmcrackan:master' into master 2023-03-13 20:49:56 -06:00
MBucari
ec7dd1b54a Use new AudibleApi methods 2023-03-13 20:47:32 -06:00
Robert McRackan
bb900b31ef update dependencies 2023-03-13 22:28:46 -04:00
MBucari
eed42bd108 Improve grid update performance 2023-03-11 21:50:30 -07:00
MBucari
3f0e6b9ee5 Fix window restore maximize statate on secondary monitor. 2023-03-11 21:35:33 -07:00
MBucari
5ec01913d5 Fix bug where book with corrupt image cannot be queued. 2023-03-11 20:58:06 -07:00
rmcrackan
245e55782e Merge pull request #527 from Mbucari/master
Improve Library Display performance and Refactor grid viewmodels
2023-03-11 16:44:31 -05:00
MBucari
cc306e0e19 Fix expand/collapse button icon in Avalonia 2023-03-11 12:28:34 -07:00
MBucari
26a9bc6bbf Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-11 11:12:05 -07:00
MBucari
fb9d062545 WinForms and Avalonia now share all GridEntry view models 2023-03-11 11:10:58 -07:00
MBucari
49c6b391fd WinForms and Avalonia now share all GridEntry view models 2023-03-10 20:00:25 -07:00
MBucari
e1cd8b8f94 Improve Library load and refresh performance 2023-03-10 19:01:49 -07:00
Robert McRackan
ef1edf1136 AYCL bug fix: US and Italy 2023-03-10 15:37:51 -05:00
rmcrackan
0def1b426a Merge pull request #526 from Mbucari/master
Add better AYCL detection and add verbose library scan logging
2023-03-10 15:26:41 -05:00
Mbucari
230e014bb1 Add better AYCL detection and add verbose library scan logging 2023-03-10 13:09:59 -07:00
Robert McRackan
34f56d2fd7 Merge branch 'master' of https://github.com/rmcrackan/Libation 2023-03-08 14:07:51 -05:00
Robert McRackan
c45ffaf4a6 Incr ver 2023-03-08 14:07:47 -05:00
rmcrackan
ae43ab103e Merge pull request #524 from Mbucari/master
Improve library scan speed and Track and display book availability
2023-03-08 14:06:45 -05:00
Mbucari
559977ce0b Add 'Unavailable' book and pdf counts. 2023-03-08 11:26:07 -07:00
Mbucari
ccd4d3e26d Check for null Plan array 2023-03-08 11:21:47 -07:00
MBucari
e76f99ff28 Fix rmcrackan/Libation#523 2023-03-07 22:34:36 -07:00
MBucari
d3607583ab Tweak episode scan 2023-03-07 20:32:50 -07:00
MBucari
3ebd4ce243 Show AbsentFromLastScan book status in grid 2023-03-07 20:02:22 -07:00
Mbucari
f6dcc0db1d Add AbsentFromLastScan 2023-03-07 18:58:18 -07:00
MBucari
bd49db83e4 Improve library scan performance 2023-03-07 15:30:22 -07:00
Mbucari
4140722a6d Merge branch 'master' of https://github.com/Mbucari/Libation 2023-03-06 16:56:57 -07:00
Mbucari
da36f9414d Improve library scan performance 2023-03-06 16:49:52 -07:00
Mbucari
1510f71ca6 Merge branch 'rmcrackan:master' into master 2023-03-03 16:33:36 -07:00
Mbucari
cdb27ef712 Add last downloaded info to exports 2023-03-03 15:06:06 -07:00
Robert McRackan
790319ed98 incr ver 2023-03-03 15:58:05 -05:00
rmcrackan
1b0fb2b316 Merge pull request #522 from Mbucari/master
Resolved Several Issues (It's not as bad as  2,453 lines suggests)
2023-03-03 15:56:59 -05:00
Mbucari
02371f2221 Deleting folders with custom icons no longer triggers system file warning 2023-03-03 10:56:31 -07:00
Mbucari
2b672f86be Fatten up the chevrons 2023-03-02 19:57:43 -07:00
Mbucari
36176bff33 Update ImageSharp 2023-03-02 19:41:59 -07:00
Mbucari
174b0c26b8 Update fileicon to latest version 2023-03-02 19:40:38 -07:00
Mbucari
26c60e8e79 Convert queue expand/collapse button text to images (rmcrackan/Libation#339) 2023-03-02 19:23:03 -07:00
Mbucari
d94759d868 Add Last Download column to grid (rmcrackan/Libation#498) 2023-03-02 18:52:45 -07:00
Mbucari
bd7e45ca3c Add last download into to database 2023-03-02 15:09:10 -07:00
Mbucari
52a863c62a Add audiobook Trash Bin 2023-03-02 13:12:32 -07:00
Mbucari
fe55b90ee3 Fix rmcrackan/Libation#511 2023-03-01 22:14:57 -07:00
Mbucari
df224cc7f3 Move TrackedQueue to LibationUiBase 2023-03-01 09:33:17 -07:00
Mbucari
2a59329350 Merge branch 'rmcrackan:master' into master 2023-02-28 16:41:14 -07:00
Mbucari
abdf0e7261 Parallelize post-liberation tasks 2023-02-28 16:40:53 -07:00
Mbucari
b9c2a1cce3 Add folder icon support to MacOS 2023-02-28 15:57:27 -07:00
rmcrackan
aa86fca08f Update InstallOnMac.md 2023-02-28 15:46:51 -05:00
Robert McRackan
cf9ec9facf did last tag incorrect. New version 2023-02-28 10:13:26 -05:00
Robert McRackan
f6084ef10c v9.4.1 2023-02-28 10:04:47 -05:00
rmcrackan
740b73beb7 Merge pull request #518 from Mbucari/master
Improve Audible login and Libation Upgrade
2023-02-28 09:51:08 -05:00
Mbucari
5c45802391 Fixed review comments 2023-02-28 07:42:26 -07:00
MBucari
429aa603f5 Update workflows 2023-02-27 21:41:59 -07:00
MBucari
80ea394934 Merge branch 'master' of https://github.com/Mbucari/Libation 2023-02-27 16:33:16 -07:00
Mbucari
bce4437c79 Change workflows 2023-02-27 16:18:48 -07:00
Mbucari
b6ad1a289b Remove windows desktop runtime dependency from chardonnay 2023-02-27 16:13:40 -07:00
Mbucari
2a22d05f37 Remove windows desktop runtime dependency from chardonnay 2023-02-27 15:08:54 -07:00
Mbucari
d787843fd2 Unify upgrade process and add update progress bar 2023-02-27 14:08:15 -07:00
Mbucari
ded58f687d Update 2FA and Captcha controls 2023-02-27 14:08:14 -07:00
Mbucari
1f1f34b6ce Merge branch 'rmcrackan:master' into master 2023-02-27 09:36:53 -07:00
Mbucari
ffadf90f4f Fix MFA and 2FA 2023-02-27 09:36:19 -07:00
rmcrackan
67807efacf Merge pull request #515 from Mbucari/patch-4
Update InstallOnMac.md
2023-02-26 15:29:55 -05:00
Mbucari
980f5afa54 Update InstallOnMac.md 2023-02-25 19:42:45 -07:00
Robert McRackan
b2f68760b2 New audible api login 2023-02-24 15:52:14 -05:00
rmcrackan
faf86711a5 Merge pull request #509 from Mbucari/master
Add More MP3 Options and improved AAXClean
2023-02-24 15:35:38 -05:00
Mbucari
4a78b9d28f Revert workflow change 2023-02-24 12:38:29 -07:00
Michael Bucari-Tovo
1b0a7f5062 New mp3 options and improved encoding performance 2023-02-24 12:12:41 -07:00
Mbucari
49982043e0 Merge branch 'rmcrackan:master' into master 2023-02-24 11:15:14 -07:00
Robert McRackan
378cf7057e updated to AudibleApi v8 2023-02-24 13:12:18 -05:00
Mbucari
abdc0f018e Update build-linux.yml 2023-02-22 09:23:15 -07:00
Robert McRackan
c65f61b92e Fix paypal links 2023-02-22 07:33:58 -05:00
Robert McRackan
c12805c8ce incr ver for release 2023-02-19 14:55:55 -05:00
rmcrackan
67f9a6db78 Merge pull request #503 from Mbucari/master
Mac and Linux Arm64 releases and Fixed #502
2023-02-19 14:52:09 -05:00
Mbucari
bb6336ce2a Update .releaseindex.json 2023-02-19 11:27:23 -07:00
Michael Bucari-Tovo
af7a4a6acf Add comments 2023-02-19 11:11:28 -07:00
Michael Bucari-Tovo
21d18aa565 Final edits 2023-02-19 10:59:42 -07:00
Michael Bucari-Tovo
c96875ba5d Add '-chardonnay' to build assets name 2023-02-19 10:23:49 -07:00
Michael Bucari-Tovo
6ebbfb8e59 Refactor SetReleaseIdentifier() 2023-02-19 10:20:01 -07:00
Michael Bucari-Tovo
1e6e28cd57 Start downloading asynchronously 2023-02-18 22:38:26 -07:00
Michael Bucari-Tovo
defed72862 Force garbage collection after completing a Processable 2023-02-18 22:16:46 -07:00
Michael Bucari-Tovo
71503b34b5 Fix macOS crash 2023-02-18 20:29:10 -07:00
Michael Bucari-Tovo
a00849fb6f Refactor InteropFactory 2023-02-18 13:57:00 -07:00
Michael Bucari-Tovo
14b63c0883 Add apple UUTYPEs 2023-02-18 10:27:37 -07:00
Michael Bucari-Tovo
59d556733e Edit Mac Build Script 2023-02-17 23:46:28 -07:00
Michael Bucari-Tovo
a99a175683 Update AAXClean to fix #502 2023-02-17 23:20:35 -07:00
Michael Bucari-Tovo
26fedcfb60 Fix DirectorySelectControl not displaying known dir 2023-02-17 22:58:24 -07:00
Michael Bucari-Tovo
dde8024506 More thread safety to address #492 2023-02-17 22:57:43 -07:00
Michael Bucari-Tovo
25f7c29380 New linux build workflows 2023-02-17 18:04:34 -07:00
847 changed files with 56481 additions and 20581 deletions

5
.cdmurls.json Normal file
View File

@@ -0,0 +1,5 @@
{
"CdmUrls": [
"https://ollj0gz40d.execute-api.us-west-2.amazonaws.com/default/AudibleCdm"
]
}

View File

@@ -6,10 +6,14 @@ labels: bug
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.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@@ -17,15 +21,23 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
## Screenshots
If applicable, add screenshots to help explain your problem.
**Platform**
## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here.
## Log Files
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'.

View File

@@ -6,14 +6,26 @@ labels: enhancement
assignees: ''
---
**Is your feature request related to a problem? Please describe.**
**No-go ideas**
There are lots of great ideas and many are beyond what we intend to do for Libation. Some good ideas which we do not intend to pursue:
* comprehensive api/cli
* aax/audiobook import
* bulk rename of existing files
* general metadata/tag editor
* playback features
* web gui
* supporting non-audible vendors
* official docker support
**Is your feature request related to a problem? Please describe.**
A clear and concise description of what the problem is. Ex. I'm always frustrated when [...]
**Describe the solution you'd like**
**Describe the solution you'd like**
A clear and concise description of what you want to happen.
**Describe alternatives you've considered**
**Describe alternatives you've considered**
A clear and concise description of any alternative solutions or features you've considered.
**Additional context**
**Additional context**
Add any other context or screenshots about the feature request here.

View File

@@ -1,81 +1,83 @@
# build-linux.yml
# Reusable workflow that builds the Linux and MacOS versions of Libation.
# Reusable workflow that builds the Linux and MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: 'Version number override'
required: false
run_unit_tests:
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
publish-r2r:
type: boolean
retention-days:
type: number
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
OS:
type: string
description: >
The operating system targeted by the build.
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
required: true
jobs:
build:
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
runs-on: ubuntu-latest
strategy:
matrix:
os: [Linux, MacOS]
ui: [Avalonia]
release_name: [chardonnay]
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
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 -oP '(?<=<Version>).*(?=</Version)' ./Source/AppScaffolding/AppScaffolding.csproj)"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LibationCli/LibationCli.csproj -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
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/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish/${{ matrix.os }}-${{ matrix.release_name }}
- name: Build bundle
id: bundle
run: |
delfiles=("libmp3lame.x86.dll" "libmp3lame.x64.dll" "ffmpegaac.x86.dll" "ffmpegaac.x64.dll")
for n in "${delfiles[@]}"; do rm "$n"; done
osbuild="$(echo '${{ matrix.os }}' | tr '[:upper:]' '[:lower:]')"
artifact="Libation.${{ steps.get_version.outputs.version }}-${osbuild}-${{ matrix.release_name }}"
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
tar -zcvf "../${artifact}.tar.gz" .
- name: Publish artifact
uses: actions/upload-artifact@v3
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.zip.outputs.artifact }}.tar.gz
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.tar.gz
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}

104
.github/workflows/build-mac.yml vendored Normal file
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 }}

View File

@@ -6,80 +6,82 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: 'Version number override'
required: false
run_unit_tests:
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
env:
DOTNET_CONFIGURATION: 'Release'
DOTNET_VERSION: '7.0.x'
publish-r2r:
type: boolean
retention-days:
type: number
jobs:
build:
name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
runs-on: windows-latest
strategy:
matrix:
os: [Windows]
ui: [Avalonia]
architecture: [x64]
release_name: [chardonnay]
include:
- os: Windows
- architecture: x64
ui: WinForms
release_name: classic
prefix: Classic-
- architecture: arm64
ui: Avalonia
release_name: chardonnay
steps:
- uses: actions/checkout@v3
- name: Setup .NET
uses: actions/setup-dotnet@v3
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
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
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj -p:PublishProfile=LoadByOS/Properties/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} LibationCli/LibationCli.csproj -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish -c ${{ env.DOTNET_CONFIGURATION }} -o bin/Publish/${{ matrix.os }}-${{ matrix.release_name }} Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
$PUBLISH_ARGS=@(
"--runtime", "win-${{ matrix.architecture }}",
"--configuration", "Release",
"--output", "../bin",
"-p:PublishProtocol=FileSystem",
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
"-p:SelfContained=true")
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish
working-directory: ./bin
run: |
$dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
$delfiles = @("libmp3lame.so", "ffmpegaac.so", "glass-with-glow_256.svg", "Libation.desktop")
foreach ($file in $delfiles){ if (test-path $dir$file){ Remove-Item $dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${dir}*" -DestinationPath "$artifact.zip"
- name: Publish artifact
uses: actions/upload-artifact@v3
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
if-no-files-found: error
$delfiles = @(
"WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json")
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path * -DestinationPath "$artifact"
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.zip.outputs.artifact }}
path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,25 +6,64 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: 'Version number override'
required: false
run_unit_tests:
description: "Libation version number"
required: true
dotnet-version:
type: string
default: "10.x"
description: ".NET version to target"
run-unit-tests:
type: boolean
description: 'Skip running unit tests'
required: false
default: true
description: "Whether to run unit tests prior to publishing."
publish-r2r:
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:
uses: ./.github/workflows/build-windows.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
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 }}
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:
strategy:
matrix:
OS: [Redhat, Debian]
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
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 }}
OS: ${{ matrix.OS }}

View File

@@ -1,43 +0,0 @@
# build-linux.yml
# Reusable workflow that builds the Libation installation bundles for Linux and MacOS.
---
name: bundle-linux
on:
workflow_call:
inputs:
version:
type: string
description: 'Version number'
required: true
jobs:
bundle:
runs-on: ubuntu-latest
strategy:
matrix:
os: [linux, macos]
release_name: [chardonnay]
steps:
- uses: actions/checkout@v3
- name: Download Artifact
uses: actions/download-artifact@v3
with:
name: "Libation.${{ inputs.version }}-${{ matrix.os }}-${{ matrix.release_name }}.tar.gz"
- name: Build bundle
id: build
run: |
SCRIPT=targz2${{ matrix.os }}bundle.sh
chmod +rwx ./Scripts/${SCRIPT}
./Scripts/${SCRIPT} "Libation.${{ inputs.version }}-${{ matrix.os }}-${{ matrix.release_name }}.tar.gz" ${{ inputs.version }}
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v3
with:
name: ${{ steps.build.outputs.artifact }}
path: ./bundle/${{ steps.build.outputs.artifact }}
if-no-files-found: error

67
.github/workflows/deploy.yml vendored Normal file
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

View File

@@ -8,7 +8,11 @@ on:
inputs:
version:
type: string
description: 'Version number'
description: "Version number"
required: true
release:
type: boolean
description: "Is this a release build?"
required: true
secrets:
docker_username:
@@ -16,31 +20,44 @@ on:
docker_token:
required: true
env:
DOCKER_IMAGE: ${{ secrets.docker_username }}/libation
jobs:
docker:
build_and_push:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v3
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v2
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
uses: docker/setup-buildx-action@v3
- name: Login to Docker Hub
uses: docker/login-action@v2
if: ${{ inputs.release }}
uses: docker/login-action@v3
with:
username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }}
- name: Build and push
uses: docker/build-push-action@v4
- name: Generate docker image tags
id: metadata
uses: docker/metadata-action@v5
with:
push: true
build-args: 'FOLDER_NAME=Linux-chardonnay'
tags: ${{ env.DOCKER_IMAGE }}:latest,${{ env.DOCKER_IMAGE }}:${{ inputs.version }}
flavor: |
latest=true
images: |
name=${{ secrets.docker_username }}/libation
tags: |
type=raw,value=${{ inputs.version }},enable=${{ inputs.release }}
- name: Build and push image
uses: docker/build-push-action@v6
with:
platforms: linux/amd64,linux/arm64
push: ${{ steps.metadata.outputs.tags != ''}}
cache-from: type=gha
cache-to: type=gha,mode=max
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}

View File

@@ -5,17 +5,17 @@ name: release
on:
push:
tags:
- 'v*'
- "v*"
jobs:
prerelease:
runs-on: ubuntu-latest
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get tag version
id: get_version
run: |
export TAG='${{ github.ref_name }}'
export TAG="${{ github.ref_name }}"
echo "version=${TAG#v}" >> "${GITHUB_OUTPUT}"
docker:
@@ -23,6 +23,7 @@ jobs:
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.prerelease.outputs.version }}
release: true
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}
@@ -30,38 +31,30 @@ jobs:
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
libation-version: ${{ needs.prerelease.outputs.version }}
publish-r2r: true
release: true
bundle:
needs: [prerelease,build]
uses: ./.github/workflows/bundle-linux.yml
with:
version: ${{ needs.prerelease.outputs.version }}
release:
needs: [prerelease,build,bundle]
needs: [prerelease, build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v3
uses: actions/download-artifact@v7
with:
path: artifacts
pattern: "*(Classic-)Libation.*"
- name: Release
id: release
uses: softprops/action-gh-release@v1
uses: softprops/action-gh-release@v2
with:
name: Libation v${{ needs.prerelease.outputs.version }}
name: Libation ${{ needs.prerelease.outputs.version }}
body: <Put a body here>
token: ${{ secrets.GITHUB_TOKEN }}
draft: true
prerelease: false
- name: Upload release assets
uses: dwenegar/upload-release-assets@v1
env:
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
with:
release_id: '${{ steps.release.outputs.id }}'
assets_path: ./artifacts
files: |
artifacts/*/*

View File

@@ -0,0 +1,22 @@
name: Validate MetaInfo
"on":
pull_request:
branches: ["master"]
paths:
- .github/workflows/validate-appstream-metainfo.yml
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
push:
branches: ["master"]
paths:
- .github/workflows/validate-appstream-metainfo.yml
- Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
jobs:
validate-appstream-metainfo:
runs-on: ubuntu-latest
container:
image: ghcr.io/flathub/flatpak-builder-lint:latest
steps:
- uses: actions/checkout@v6
- name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml

View File

@@ -0,0 +1,21 @@
name: Check desktop file
"on":
pull_request:
branches: ["master"]
paths:
- .github/workflows/validate-desktop-file.yml
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
push:
branches: ["master"]
paths:
- .github/workflows/validate-desktop-file.yml
- Source/LoadByOS/LinuxConfigApp/Libation.desktop
jobs:
validate-desktop-file:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- run: sudo apt --yes install desktop-file-utils
- name: Check the desktop file
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop

View File

@@ -1,14 +1,47 @@
# validate.yml
# Validates that Libation will build on a pull request or push to master.
# Validates that Libation will build on a pull request or push to master.
---
name: validate
on:
push:
branches: [master]
paths:
- Source/**
- .github/workflows/**
pull_request:
branches: [master]
paths:
- Source/**
- .github/workflows/**
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:
needs: [get_version]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.get_version.outputs.version }}
retention-days: 14
run-unit-tests: true
docker:
needs: [get_version]
uses: ./.github/workflows/docker.yml
with:
version: ${{ needs.get_version.outputs.version }}
release: false
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}
docker_token: ${{ secrets.DOCKERHUB_TOKEN }}

13
.gitignore vendored
View File

@@ -370,4 +370,15 @@ FodyWeavers.xsd
/__TODO.txt
/DataLayer/LibationContext.db
*/bin-Avalonia
*/bin-Avalonia
# macOS Directory Info
.DS_Store
# JetBrains Rider Settings
**/.idea/
# VitePress
node_modules
.vitepress/cache
.vitepress/dist

View File

@@ -1,6 +1,11 @@
{
"WindowsClassic": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-classic\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-win(dows)?-chardonnay\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+-linux-chardonnay\\.deb",
"MacOSAvalonia": "Libation\\.app-macOS-x64-\\d+\\.\\d+\\.\\d+\\.tgz"
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"WindowsAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-arm64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
}

90
.vitepress/config.js Normal file
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",
},
},
});

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;
}
}

View File

@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme

32
.vscode/launch.json vendored Normal file
View File

@@ -0,0 +1,32 @@
{
// Use IntelliSense to learn about possible attributes.
// Hover to view descriptions of existing attributes.
// For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387
"version": "0.2.0",
"configurations": [
{
"name": ".NET Core Launch (console) Windows",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"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"
}
]
}

59
.vscode/tasks.json vendored Normal file
View File

@@ -0,0 +1,59 @@
{
// See https://go.microsoft.com/fwlink/?LinkId=733558
// for the documentation about the tasks.json format
"version": "2.0.0",
"tasks": [
{
"label": "build",
"dependsOn": [
"build_libation",
"build_linuxconfigapp"
]
},
{
"label": "build_libation",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
"label": "build_linuxconfigapp",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"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
Directory.Build.props Normal file
View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<EnableMSTestRunner>true</EnableMSTestRunner>
</PropertyGroup>
</Project>

3
Docker/appsettings.json Normal file
View File

@@ -0,0 +1,3 @@
{
"LibationFiles": "/config-internal"
}

View File

@@ -1,68 +1,174 @@
#!/bin/bash
# Rewire echo to print date time
echo() {
if [[ -n $1 ]]; then
printf "$(date '+%F %T'): %s\n" "$1"
fi
error() {
log "ERROR" "$1"
}
# ################################
# Setup
# ################################
echo "Starting"
if [[ -z "${SLEEP_TIME}" ]]; then
echo "No sleep time passed in. Will run once and exit."
else
echo "Sleep time is set to ${SLEEP_TIME}"
fi
warn() {
log "WARNING" "$1"
}
echo ""
info() {
log "info" "$1"
}
# Check if the config directory is passed in, and there is no link to it then create the link.
if [ -d "/config" ] && [ ! -d "/root/Libation" ]; then
echo "Linking config directory to the Libation config directory"
ln -s /config/ /root/Libation
fi
debug() {
if [ "${LOG_LEVEL}" = "debug" ]; then
log "debug" "$1"
fi
}
# If no config error and exit
if [ ! -d "/config" ]; then
echo "ERROR: No /config directory. You must pass in a volume containing your config mapped to /config"
log() {
LEVEL=$1
MESSAGE=$2
printf "$(date '+%F %T') %s: %s\n" "${LEVEL}" "${MESSAGE}"
}
init_config_file() {
FILE=$1
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
if [ -f ${FULLPATH} ]; then
info "loading ${FILE}"
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
return 0
else
warn "${FULLPATH} not found, creating empty file"
echo "{}" > ${LIBATION_CONFIG_INTERNAL}/${FILE}
return 1
fi
}
update_settings() {
FILE=$1
KEY=$2
VALUE=$3
info "setting ${KEY} to ${VALUE}"
echo $(jq --arg k "${KEY}" --arg v "${VALUE}" '.[$k] = $v' ${LIBATION_CONFIG_INTERNAL}/${FILE}) > ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp
mv ${LIBATION_CONFIG_INTERNAL}/${FILE}.tmp ${LIBATION_CONFIG_INTERNAL}/${FILE}
}
is_mounted() {
DIR=$1
if grep -qs "${DIR} " /proc/mounts;
then
return 0
else
return 1
fi
}
create_db() {
DBFILE=$1
if [ -f "${DBFILE}" ]; then
warn "prexisting database found when creating"
return 0
else
if ! touch "${DBFILE}"; then
error "unable to create database, check permissions on host"
exit 1
fi
return 1
fi
}
setup_db() {
DBPATH=$1
dbpattern="*.db"
debug "using database directory ${DBPATH}"
# Figure out the right databse file
if [[ -z "${LIBATION_DB_FILE}" ]];
then
dbCount=$(find "${DBPATH}" -maxdepth 1 -type f -name "${dbpattern}" | wc -l)
if [ "${dbCount}" -gt 1 ];
then
error "too many database files found, set LIBATION_DB_FILE to the filename you wish to use"
exit 1
elif [ "${dbCount}" -eq 1 ];
then
files=( ${DBPATH}/${dbpattern} )
FILE=${files[0]}
else
FILE="${DBPATH}/LibationContext.db"
fi
else
FILE="${DBPATH}/${LIBATION_DB_FILE}"
fi
debug "planning to use database ${FILE}"
if [ -f "${FILE}" ]; then
info "database found at ${FILE}"
elif [ ${LIBATION_CREATE_DB} = "true" ];
then
warn "database not found, creating one at ${FILE}"
create_db ${FILE}
else
error "database not found and creation is disabled"
exit 1
fi
fi
ln -s "${FILE}" "${LIBATION_CONFIG_INTERNAL}/LibationContext.db"
}
# If user passes in db from a /db/ folder and a db does not already exist / is not already linked
FILE=/db/LibationContext.db
if [ -f "${FILE}" ] && [ ! -f "/config/LibationContext.db" ]; then
echo "Linking passed in Libation database from /db/ to the Libation config directory"
ln -s $FILE /config/LibationContext.db
fi
run() {
info "scanning accounts"
/libation/LibationCli scan
info "liberating books"
/libation/LibationCli liberate
}
# Confirm we have a db in the config direcotry.
if [ ! -f "/config/LibationContext.db" ]; then
echo "ERROR: No Libation database detected, exiting."
exit 1
fi
main() {
info "initializing libation"
init_config_file AccountsSettings.json
init_config_file Settings.json
info "loading settings"
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
update_settings Settings.json InProgress /tmp
# ################################
# Loop and liberate
# ################################
while true
do
echo ""
echo "Scanning accounts"
/libation/LibationCli scan
echo "Liberating books"
/libation/LibationCli liberate
echo ""
info "loading database"
# If user provides a separate database mount, use that
if is_mounted "${LIBATION_DB_DIR}";
then
DB_LOCATION=${LIBATION_DB_DIR}
# Otherwise, use the config directory
else
DB_LOCATION=${LIBATION_CONFIG_DIR}
fi
setup_db ${DB_LOCATION}
# Try to warn if books dir wasn't mounted in
if ! is_mounted "${LIBATION_BOOKS_DIR}";
then
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
fi
# Let the user know what the run type will be
if [[ -z "${SLEEP_TIME}" ]]; then
SLEEP_TIME=-1
fi
if [ "${SLEEP_TIME}" == -1 ]; then
info "running once"
else
info "running every ${SLEEP_TIME}"
fi
# loop
while true
do
run
# Liberate only once if SLEEP_TIME was set to -1
if [ "${SLEEP_TIME}" = -1 ]; then
if [ "${SLEEP_TIME}" == -1 ]; then
break
fi
echo "Sleeping for ${SLEEP_TIME}"
sleep "${SLEEP_TIME}"
done
done
echo "Exiting"
info "exiting"
}
main

View File

@@ -1,22 +1,42 @@
# Dockerfile
FROM mcr.microsoft.com/dotnet/sdk:7.0 as build-env
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
COPY Source /Source
RUN dotnet publish -c Release -o /Source/bin/Publish/Linux-chardonnay /Source/LibationCli/LibationCli.csproj -p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
COPY Docker/liberate.sh /Source/bin/Publish/Linux-chardonnay
RUN dotnet publish \
/Source/LibationCli/LibationCli.csproj \
--os linux \
--arch ${TARGETARCH} \
--configuration Release \
--output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProtocol=FileSystem \
-p:PublishReadyToRun=true \
-p:SelfContained=true
FROM mcr.microsoft.com/dotnet/runtime:10.0
ARG USER_UID=1001
ARG USER_GID=1001
# Set the character set that will be used for folder and filenames when liberating
ENV LANG=C.UTF-8
ENV LC_ALL=C.UTF-8
ENV SLEEP_TIME=-1
ENV LIBATION_CONFIG_INTERNAL=/config-internal
ENV LIBATION_CONFIG_DIR=/config
ENV LIBATION_DB_DIR=/db
ENV LIBATION_DB_FILE=
ENV LIBATION_CREATE_DB=true
ENV LIBATION_BOOKS_DIR=/data
FROM mcr.microsoft.com/dotnet/runtime:7.0
RUN apt-get update && apt-get -y upgrade && \
apt-get install -y jq && \
mkdir -m777 ${LIBATION_CONFIG_INTERNAL} ${LIBATION_BOOKS_DIR}
ENV SLEEP_TIME "30m"
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
COPY Docker/* /libation
# Sets the character set that will be used for folder and filenames when liberating
ENV LANG C.UTF-8
ENV LC_ALL C.UTF-8
USER ${USER_UID}:${USER_GID}
RUN mkdir /db /config /data
COPY --from=build-env /Source/bin/Publish/Linux-chardonnay /libation
CMD ["./libation/liberate.sh"]
CMD ["/libation/liberate.sh"]

View File

@@ -1,79 +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 [PalPal.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)
### 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.
### 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
```

View File

@@ -1,36 +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 [PalPal.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**.
### Setup
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
In Settings.json, make the following changes:
* Change `Books` to `/data`
* Change `InProgress` to `/tmp`
### Running
Once the configuration files are copied and edited, the docker image can be run with the following command.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
--name libation \
--restart=always \
rmcrackan/libation
```
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
-e SLEEP_TIME='10m' \
--name libation \
--restart=always \
rmcrackan/libation
```

View File

@@ -1,22 +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 [PalPal.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**.
### Install and Run Libation on Ubuntu
New Libation releases are automatically packed into a debian package and are available from the Libation repository's releases page.
Run this command in your terminal to dowbnload and install Libation, replacing the url with the Latest Libation .deb package url:
```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
```
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

View File

@@ -1,40 +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 [PalPal.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.
## Install Libation
- Download the `Libation.app-macOS-x64-x.x.x.tgz` file from the latest release and extract it.
- Move the extracted Libation app bundle to your applications folder.
- Open a terminal (Go > Utilities > Terminal)
- Copy/paste/run the following command (you'll be prompted to enter your 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!
## 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
```
## Runnign 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)

View File

@@ -1,116 +0,0 @@
# 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)
- [Name List Formatters](#name-list-formatters)
- [Integer Formatters](#integer-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|Text|
|\<title short\>|Title. Stop at first colon|Text|
|\<author\>|Author(s)|Name List|
|\<first author\>|First author|Text|
|\<narrator\>|Narrator(s)|Name List|
|\<first narrator\>|First narrator|Text|
|\<series\>|Name of series|Text|
|\<series#\>|Number order in series|Text|
|\<bitrate\>|File's original bitrate (Kbps)|Integer|
|\<samplerate\>|File's original audio sample rate|Integer|
|\<channels\>|Number of audio channels|Integer|
|\<account\>|Audible account of this book|Text|
|\<locale\>|Region/country|Text|
|\<year\>|Year published|Integer|
|\<language\>|Book's language|Text|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|DateTime|
|\<pub date\>|Audiobook publication date|DateTime|
|\<date added\>|Date the book added to your Audible account|DateTime|
|\<ch count\> **‡**|Number of chapters|Integer|
|\<ch title\> **‡**|Chapter title|Text|
|\<ch#\> **‡**|Chapter number|Integer|
|\<ch# 0\> **‡**|Chapter number with leading zeros|Integer|
**†** 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|
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**, **Integer**, 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|
## Name List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|format(\{T \| F \| M \| L \| S\})|Formats the human name using 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><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\} |`<author[format({L}, {F}) separator(; )]>`|Doyle, Arthur; Fry, Stephen|
|sort(F \| M \| L)|Sorts the names by first, middle, 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|
## Integer Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|# (a number)|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
## 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|

BIN
Images/Plus Minus.psd Normal file
View File

Binary file not shown.

View File

Binary file not shown.

BIN
Images/Stoplight.psd Normal file
View File

Binary file not shown.

View File

@@ -0,0 +1,32 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" width="512px" enable-background="new 0 0 512 512">
<path id="slosh" transform=
"translate(-50 23)
scale(0.7, 0.7)
rotate(12 256,256)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
<use href="#slosh" transform="translate(512 0) scale(-1 1)" />
</svg>

After

Width:  |  Height:  |  Size: 736 B

41
Images/libation_glass.svg Normal file
View File

@@ -0,0 +1,41 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<defs>
<g id="glass">
<path transform="translate(16 16)" fill-rule="evenodd" d=
"M177,16
H79
A 32.0781 63.7932 -1.5106 0 0 66 80
A 158.789 471.1259 41.9466 0 0 90 131
A 81.7197 122.0515 35.3745 0 0 128 143.3484
A 81.7197 122.0515 -35.3745 0 0 166 131
A 158.789 471.1259 -41.9466 0 0 190 80
A 32.0781 63.7932 1.5106 0 0 177 16
L 184 0
A 44.7901 78.5247 1.1521 0 1 194 122
A 97.0039 135.3148 -36.2124 0 1 136 159
V 240
H 176
A 8 8 0 0 1 176 256
H 80
A 8 8 0 0 1 80 240
H 120
V 159
A 97.0039 135.3148 36.2124 0 1 62 122
A 44.7901 78.5247 -1.1521 0 1 72 0
H184
z"/>
</g>
<g transform="translate(16 16)" id="wine-level">
<path d=
"M182,64
H 74
A 115.9979 308.8033 38.9474 0 0 128 134.4277
A 115.9979 308.8033 -38.9474 0 0 182,64
z"/>
</g>
</defs>
<use href="#glass" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#wine-level" stroke="#ffffffa0" stroke-width="16" fill="Transparent" />
<use href="#glass" fill="Black" />
<use href="#wine-level" fill="Black" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

View File

@@ -0,0 +1,31 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 288 288" enable-background="new 0 0 288 288">
<g>
<path transform="rotate(90 128,128) translate(60 -16)" fill-rule="evenodd" d=
"M177,16
H79
A 32.0781 63.7932 -1.5106 0 0 66 80
A 158.789 471.1259 41.9466 0 0 90 131
A 81.7197 122.0515 35.3745 0 0 128 143.3484
A 81.7197 122.0515 -35.3745 0 0 166 131
A 158.789 471.1259 -41.9466 0 0 190 80
A 32.0781 63.7932 1.5106 0 0 177 16
L 184 0
A 44.7901 78.5247 1.1521 0 1 194 122
A 97.0039 135.3148 -36.2124 0 1 136 159
V 240
H 176
A 8 8 0 0 1 176 256
H 80
A 8 8 0 0 1 80 240
H 120
V 159
A 97.0039 135.3148 36.2124 0 1 62 122
A 44.7901 78.5247 -1.1521 0 1 72 0
H184
M170,115
V24
A 19.5181 45.9183 -3.3549 0 1 182.4322 69.5
A 19.5181 45.9183 3.3549 0 1 170 115
z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 936 B

33
Images/libation_slosh.svg Normal file
View File

@@ -0,0 +1,33 @@
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 512 512" enable-background="new 0 0 512 512">
<path
transform=
"rotate(15 256,256)
translate(0 25)
scale(0.93, 0.93)"
d=
"M139,2
A 192,200 0 0 0 103,84
A 222,334 41 0 0 241,320
V478
H160
A 16,16 0 0 0 160,510
H352
A16 16 0 0 0 352,478
H271
V320
A 222,334 -41 0 0 409,84
A 192,200 0 0 0 373,2
M355,32
A 192,200 0 0 1 381,127
A 187.5,334 -35 0 1 256,286
A 187.5,334 35 0 1 131,127
A 192,200 0 0 1 157,32
H355
M146,147
A 168,300 35 0 0 256,270
A 168,300 -35 0 0 366,128
S 360,50 280,110
S 192,128 147,147
z" />
</svg>

After

Width:  |  Height:  |  Size: 649 B

View File

@@ -2,68 +2,41 @@
## [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 [PalPal.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**.
# Table of Contents
## Getting started with Libation
- [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)
- [Docker](Documentation/Docker.md)
## Getting started
All documentation has been moved to our new site: [getlibation.com](https://getlibation.com). Or jump to the important bits:
* [Getting Started](https://getlibation.com/docs/getting-started)
* [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
* 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
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).
<a name="theBad"/>
**Prerequisites**: Node.js 18+
### The bad
**Commands**:
* Only fully supported in Windows. (Mac and Linux are in beta)
* Large file size
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
```bash
# Install dependencies
npm install
### The ugly
# Start local dev server (http://localhost:5173)
npm run docs:dev
* Documentation? Yer lookin' at it
* This is a single-developer personal passion project. Support, response, updates, enhancements, bug fixes etc are as my free time allows
* 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
# Build for production (output: docs/.vitepress/dist)
npm run docs:build
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`.

130
Scripts/Bundle_Debian.sh Normal file
View File

@@ -0,0 +1,130 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation Linux bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z "$ARCH" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb
FOLDER_EXEC=$DEB_DIR/usr/lib/libation
echo "Exec dir: $FOLDER_EXEC"
mkdir -p $FOLDER_EXEC
echo "Moving bins from $BIN_DIR to $FOLDER_EXEC"
mv "${BIN_DIR}/"* $FOLDER_EXEC
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
fi
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $FOLDER_EXEC/$n
done
FOLDER_ICON=$DEB_DIR/usr/share/icons/hicolor/scalable/apps/
echo "Icon dir: $FOLDER_ICON"
FOLDER_DESKTOP=$DEB_DIR/usr/share/applications
echo "Desktop dir: $FOLDER_DESKTOP"
FOLDER_DEBIAN=$DEB_DIR/DEBIAN
echo "Debian dir: $FOLDER_DEBIAN"
mkdir -p $FOLDER_ICON
mkdir -p $FOLDER_DESKTOP
mkdir -p $FOLDER_DEBIAN
echo "Copying icon..."
cp $FOLDER_EXEC/libation_glass.svg $FOLDER_ICON/libation.svg
echo "Copying desktop file..."
cp $FOLDER_EXEC/Libation.desktop $FOLDER_DESKTOP/Libation.desktop
echo "Creating pre-install file..."
echo "#!/bin/bash
# Pre-install script, removes previous installation program files and sym links
echo \"Removing previously created symlinks...\"
rm /usr/bin/libation
rm /usr/bin/hangover
rm /usr/bin/libationcli
echo \"Removing previously installed Libation files...\"
rm -r /usr/lib/libation
# making sure it won't stop installation
exit 0
" >> $FOLDER_DEBIAN/preinst
echo "Creating post-install file..."
echo "#!/bin/bash
gtk-update-icon-cache -f /usr/share/icons/hicolor/
ln -s /usr/lib/libation/Libation /usr/bin/libation
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
# Increase the maximum number of inotify instances
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
" >> $FOLDER_DEBIAN/postinst
echo "Creating control file..."
echo "Package: Libation
Version: $VERSION
Architecture: $ARCH
Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
Recommends: libgtk-3-0, libwebkit2gtk-4.1-0
" >> $FOLDER_DEBIAN/control
echo "Changing permissions for pre- and post-install files..."
chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
if [ "$(uname -s)" == "Darwin" ]; then
echo "macOS detected, installing dpkg"
brew install dpkg
fi
DEB_FILE=Libation.${VERSION}-linux-chardonnay-${ARCH}.deb
echo "Creating $DEB_FILE"
dpkg-deb -Zxz --build $DEB_DIR ./$DEB_FILE
echo "moving to ./bundle/$DEB_FILE"
mkdir bundle
mv $DEB_FILE ./bundle/$DEB_FILE
rm -r "$BIN_DIR"
echo "Done!"

132
Scripts/Bundle_MacOS.sh Normal file
View File

@@ -0,0 +1,132 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
SIGN_WITH_KEY=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation macos bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z $VERSION ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z $ARCH ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
if [ "$SIGN_WITH_KEY" != "true" ]
then
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
fi
BUNDLE=./Libation.app
echo "Bundle dir: $BUNDLE"
if [[ -d $BUNDLE ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
fi
BUNDLE_CONTENTS=$BUNDLE/Contents
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
BUNDLE_RESOURCES=$BUNDLE_CONTENTS/Resources
echo "Resources dir: $BUNDLE_RESOURCES"
BUNDLE_MACOS=$BUNDLE_CONTENTS/MacOS
echo "MacOS dir: $BUNDLE_MACOS"
mkdir -p $BUNDLE_CONTENTS
mkdir -p $BUNDLE_RESOURCES
mkdir -p $BUNDLE_MACOS
mv "${BIN_DIR}/"* $BUNDLE_MACOS
if [ $? -ne 0 ]
then echo "Error moving ${BIN_DIR} files"
exit
fi
echo "Make fileicon executable..."
chmod +x $BUNDLE_MACOS/fileicon
echo "Moving icon..."
mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
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/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
all_identities=$(security find-identity -v -p codesigning)
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
if [ "$SIGN_WITH_KEY" == "true" ]; then
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
echo "Creating app disk image: $DMG_FILE"
mkdir Libation
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
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing $DMG_FILE"
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
fi
echo "Done!"

133
Scripts/Bundle_Redhat.sh Normal file
View File

@@ -0,0 +1,133 @@
#!/bin/bash
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
if [ -z "$BIN_DIR" ]
then
echo "This script must be called with a the Libation Linux bins directory as an argument."
exit
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
if [ -z "$ARCH" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
fi
BASEDIR=$(pwd)
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
if [[ "$ARCH" == "x64" ]]
then
ARCH_RPM="x86_64"
ARCH="amd64"
else
ARCH_RPM="aarch64"
fi
notinstalled=('libcoreclrtraceptprovider.so' 'libation_glass.svg' 'Libation.desktop')
mkdir -p ~/rpmbuild/SPECS
mkdir ~/rpmbuild/BUILD
mkdir ~/rpmbuild/RPMS
echo "Name: libation
Version: ${VERSION}
Release: 1
Summary: Liberate your Audible Library
License: GPLv3+
URL: https://github.com/rmcrackan/Libation
Source0: https://github.com/rmcrackan/Libation
Requires: bash gtk3 webkit2gtk4.1
%define __os_install_post %{nil}
%description
Liberate your Audible Library
%install
mkdir -p %{buildroot}%{_libdir}/%{name}
mkdir -p %{buildroot}%{_datadir}/icons/hicolor/scalable/apps
mkdir -p %{buildroot}%{_datadir}/applications
if test -f 'libcoreclrtraceptprovider.so'; then
rm 'libcoreclrtraceptprovider.so'
fi
install -m 666 libation_glass.svg %{buildroot}%{_datadir}/icons/hicolor/scalable/apps/libation.svg
install -m 666 Libation.desktop %{buildroot}%{_datadir}/applications/Libation.desktop
rm libation_glass.svg
rm Libation.desktop
install * %{buildroot}%{_libdir}/%{name}/
%post
if [ \$1 -eq 1 ] ; then
# Initial installation
ln -s %{_libdir}/%{name}/Libation %{_bindir}/libation
ln -s %{_libdir}/%{name}/Hangover %{_bindir}/hangover
ln -s %{_libdir}/%{name}/LibationCli %{_bindir}/libationcli
gtk-update-icon-cache -f %{_datadir}/icons/hicolor/
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
fi
%postun
if [ \$1 -eq 0 ] ; then
# Uninstall
rm %{_bindir}/libation
rm %{_bindir}/hangover
rm %{_bindir}/libationcli
fi
%files
%{_datadir}/icons/hicolor/scalable/apps/libation.svg
%{_datadir}/applications/Libation.desktop" >> ~/rpmbuild/SPECS/libation.spec
cd "$BIN_DIR"
for f in *; do
if [[ " ${delfiles[*]} " =~ " ${f} " ]]; then
echo "Deleting $f"
elif [[ ! " ${notinstalled[*]} " =~ " ${f} " ]]; then
echo "%{_libdir}/%{name}/${f}" >> ~/rpmbuild/SPECS/libation.spec
cp $f ~/rpmbuild/BUILD/
else
cp $f ~/rpmbuild/BUILD/
fi
done
cd ~/rpmbuild/SPECS/
rpmbuild -bb --target $ARCH_RPM libation.spec
cd $BASEDIR
RPM_FILE=$(ls ~/rpmbuild/RPMS/${ARCH_RPM})
mkdir bundle
mv ~/rpmbuild/RPMS/${ARCH_RPM}/$RPM_FILE "./bundle/Libation.${VERSION}-linux-chardonnay-${ARCH}.rpm"

View File

@@ -1,142 +0,0 @@
#!/bin/bash
FILE=$1; shift
VERSION=$1; shift
if [ -z "$FILE" ]
then
echo "This script must be called with a the Libation Linux bin zip file as an argument."
exit
fi
if [ ! -f "$FILE" ]
then
echo "The file \"$FILE\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$FILE" "$VERSION"
then
echo "This script must be called with a Libation version number that is present in the filename passed."
exit
fi
# remove trailing ".tar.gz"
FOLDER_MAIN=${FILE::-7}
echo "Working dir: $FOLDER_MAIN"
if [[ -d "$FOLDER_MAIN" ]]
then
echo "$FOLDER_MAIN directory already exists, aborting."
exit
fi
FOLDER_EXEC="$FOLDER_MAIN/usr/lib/libation"
echo "Exec dir: $FOLDER_EXEC"
FOLDER_ICON="$FOLDER_MAIN/usr/share/icons/hicolor/scalable/apps/"
echo "Icon dir: $FOLDER_ICON"
FOLDER_DESKTOP="$FOLDER_MAIN/usr/share/applications"
echo "Desktop dir: $FOLDER_DESKTOP"
FOLDER_DEBIAN="$FOLDER_MAIN/DEBIAN"
echo "Debian dir: $FOLDER_DEBIAN"
mkdir -p "$FOLDER_EXEC"
mkdir -p "$FOLDER_ICON"
mkdir -p "$FOLDER_DESKTOP"
mkdir -p "$FOLDER_DEBIAN"
echo "Extracting $FILE to $FOLDER_EXEC..."
tar -xzf ${FILE} -C ${FOLDER_EXEC}
if [ $? -ne 0 ]
then echo "Error extracting ${FILE}"
exit
fi
echo "Copying icon..."
cp "$FOLDER_EXEC/glass-with-glow_256.svg" "$FOLDER_ICON/libation.svg"
echo "Copying desktop file..."
cp "$FOLDER_EXEC/Libation.desktop" "$FOLDER_DESKTOP/Libation.desktop"
echo "Workaround for desktop file..."
sed -i '/^Exec=Libation/c\Exec=/usr/bin/libation' "$FOLDER_DESKTOP/Libation.desktop"
echo "Creating pre-install file..."
echo "#!/bin/bash
# Pre-install script, removes previous installation program files and sym links
echo \"Removing previously created symlinks...\"
rm /usr/bin/libation
rm /usr/bin/Libation
rm /usr/bin/hangover
rm /usr/bin/Hangover
rm /usr/bin/libationcli
rm /usr/bin/LibationCli
echo \"Removing previously installed Libation files...\"
rm -r /usr/lib/libation
rm -r /usr/lib/Libation
# making sure it won't stop installation
exit 0
" >> "$FOLDER_DEBIAN/preinst"
echo "Creating post-install file..."
echo "#!/bin/bash
gtk-update-icon-cache -f /usr/share/icons/hicolor/
ln -s /usr/lib/libation/Libation /usr/bin/libation
ln -s /usr/lib/libation/Hangover /usr/bin/hangover
ln -s /usr/lib/libation/LibationCli /usr/bin/libationcli
# Increase the maximum number of inotify instances
if ! grep -q 'fs.inotify.max_user_instances=524288' /etc/sysctl.conf; then
echo fs.inotify.max_user_instances=524288 | tee -a /etc/sysctl.conf && sysctl -p
fi
# workaround until this file is moved to the user's home directory
touch /usr/lib/libation/appsettings.json
chmod 666 /usr/lib/libation/appsettings.json
" >> "$FOLDER_DEBIAN/postinst"
echo "Creating control file..."
echo "Package: Libation
Version: $VERSION
Architecture: all
Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
" >> "$FOLDER_DEBIAN/control"
echo "Changing permissions for pre- and post-install files..."
chmod +x "$FOLDER_DEBIAN/preinst"
chmod +x "$FOLDER_DEBIAN/postinst"
echo "Creating .deb file..."
dpkg-deb -Zxz --build $FOLDER_MAIN
mkdir bundle
echo "moving to ./bundle/$FOLDER_MAIN.deb"
mv "$FOLDER_MAIN.deb" "./bundle/$FOLDER_MAIN.deb"
rm -r "$FOLDER_MAIN"
echo "Done!"

View File

@@ -1,84 +0,0 @@
#!/bin/bash
FILE=$1; shift
VERSION=$1; shift
if [ -z "$FILE" ]
then
echo "This script must be called with a the Libation macos bin zip file as an argument."
exit
fi
if [ ! -f "$FILE" ]
then
echo "The file \"$FILE\" does not exist."
exit
fi
if [ -z "$VERSION" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$FILE" "$VERSION"
then
echo "This script must be called with a Libation version number that is present in the filename passed."
exit
fi
BUNDLE="Libation.app"
echo "Bundle dir: $BUNDLE"
if [[ -d "$BUNDLE" ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
fi
BUNDLE_CONTENTS="$BUNDLE/Contents"
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
BUNDLE_RESOURCES="$BUNDLE_CONTENTS/Resources"
echo "Resources dir: $BUNDLE_RESOURCES"
BUNDLE_MACOS="$BUNDLE_CONTENTS/MacOS"
echo "MacOS dir: $BUNDLE_MACOS"
mkdir -p "$BUNDLE_CONTENTS"
mkdir -p "$BUNDLE_RESOURCES"
mkdir -p "$BUNDLE_MACOS"
echo "Extracting $FILE to $BUNDLE_MACOS..."
tar -xzf ${FILE} -C ${BUNDLE_MACOS}
if [ $? -ne 0 ]
then echo "Error extracting ${FILE}"
exit
fi
echo "Copying icon..."
cp "$BUNDLE_MACOS/libation.icns" "$BUNDLE_RESOURCES/libation.icns"
echo "Copying Info.plist file..."
cp "$BUNDLE_MACOS/Info.plist" "$BUNDLE_CONTENTS/Info.plist"
echo "Set Libation version number..."
sed -i -e "s/VERSION_STRING/$VERSION/" "$BUNDLE_CONTENTS/Info.plist"
echo "deleting unneeded files.."
delfiles=("libmp3lame.x64.so" "ffmpegaac.x64.so" "libation.icns" "Info.plist")
for n in "${delfiles[@]}"; do rm "$BUNDLE_MACOS/$n"; done
echo "Creating app bundle: $BUNDLE-$VERSION.tar.gz"
tar -czvf "$BUNDLE-$VERSION.tar.gz" "$BUNDLE"
mkdir bundle
echo "moving to ./bundle/$BUNDLE-$VERSION.tar.gz"
mv "$BUNDLE-$VERSION.tar.gz" "./bundle/$BUNDLE-macOS-x64-$VERSION.tgz"
rm -r "$BUNDLE"
echo "Done!"

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="0.5.14" />
<PackageReference Include="AAXClean.Codecs" Version="2.1.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,18 +1,21 @@
using AAXClean;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{
public event EventHandler<AppleTags> RetrievedMetadata;
public event EventHandler<AppleTags>? RetrievedMetadata;
protected AaxFile AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; }
public Mp4File? AaxFile { get; private set; }
protected Mp4Operation? AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
@@ -24,15 +27,65 @@ namespace AaxDecrypter
public override async Task CancelAsync()
{
IsCanceled = true;
await base.CancelAsync();
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
FinalizeDownload();
}
private Mp4File Open()
{
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
//the dash files default Key ID.
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
var dash = new DashFile(InputFileStream);
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
if (kidIndex == -1)
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
keys[0] = keys[kidIndex];
var keyId = keys[kidIndex].KeyPart1;
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);
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
return dash;
}
else if (DownloadOptions.InputType is FileType.Aax)
{
var aax = new AaxFile(InputFileStream);
var key = keys[0].KeyPart1;
aax.SetDecryptionKey(keys[0].KeyPart1);
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
return aax;
}
else if (DownloadOptions.InputType is FileType.Aaxc)
{
var aax = new AaxFile(InputFileStream);
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;
}
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()
{
AaxFile = new AaxFile(InputFileStream);
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
AaxFile = Open();
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
if (DownloadOptions.StripUnabridged)
{
@@ -40,25 +93,52 @@ namespace AaxDecrypter
AaxFile.AppleTags.Album = AaxFile.AppleTags.Album?.Replace(" (Unabridged)", "");
}
if (DownloadOptions.FixupFile && !string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
AaxFile.AppleTags.AppleListBox.EditOrAddTag("TCOM", AaxFile.AppleTags.Narrator);
if (DownloadOptions.FixupFile)
{
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Narrator))
AaxFile.AppleTags.AppleListBox.EditOrAddTag("©wrt", AaxFile.AppleTags.Narrator);
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate);
if (!string.IsNullOrWhiteSpace(AaxFile.AppleTags.Copyright))
AaxFile.AppleTags.Copyright = AaxFile.AppleTags.Copyright.Replace("(P)", "℗").Replace("&#169;", "©");
//Add audiobook shelf tags
//https://github.com/advplyr/audiobookshelf/issues/1794#issuecomment-1565050213
const string tagDomain = "com.pilabor.tone";
AaxFile.AppleTags.Title = DownloadOptions.Title;
if (DownloadOptions.Subtitle is string subtitle)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SUBTITLE", subtitle);
if (DownloadOptions.Publisher is string publisher)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PUBLISHER", publisher);
if (DownloadOptions.Language is string language)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "LANGUAGE", language);
if (DownloadOptions.AudibleProductId is string asin)
{
AaxFile.AppleTags.Asin = asin;
AaxFile.AppleTags.AppleListBox.EditOrAddTag("asin", asin);
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ASIN", asin);
}
if (DownloadOptions.SeriesName is string series)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
if (DownloadOptions.SeriesNumber is string part)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
}
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
RetrievedMetadata?.Invoke(this, AaxFile.AppleTags);
OnInitialized();
return !IsCanceled;
}
protected virtual void OnInitialized() { }
}
}

View File

@@ -5,20 +5,32 @@ using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
{
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
private FileStream workingFileStream;
private FileStream? workingFileStream;
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
chapters: null);
}
/*
@@ -47,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()
{
if (AaxFile is null) return false;
var chapters = DownloadOptions.ChapterInfo.Chapters;
// Ensure split files are at least minChapterLength in duration.
@@ -71,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea
try
{
await (AaxConversion = decryptMultiAsync(splitChapters));
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(workingFileStream?.Name);
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
return AaxConversion.IsCompletedSuccessfully;
}
@@ -85,54 +98,51 @@ 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;
return
DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMultiMp4aAsync
? aaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.TrimOutputToChapterLength
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
)
: AaxFile.ConvertToMultiMp3Async
: aaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
DownloadOptions.LameConfig,
DownloadOptions.TrimOutputToChapterLength
DownloadOptions.LameConfig
);
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
{
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
MultiConvertFileProperties props = new()
{
OutputFileName = OutputFileName,
OutputFileName = newTempFile.FilePath,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title,
Title = newSplitCallback.Chapter?.Title,
};
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count;
OnFileCreated(workingFileStream.Name);
OnTempFileCreated(newTempFile with { PartProperties = props });
}
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
FileUtility.SaferDelete(fileName);
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
}
private Mp4Operation moveMoovToBeginning(string filename)
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
{
if (DownloadOptions.OutputFormat is OutputFormat.M4b
&& DownloadOptions.MoveMoovToBeginning
@@ -141,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
{
return Mp4File.RelocateMoovAsync(filename);
}
else return Mp4Operation.CompletedOperation;
else return Mp4Operation.FromCompleted(aaxFile);
}
}
}

View File

@@ -6,43 +6,51 @@ using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
private readonly AverageSpeed averageSpeed = new();
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
private TempFile? outputTempFile;
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{
var step = 1;
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps["Step 4: Create Cue"] = Step_CreateCueAsync;
AsyncSteps[$"Step {step++}: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
protected override void OnInitialized()
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
DownloadOptions.ChapterInfo);
}
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);
OnFileCreated(OutputFileName);
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnTempFileCreated(outputTempFile);
try
{
await (AaxConversion = decryptAsync(outputFile));
if (AaxConversion.IsCompletedSuccessfully
&& DownloadOptions.MoveMoovToBeginning
&& DownloadOptions.OutputFormat is OutputFormat.M4b)
{
outputFile.Close();
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
}
await (AaxConversion = decryptAsync(AaxFile, outputFile));
return AaxConversion.IsCompletedSuccessfully;
}
@@ -52,43 +60,49 @@ namespace AaxDecrypter
}
}
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
private async Task<bool> Step_MoveMoov()
{
if (outputTempFile is null) return false;
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
return AaxConversion.IsCompletedSuccessfully;
}
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
var remainingTimeToProcess = (e.TotalDuration - e.ProcessPosition).TotalSeconds;
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
if (double.IsNormal(estTimeRemaining))
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
var progressPercent = 100d * (1 - remainingTimeToProcess / e.TotalDuration.TotalSeconds);
OnDecryptProgressUpdate(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(InputFileStream.Length * progressPercent),
ProgressPercentage = 100 * e.FractionCompleted,
BytesReceived = (long)(InputFileStream.Length * e.FractionCompleted),
TotalBytesToReceive = InputFileStream.Length
});
}
private Mp4Operation decryptAsync(Stream outputFile)
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
? AaxFile.ConvertToMp3Async
? aaxFile.ConvertToMp3Async
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
DownloadOptions.ChapterInfo
)
: DownloadOptions.FixupFile
? AaxFile.ConvertToMp4aAsync
? aaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo,
DownloadOptions.TrimOutputToChapterLength
DownloadOptions.ChapterInfo
)
: AaxFile.ConvertToMp4aAsync(outputFile);
: aaxFile.ConvertToMp4aAsync(outputFile);
}
}

View File

@@ -6,83 +6,105 @@ using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public enum OutputFormat { M4b, Mp3 }
public abstract class AudiobookDownloadBase
{
public event EventHandler<string> RetrievedTitle;
public event EventHandler<string> RetrievedAuthors;
public event EventHandler<string> RetrievedNarrators;
public event EventHandler<byte[]> RetrievedCoverArt;
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
public event EventHandler<string?>? RetrievedTitle;
public event EventHandler<string?>? RetrievedAuthors;
public event EventHandler<string?>? RetrievedNarrators;
public event EventHandler<byte[]?>? RetrievedCoverArt;
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
public event EventHandler<TempFile>? TempFileCreated;
public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputFileName { get; }
protected IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position;
protected string OutputDirectory { get; }
public IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
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 readonly NetworkFileStreamPersister nfsPersister;
private NetworkFileStreamPersister? m_nfsPersister;
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
private readonly DownloadProgress zeroProgress;
private readonly string jsonDownloadState;
private readonly string tempFilePath;
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
{
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
{
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(outDir))
Directory.CreateDirectory(outDir);
if (!Directory.Exists(OutputDirectory))
Directory.CreateDirectory(OutputDirectory);
if (!Directory.Exists(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");
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
{
BytesReceived = 0,
ProgressPercentage = 0,
TotalBytesToReceive = InputFileStream.Length
TotalBytesToReceive = 0
};
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()
{
await InputFileStream.BeginDownloadingAsync();
var progressTask = Task.Run(reportProgress);
AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync();
//Stop the downloader so it doesn't keep running in the background.
if (!success)
NfsPersister.Dispose();
await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
NfsPersister.Dispose();
return success;
async Task reportProgress()
{
AverageSpeed averageSpeed = new();
while (InputFileStream.CanRead && InputFileStream.Length > InputFilePosition && !InputFileStream.IsCancelled)
while (
InputFileStream.CanRead
&& InputFileStream.Length > InputFilePosition
&& !InputFileStream.IsCancelled
&& !downloadFinished)
{
averageSpeed.AddPosition(InputFilePosition);
@@ -109,59 +131,52 @@ namespace AaxDecrypter
}
}
public abstract Task CancelAsync();
public virtual Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
}
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt)
{
if (coverArt is not null)
OnRetrievedCoverArt(coverArt);
}
protected void OnRetrievedTitle(string title)
public virtual void SetCoverArt(byte[] coverArt) { }
protected void OnRetrievedTitle(string? title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors)
protected void OnRetrievedAuthors(string? authors)
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators)
protected void OnRetrievedNarrators(string? narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
protected void OnRetrievedCoverArt(byte[]? coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
protected void OnFileCreated(string path)
=> FileCreated?.Invoke(this, path);
public void OnTempFileCreated(TempFile path)
=> TempFileCreated?.Invoke(this, path);
protected virtual void FinalizeDownload()
{
nfsPersister?.Dispose();
OnDecryptTimeRemaining(TimeSpan.Zero);
OnDecryptProgressUpdate(zeroProgress);
}
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
{
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
NfsPersister.Dispose();
downloadFinished = true;
}
protected async Task<bool> Step_CreateCueAsync()
{
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
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
OnFileCreated(path);
var tempFile = GetNewTempFilePath(".cue");
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
OnTempFileCreated(tempFile);
}
catch (Exception ex)
{
@@ -170,36 +185,9 @@ namespace AaxDecrypter
return !IsCanceled;
}
private async Task<bool> CleanupAsync()
{
if (IsCanceled) return false;
FileUtility.SaferDelete(jsonDownloadState);
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
DownloadOptions.RetainEncryptedFile)
{
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
FileUtility.SaferMove(tempFilePath, aaxPath);
//Write aax decryption key
string keyPath = Path.ChangeExtension(aaxPath, ".key");
FileUtility.SaferDelete(keyPath);
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(tempFilePath);
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
{
NetworkFileStreamPersister nfsp = default;
NetworkFileStreamPersister? nfsp = default;
try
{
if (!File.Exists(jsonDownloadState))
@@ -213,13 +201,21 @@ namespace AaxDecrypter
}
catch
{
nfsp?.Target?.Dispose();
FileUtility.SaferDelete(jsonDownloadState);
FileUtility.SaferDelete(tempFilePath);
return nfsp = newNetworkFilePersister();
}
finally
{
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
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()

View File

@@ -105,7 +105,7 @@ public class AverageSpeed
public AverageSpeed() : this(TimeSpan.FromSeconds(15), Significance.P10, TimeSpan.FromSeconds(3), Significance.P01) { }
/// <param name="slowWindow">Total moving average time window</param>
/// <param name="slowSignificance">T-test signifance level at which the newest speed will be considered different from the slow window's mean speed.</param>
/// <param name="slowSignificance">T-test significance level at which the newest speed will be considered different from the slow window's mean speed.</param>
/// <param name="fastWindow">A shorter moving window of the most resent speeds. The average speed in <paramref name="fastWindow"/> is compared to the average speed in the rest of <paramref name="slowWindow"/> to quickly detect large changes in speed.</param>
/// <param name="fastSignificance">T-test significance level at which the mean speed in <paramref name="fastWindow"/> will be considered different from the mean speed of the remainder of <paramref name="slowWindow"/>.</param>
public AverageSpeed(TimeSpan slowWindow, Significance slowSignificance, TimeSpan fastWindow, Significance fastSignificance)
@@ -119,7 +119,7 @@ public class AverageSpeed
/// <summary>Add a new position to the moving average</summary>
public void AddPosition(double position)
{
var now = DateTime.Now;
var now = DateTime.UtcNow;
if (start == default)
start = now;

View File

@@ -1,32 +1,55 @@
using AAXClean;
using System;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public interface IDownloadOptions
public class KeyData
{
public byte[] KeyPart1 { get; }
public byte[]? KeyPart2 { get; }
public KeyData(byte[] keyPart1, byte[]? keyPart2 = null)
{
KeyPart1 = keyPart1;
KeyPart2 = keyPart2;
}
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));
KeyPart1 = Convert.FromHexString(keyPart1);
if (keyPart2 != null)
KeyPart2 = Convert.FromHexString(keyPart2);
}
}
public interface IDownloadOptions
{
event EventHandler<long> DownloadSpeedChanged;
string DownloadUrl { get; }
string UserAgent { get; }
string AudibleKey { get; }
string AudibleIV { get; }
KeyData[]? DecryptionKeys { get; }
TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
bool DownloadClipsBookmarks { get; }
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
NAudio.Lame.LameConfig LameConfig { get; }
string? AudibleProductId { get; }
string? Title { get; }
string? Subtitle { get; }
string? Publisher { get; }
string? Language { get; }
string? SeriesName { get; }
string? SeriesNumber { get; }
NAudio.Lame.LameConfig? LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitle(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarksAsync(string fileName);
public FileType? InputType { get; }
}
}

View File

@@ -1,31 +1,69 @@
using AAXClean;
using AAXClean.Codecs;
using NAudio.Lame;
using System;
namespace AaxDecrypter
{
public static class MpegUtil
{
public static void ConfigureLameOptions(Mp4File mp4File, LameConfig lameConfig, bool downsample, bool matchSourceBitrate)
private const string TagDomain = "com.pilabor.tone";
public static void ConfigureLameOptions(
Mp4File mp4File,
LameConfig lameConfig,
bool downsample,
bool matchSourceBitrate,
ChapterInfo chapters)
{
double bitrateMultiple = 1;
if (mp4File.TimeScale < lameConfig.OutputSampleRate)
{
lameConfig.OutputSampleRate = mp4File.TimeScale;
}
else if (mp4File.TimeScale > lameConfig.OutputSampleRate)
{
bitrateMultiple *= (double)lameConfig.OutputSampleRate / mp4File.TimeScale;
}
if (mp4File.AudioChannels == 2)
{
if (downsample)
bitrateMultiple = 0.5;
bitrateMultiple /= 2;
else
lameConfig.Mode = MPEGMode.Stereo;
}
if (matchSourceBitrate)
{
int kbps = (int)(mp4File.AverageBitrate * bitrateMultiple / 1024);
int kbps = (int)Math.Round(mp4File.AverageBitrate * bitrateMultiple / 1024);
if (lameConfig.VBR is null)
lameConfig.BitRate = kbps;
else if (lameConfig.VBR == VBRMode.ABR)
lameConfig.ABRRateKbps = kbps;
}
//Setup metadata tags
lameConfig.ID3 = mp4File.AppleTags.ToIDTags();
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
lameConfig.ID3.Subtitle = subtitle;
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
lameConfig.ID3.UserDefinedText.Add("SERIES", series);
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
lameConfig.ID3.UserDefinedText.Add("PART", part);
if (chapters?.Count > 0)
{
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
}
}
}
}

View File

@@ -40,6 +40,9 @@ namespace AaxDecrypter
[JsonIgnore]
public bool IsCancelled => _cancellationSource.IsCancellationRequested;
[JsonIgnore]
public Task DownloadTask { get; private set; }
private long _speedLimit = 0;
/// <summary>bytes per second</summary>
public long SpeedLimit { get => _speedLimit; set => _speedLimit = value <= 0 ? 0 : Math.Max(value, MIN_BYTES_PER_SECOND); }
@@ -51,20 +54,21 @@ namespace AaxDecrypter
private FileStream _readFile { get; }
private CancellationTokenSource _cancellationSource { get; } = new();
private EventWaitHandle _downloadedPiece { get; set; }
private Task _backgroundDownloadTask { get; set; }
private DateTime NextUpdateTime { get; set; }
#endregion
#region Constants
//Download buffer size
private const int DOWNLOAD_BUFF_SZ = 32 * 1024;
//Download memory buffer size
private const int DOWNLOAD_BUFF_SZ = 8 * 1024;
//NetworkFileStream will flush all data in _writeFile to disk after every
//DATA_FLUSH_SZ bytes are written to the file stream.
private const int DATA_FLUSH_SZ = 1024 * 1024;
//Number of times per second the download rate is checkd and throttled
//Number of times per second the download rate is checked and throttled
private const int THROTTLE_FREQUENCY = 8;
//Minimum throttle rate. The minimum amount of data that can be throttled
@@ -96,6 +100,12 @@ namespace AaxDecrypter
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);
SetUriForSameFile(uri);
@@ -106,12 +116,18 @@ namespace AaxDecrypter
#region Downloader
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
private void OnUpdate()
private void OnUpdate(bool waitForWrite = false)
{
RequestHeaders["Range"] = $"bytes={WritePosition}-";
try
{
Updated?.Invoke(this, EventArgs.Empty);
if (waitForWrite || DateTime.UtcNow > NextUpdateTime)
{
Updated?.Invoke(this, EventArgs.Empty);
//JsonFilePersister Will not allow update intervals shorter than 100 milliseconds
//If an update is called less than 100 ms since the last update, persister will
//sleep the thread until 100 ms has elapsed.
NextUpdateTime = DateTime.UtcNow.AddMilliseconds(110);
}
}
catch (Exception ex)
{
@@ -125,57 +141,132 @@ namespace AaxDecrypter
{
ArgumentValidator.EnsureNotNullOrWhiteSpace(uriToSameFile?.AbsoluteUri, nameof(uriToSameFile));
if (Path.GetFileName(uriToSameFile.LocalPath) != Path.GetFileName(Uri.LocalPath))
throw new ArgumentException($"New uri to the same file must have the same file name.");
if (uriToSameFile.Host != Uri.Host)
throw new ArgumentException($"New uri to the same file must have the same host.\r\n Old Host :{Uri.Host}\r\nNew Host: {uriToSameFile.Host}");
if (_backgroundDownloadTask is not null)
if (DownloadTask is not null)
throw new InvalidOperationException("Cannot change Uri after download has started.");
Uri = uriToSameFile;
RequestHeaders["Range"] = $"bytes={WritePosition}-";
}
/// <summary> Begins downloading <see cref="Uri"/> to <see cref="SaveFilePath"/> in a background thread. </summary>
/// <returns>The downloader <see cref="Task"/></returns>
private Task BeginDownloading()
public async Task BeginDownloadingAsync()
{
if (ContentLength != 0 && WritePosition == ContentLength)
return Task.CompletedTask;
{
DownloadTask = Task.CompletedTask;
return;
}
if (ContentLength != 0 && WritePosition > ContentLength)
throw new WebException($"Specified write position (0x{WritePosition:X10}) is larger than {nameof(ContentLength)} (0x{ContentLength:X10}).");
var request = new HttpRequestMessage(HttpMethod.Get, Uri);
//Initiate connection with the first request block and
//get the total content length before returning.
var client = new HttpClient();
var response = await RequestNextByteRangeAsync(client);
if (ContentLength != 0 && ContentLength != response.FileSize)
throw new WebException($"Content length of 0x{response.FileSize:X10} differs from partially downloaded content length of 0x{ContentLength:X10}");
ContentLength = response.FileSize;
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
//Hand off the client and the open request to the downloader to download and write data to file.
DownloadTask = Task.Run(() => DownloadLoopInternal(client , response), _cancellationSource.Token);
}
private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse)
{
try
{
long startPosition = WritePosition;
while (WritePosition < ContentLength && !IsCancelled)
{
try
{
await DownloadToFile(blockResponse);
}
catch (HttpIOException e)
when (e.HttpRequestError is HttpRequestError.ResponseEnded
&& WritePosition != startPosition
&& WritePosition < ContentLength && !IsCancelled)
{
Serilog.Log.Logger.Debug($"The download connection ended before the file completed downloading all 0x{ContentLength:X10} bytes");
//the download made *some* progress since the last attempt.
//Try again to complete the download from where it left off.
//Make sure to rewind file to last flush position.
_writeFile.Position = startPosition = WritePosition;
blockResponse.Dispose();
blockResponse = await RequestNextByteRangeAsync(client);
Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}.");
}
}
}
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
{
_writeFile.Dispose();
blockResponse.Dispose();
client.Dispose();
}
}
private async Task<BlockResponse> RequestNextByteRangeAsync(HttpClient client)
{
using var request = new HttpRequestMessage(HttpMethod.Get, Uri);
//Just in case it snuck in the saved json (Issue #1232)
RequestHeaders.Remove("Range");
foreach (var header in RequestHeaders)
request.Headers.Add(header.Key, header.Value);
var response = new HttpClient().Send(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
request.Headers.Add("Range", $"bytes={WritePosition}-");
var response = await client.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, _cancellationSource.Token);
if (response.StatusCode != HttpStatusCode.PartialContent)
throw new WebException($"Server at {Uri.Host} responded with unexpected status code: {response.StatusCode}.");
//Content length is the length of the range request, and it is only equal
//to the complete file length if requesting Range: bytes=0-
if (WritePosition == 0)
ContentLength = response.Content.Headers.ContentLength.GetValueOrDefault();
var totalSize = response.Content.Headers.ContentRange?.Length ??
throw new WebException("The response did not contain a total content length.");
var networkStream = response.Content.ReadAsStream(_cancellationSource.Token);
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
var rangeSize = response.Content.Headers.ContentLength ??
throw new WebException($"The response did not contain a {nameof(response.Content.Headers.ContentLength)};");
//Download the file in the background.
return Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
return new BlockResponse(response, rangeSize, totalSize);
}
private readonly record struct BlockResponse(HttpResponseMessage Response, long BlockSize, long FileSize) : IDisposable
{
public void Dispose() => Response?.Dispose();
}
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
private async Task DownloadFile(Stream networkStream)
private async Task DownloadToFile(BlockResponse block)
{
var endPosition = WritePosition + block.BlockSize;
using var networkStream = await block.Response.Content.ReadAsStreamAsync(_cancellationSource.Token);
var downloadPosition = WritePosition;
var nextFlush = downloadPosition + DATA_FLUSH_SZ;
var buff = new byte[DOWNLOAD_BUFF_SZ];
try
{
DateTime startTime = DateTime.Now;
DateTime startTime = DateTime.UtcNow;
long bytesReadSinceThrottle = 0;
int bytesRead;
do
@@ -200,36 +291,35 @@ namespace AaxDecrypter
if (SpeedLimit >= MIN_BYTES_PER_SECOND && bytesReadSinceThrottle > SpeedLimit / THROTTLE_FREQUENCY)
{
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.Now).TotalMilliseconds;
var delayMS = (int)(startTime.AddSeconds(1d / THROTTLE_FREQUENCY) - DateTime.UtcNow).TotalMilliseconds;
if (delayMS > 0)
await Task.Delay(delayMS, _cancellationSource.Token);
startTime = DateTime.Now;
startTime = DateTime.UtcNow;
bytesReadSinceThrottle = 0;
}
#endregion
} while (downloadPosition < ContentLength && !IsCancelled && bytesRead > 0);
} while (downloadPosition < endPosition && !IsCancelled && bytesRead > 0);
await _writeFile.FlushAsync(_cancellationSource.Token);
WritePosition = downloadPosition;
if (!IsCancelled && WritePosition < ContentLength)
if (!IsCancelled && WritePosition < endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is less than {nameof(ContentLength)} (0x{ContentLength:X10}).");
if (WritePosition > ContentLength)
if (WritePosition > endPosition)
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");
}
finally
{
networkStream.Close();
_writeFile.Close();
_downloadedPiece.Set();
OnUpdate();
OnUpdate(waitForWrite: true);
}
}
@@ -251,7 +341,8 @@ namespace AaxDecrypter
{
get
{
_backgroundDownloadTask ??= BeginDownloading();
if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
return ContentLength;
}
}
@@ -274,7 +365,8 @@ namespace AaxDecrypter
public override int Read(byte[] buffer, int offset, int count)
{
_backgroundDownloadTask ??= BeginDownloading();
if (DownloadTask is null)
throw new InvalidOperationException($"Background downloader must first be started by calling {nameof(BeginDownloadingAsync)}");
var toRead = Math.Min(count, Length - Position);
WaitToPosition(Position + toRead);
@@ -299,7 +391,7 @@ namespace AaxDecrypter
private void WaitToPosition(long requiredPosition)
{
while (WritePosition < requiredPosition
&& _backgroundDownloadTask?.IsCompleted is false
&& DownloadTask?.IsCompleted is false
&& !IsCancelled)
{
_downloadedPiece.WaitOne(50);
@@ -316,18 +408,17 @@ namespace AaxDecrypter
*/
protected override void Dispose(bool disposing)
{
if (disposing && !disposed)
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
{
_cancellationSource.Cancel();
_backgroundDownloadTask?.GetAwaiter().GetResult();
DownloadTask?.GetAwaiter().GetResult();
_downloadedPiece?.Dispose();
_cancellationSource?.Dispose();
_readFile.Dispose();
_writeFile.Dispose();
OnUpdate();
OnUpdate(waitForWrite: true);
}
disposed = true;
base.Dispose(disposing);
}

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();
}
}

View File

@@ -1,42 +1,32 @@
using FileManager;
using System;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
{
protected override long InputFilePosition => InputFileStream.WritePosition;
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
: base(outDirectory, cacheDirectory, dlLic)
{
AsyncSteps.Name = "Download Unencrypted Audiobook";
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
}
public override Task CancelAsync()
{
IsCanceled = true;
FinalizeDownload();
return Task.CompletedTask;
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
// MUST put InputFileStream.Length first, because it starts background downloader.
while (InputFileStream.Length > InputFilePosition && !InputFileStream.IsCancelled)
await Task.Delay(200);
await InputFileStream.DownloadTask;
if (IsCanceled)
return false;
else
{
FinalizeDownload();
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
OnFileCreated(OutputFileName);
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
OnTempFileCreated(tempFile);
return true;
}
}

View File

@@ -1,11 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<Version>9.3.2.1</Version>
<TargetFramework>net10.0</TargetFramework>
<Version>13.0.0.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="5.0.0" />
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks -->
<!-- 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.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
@@ -18,4 +22,4 @@
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
</Project>
</Project>

View File

@@ -1,31 +1,41 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reflection;
using ApplicationServices;
using ApplicationServices;
using AudibleUtilities;
using Dinah.Core;
using Dinah.Core.IO;
using Dinah.Core.Logging;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.IO;
using System.Linq;
using System.Reflection;
using System.Runtime.InteropServices;
namespace AppScaffolding
{
public enum ReleaseIdentifier
public enum ReleaseIdentifier
{
None,
WindowsClassic,
WindowsAvalonia,
LinuxAvalonia,
MacOSAvalonia
WindowsClassic = OS.Windows | Variety.Classic | Architecture.X64,
WindowsAvalonia = OS.Windows | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia_Arm64 = OS.Linux | 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
public enum VarietyType { None, Classic, Chardonnay }
[Flags]
public enum Variety
{
None,
Classic = 0x10000,
Chardonnay = 0x20000,
}
public static class LibationScaffolding
{
@@ -33,13 +43,7 @@ namespace AppScaffolding
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
public static ReleaseIdentifier ReleaseIdentifier { get; private set; }
public static VarietyType Variety
=> ReleaseIdentifier == ReleaseIdentifier.WindowsClassic ? VarietyType.Classic
: ReleaseIdentifier.In(ReleaseIdentifier.WindowsAvalonia, ReleaseIdentifier.LinuxAvalonia, ReleaseIdentifier.MacOSAvalonia) ? VarietyType.Chardonnay
: VarietyType.None;
public static void SetReleaseIdentifier(ReleaseIdentifier releaseID)
=> ReleaseIdentifier = releaseID;
public static Variety Variety { get; private set; }
// AppScaffolding
private static Assembly _executingAssembly;
@@ -58,13 +62,15 @@ namespace AppScaffolding
??= new[] { ExecutingAssembly.GetName(), EntryAssembly.GetName() }
.Max(a => a.Version);
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
/// <summary>Run migrations before loading Configuration for the first time. Then load and return Configuration</summary>
public static Configuration RunPreConfigMigrations()
{
// must occur before access to Configuration instance
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
Configuration.SetLibationVersion(BuildVersion);
//***********************************************//
// //
// do not use Configuration before this line //
@@ -74,8 +80,18 @@ namespace AppScaffolding
}
/// <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();
//
@@ -83,11 +99,53 @@ namespace AppScaffolding
//
Migrations.migrate_to_v6_6_9(config);
Migrations.migrate_to_v11_5_0(config);
Migrations.migrate_to_v11_6_5(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>
public static void RunPostMigrationScaffolding(Configuration config)
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
{
Variety = Enum.IsDefined(variety) ? variety : Variety.None;
var releaseID = (ReleaseIdentifier)((int)variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);
ReleaseIdentifier = Enum.IsDefined(releaseID) ? releaseID : ReleaseIdentifier.None;
ensureSerilogConfig(config);
configureLogging(config);
logStartupState(config);
@@ -99,14 +157,35 @@ namespace AppScaffolding
private static void ensureSerilogConfig(Configuration config)
{
if (config.GetObject("Serilog") is not null)
if (config.GetObject("Serilog") is JObject serilog)
{
bool fileChanged = false;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
{
zipFileSink["Name"] = "File";
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;
}
var serilogObj = new JObject
{
{ "MinimumLevel", "Information" },
{ "WriteTo", new JArray
{
// ABOUT SINKS
// 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
{
@@ -115,7 +194,7 @@ namespace AppScaffolding
new JObject
{
// 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" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -123,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}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
// {Properties:j} needed for expanded exception logging
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
{ "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
}
}
}
@@ -198,6 +278,7 @@ namespace AppScaffolding
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
#nullable enable
private static void logStartupState(Configuration config)
{
#if DEBUG
@@ -210,12 +291,22 @@ namespace AppScaffolding
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
Log.Logger.Information("Begin. {@DebugInfo}", new
static int fileCount(FileManager.LongPath? longPath)
{
if (longPath is null)
return -1;
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
catch { return -1; }
}
Log.Logger.Information("Begin. {@DebugInfo}", new
{
AppName = EntryAssembly.GetName().Name,
Version = BuildVersion.ToString(),
ReleaseIdentifier,
Configuration.OS,
Environment.OSVersion,
InteropFactory.InteropFunctionsType,
Mode = mode,
LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(),
@@ -225,6 +316,7 @@ namespace AppScaffolding
LogLevel_Error_Enabled = Log.Logger.IsErrorEnabled(),
LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(),
config.AutoScan,
config.BetaOptIn,
config.UseCoverAsFolderIcon,
config.LibationFiles,
@@ -233,52 +325,48 @@ namespace AppScaffolding
config.InProgress,
AudibleFileStorage.DownloadsInProgressDirectory,
DownloadsInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DownloadsInProgressDirectory).Count(),
DownloadsInProgressFiles = fileCount(AudibleFileStorage.DownloadsInProgressDirectory),
AudibleFileStorage.DecryptInProgressDirectory,
DecryptInProgressFiles = FileManager.FileUtility.SaferEnumerateFiles(AudibleFileStorage.DecryptInProgressDirectory).Count(),
DecryptInProgressFiles = fileCount(AudibleFileStorage.DecryptInProgressDirectory),
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");
}
private static void wireUpSystemEvents(Configuration configuration)
#nullable restore
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (_, __) => SearchEngineCommands.FullReIndex();
LibraryCommands.BookUserDefinedItemCommitted += (_, books) => SearchEngineCommands.UpdateBooks(books);
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
=> SearchEngineCommands.UpdateBooks(books);
}
public static UpgradeProperties GetLatestRelease()
{
// 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)
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)
if (version is null || latest is null || zip is null)
return null;
// we have an update
var zipUrl = zip?.BrowserDownloadUrl;
Log.Logger.Information("Update available: {@DebugInfo}", new
{
latestRelease = latestRelease.ToString(),
latestRelease = version.ToString(),
latest.HtmlUrl,
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
{
@@ -292,26 +380,41 @@ namespace AppScaffolding
{
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()
{
var ownerAccount = "rmcrackan";
var repoName = "Libation";
const string ownerAccount = "rmcrackan";
const string repoName = "Libation";
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
var bts = await gitHubClient.Repository.Content.GetRawContent(ownerAccount, repoName, ".releaseindex.json");
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
var regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
// https://octokitnet.readthedocs.io/en/latest/releases/
var releases = await gitHubClient.Repository.Release.GetAll(ownerAccount, repoName);
string regexPattern;
try
{
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
}
catch
{
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
}
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var latestRelease = releases.FirstOrDefault(r => !r.Draft && !r.Prerelease && r.Assets.Any(a => regex.IsMatch(a.Name)));
return (latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
return (releaseVersion, latestRelease, latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name)));
}
}
@@ -363,5 +466,139 @@ namespace AppScaffolding
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
}
class FilterState_6_6_9
{
public bool UseDefault { get; set; }
public List<string> Filters { get; set; } = new();
}
public static void migrate_to_v12_0_1(Configuration config)
{
#nullable enable
//Migrate from version 1 file cache to the dictionary-based version 2 cache
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{
try
{
//FilePathCache loads the cache in its static constructor,
//so perform migration without using FilePathCache.CacheEntry
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
return;
Dictionary<string, JArray> cache = new();
//Convert to c# objects to speed up searching by ID inside the iterator
var allItems
= v1Cache
.Select(i => new
{
Id = i["Id"]?.Value<string>(),
Path = i["Path"]?["Path"]?.Value<string>()
}).Where(i => i.Id != null)
.ToArray();
foreach (var id in allItems.Select(i => i.Id).OfType<string>().Distinct())
{
//Use this opportunity to purge non-existent files and re-classify file types
//(due to *.aax files previously not being classified as FileType.AAXC)
var items = allItems
.Where(i => i.Id == id && File.Exists(i.Path))
.Select(i => new JObject
{
{ "Id", i.Id },
{ "FileType", (int)FileTypes.GetFileTypeFromPath(i.Path) },
{ "Path", new JObject{ { "Path", i.Path } } }
})
.ToArray();
if (items.Length == 0)
continue;
cache[id] = new JArray(items);
}
var cacheJson = new JObject { { "Dictionary", JObject.FromObject(cache) } };
var cacheFileText = cacheJson.ToString(Formatting.Indented);
void migrate()
{
File.WriteAllText(jsonFileV2, cacheFileText);
File.Delete(jsonFileV1);
}
try { migrate(); }
catch (IOException)
{
try { migrate(); }
catch (IOException)
{
migrate();
}
}
}
catch { /* eat */ }
}
#nullable restore
}
public static void migrate_to_v11_6_5(Configuration config)
{
//Settings migration for unsupported sample rates (#1116)
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
}
public static void migrate_to_v11_5_0(Configuration config)
{
// Read file, but convert old format to new (with Name field) as necessary.
if (!File.Exists(QuickFilters.JsonFile))
{
QuickFilters.InMemoryState = new();
return;
}
try
{
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
is QuickFilters.FilterState inMemState)
{
QuickFilters.InMemoryState = inMemState;
return;
}
}
catch
{
// Eat
}
try
{
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
is FilterState_6_6_9 inMemState)
{
// Copy old structure to new.
QuickFilters.InMemoryState = new();
QuickFilters.InMemoryState.UseDefault = inMemState.UseDefault;
foreach (var oldFilter in inMemState.Filters)
QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null));
return;
}
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
}
catch
{
// Eat
}
}
}
}

View File

@@ -1,29 +0,0 @@
using System;
namespace AppScaffolding
{
public abstract class OSConfigBase
{
public abstract Type InteropFunctionsType { get; }
public virtual Type[] ReferencedTypes { get; } = new Type[0];
public void Run()
{
//Each of these types belongs to a different windows-only assembly that's needed by
//the WinInterop methods. By referencing these types in main we force the runtime to
//load their assemblies before execution reaches inside main. This allows the calling
//process to find these assemblies in its module list.
_ = ReferencedTypes;
_ = InteropFunctionsType;
//Wait for the calling process to be ready to read the WriteLine()
Console.ReadLine();
// Signal the calling process that execution has reached inside main, and that all referenced assemblies have been loaded.
Console.WriteLine();
// Wait for the calling process to finish reading the process module list, then exit.
Console.ReadLine();
}
}
}

View File

@@ -7,6 +7,7 @@ using LibationFileManager;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
#nullable enable
namespace AppScaffolding
{
/// <summary>
@@ -20,21 +21,21 @@ namespace AppScaffolding
/// </summary>
public static class UNSAFE_MigrationHelper
{
public static string SettingsDirectory
=> !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null
public static string? SettingsDirectory
=> !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null
? null
: value;
#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;
JToken val = null;
JToken? val = null;
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;
}
@@ -59,7 +60,10 @@ namespace AppScaffolding
/// <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)
{
var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile);
if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile)
return;
var startingContents = File.ReadAllText(appSettingsFile);
JObject jObj;
try
@@ -82,40 +86,37 @@ namespace AppScaffolding
if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact))
return;
File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented);
File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented);
System.Threading.Thread.Sleep(100);
}
#endregion
#region Settings.json
public const string LIBATION_FILES_KEY = "LibationFiles";
private const string SETTINGS_JSON = "Settings.json";
public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON);
public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath);
public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON);
public static bool Settings_TryGet(string key, out string value)
public static bool Settings_TryGet(string key, out string? value)
{
bool success = false;
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false);
value = success ? val.Value<string>() : null;
value = success ? val?.Value<string>() : null;
return success;
}
public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType)
{
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
return val?.Type == jTokenType;
}
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value)
public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value)
{
JToken val = null;
JToken? val = null;
process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false);
@@ -157,10 +158,10 @@ namespace AppScaffolding
if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array))
return false;
JArray array = null;
process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath));
JArray? array = null;
process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray);
length = array.Count;
length = array?.Count ?? 0;
return true;
}
@@ -171,8 +172,7 @@ namespace AppScaffolding
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
array.Add(newValue);
(jObj.SelectToken(jsonPath) as JArray)?.Add(newValue);
});
}
@@ -200,8 +200,7 @@ namespace AppScaffolding
process_SettingsJson(jObj =>
{
var array = (JArray)jObj.SelectToken(jsonPath);
if (position < array.Count)
if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count)
array.RemoveAt(position);
});
}
@@ -228,7 +227,7 @@ namespace AppScaffolding
private static void process_SettingsJson(Action<JObject> action, bool save = true)
{
// only insert if not exists
if (!SettingsJson_Exists)
if (!File.Exists(SettingsJsonPath))
return;
var startingContents = File.ReadAllText(SettingsJsonPath);
@@ -260,7 +259,7 @@ namespace AppScaffolding
#endregion
#region LibationContext.db
public const string LIBATION_CONTEXT = "LibationContext.db";
public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT);
public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile);
#endregion
}

View File

@@ -1,5 +1,4 @@
using NPOI.XWPF.UserModel;
using System;
using System;
using System.Text.RegularExpressions;
namespace AppScaffolding

View File

@@ -1,17 +1,19 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="30.0.1" />
<PackageReference Include="NPOI" Version="2.6.0" />
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\DtoImporterService\DtoImporterService.csproj" />
<ProjectReference Include="..\LibationSearchEngine\LibationSearchEngine.csproj" />
<ProjectReference Include="..\DataLayer.Postgres\DataLayer.Postgres.csproj" />
<ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" />
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -10,7 +10,7 @@ namespace ApplicationServices
{
public class BulkSetDownloadStatus
{
private List<(string message, LiberatedStatus newStatus, IEnumerable<Book> Books)> actionSets { get; } = new();
private List<(string message, LiberatedStatus newStatus, IEnumerable<LibraryBook> LibraryBooks)> actionSets { get; } = new();
public int Count => actionSets.Count;
@@ -33,7 +33,7 @@ namespace ApplicationServices
var bookExistsList = _libraryBooks
.Select(libraryBook => new
{
libraryBook.Book,
LibraryBook = libraryBook,
FileExists = AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId) is not null
})
.ToList();
@@ -41,8 +41,8 @@ namespace ApplicationServices
if (_setDownloaded)
{
var books2change = bookExistsList
.Where(a => a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
.Select(a => a.Book)
.Where(a => a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.Liberated)
.Select(a => a.LibraryBook)
.ToList();
if (books2change.Any())
@@ -55,8 +55,8 @@ namespace ApplicationServices
if (_setNotDownloaded)
{
var books2change = bookExistsList
.Where(a => !a.FileExists && a.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
.Select(a => a.Book)
.Where(a => !a.FileExists && a.LibraryBook.Book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated)
.Select(a => a.LibraryBook)
.ToList();
if (books2change.Any())
@@ -69,10 +69,10 @@ namespace ApplicationServices
return Count;
}
public void Execute()
public async Task ExecuteAsync()
{
foreach (var a in actionSets)
a.Books.UpdateBookStatus(a.newStatus);
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
}
}
}

View File

@@ -1,21 +1,40 @@
using System;
using System.Collections.Generic;
using DataLayer;
using DataLayer;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices
{
public static class DbContexts
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString);
public static class DbContexts
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
{
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>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{
/// <summary>Use for full library querying. No lazy loading</summary>
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();
return context.GetLibrary_Flat_NoTracking(includeParents);
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
}
}
}

View File

@@ -1,22 +1,28 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Logging;
using DtoImporterService;
using FileManager;
using LibationFileManager;
using Microsoft.Extensions.DependencyModel;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static DtoImporterService.PerfLogger;
#nullable enable
namespace ApplicationServices
{
public static class LibraryCommands
{
public static event EventHandler<int> ScanBegin;
public static event EventHandler ScanEnd;
public static event EventHandler<int>? ScanBegin;
public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
@@ -27,7 +33,7 @@ namespace ApplicationServices
ScanEnd += (_, __) => Scanning = false;
}
public static async Task<List<LibraryBook>> FindInactiveBooks(Func<Account, Task<ApiExtended>> apiExtendedfunc, IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<LibraryBook> existingLibrary, params Account[] accounts)
{
logRestart();
@@ -53,7 +59,7 @@ namespace ApplicationServices
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count;
@@ -65,7 +71,7 @@ namespace ApplicationServices
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// 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:
@@ -90,19 +96,21 @@ namespace ApplicationServices
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
}
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, params Account[] accounts)
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
try
int newCount = 0;
try
{
lock (_lock)
{
@@ -113,11 +121,18 @@ namespace ApplicationServices
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
ResponseGroups = LibraryOptions.ResponseGroupOptions.ALL_OPTIONS,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
| LibraryOptions.ResponseGroupOptions.IsFinished,
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
};
var importItems = await scanAccountsAsync(apiExtendedfunc, accounts, libraryOptions);
var importItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
@@ -127,16 +142,16 @@ namespace ApplicationServices
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
var newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// 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:
@@ -161,63 +176,182 @@ namespace ApplicationServices
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, null);
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
private static async Task<List<ImportItem>> scanAccountsAsync(Func<Account, Task<ApiExtended>> apiExtendedfunc, Account[] accounts, LibraryOptions libraryOptions)
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(accountId, "accountId");
ArgumentValidator.EnsureNotNull(localeName, "localeName");
var importItem = new ImportItem
{
DtoItem = item,
AccountId = accountId,
LocaleName = localeName
};
var importItems = new List<ImportItem> { importItem };
var validator = new LibraryValidator();
var exceptions = validator.Validate(importItems.Select(i => i.DtoItem));
if (exceptions?.Any() ?? false)
{
Log.Logger.Error(new AggregateException(exceptions), "Error validating library book. {@DebugInfo}", new { item, accountId, localeName });
return 0;
}
return DoDbSizeChangeOperation(ctx =>
{
var bookImporter = new BookImporter(ctx);
bookImporter.Import(importItems);
var book = ctx.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId);
if (book is null)
{
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
ctx.LibraryBooks.Add(book);
}
else
{
book.AbsentFromLastScan = false;
}
});
}
private static LogArchiver? openLogArchive(string? archivePath)
{
if (string.IsNullOrWhiteSpace(archivePath))
return null;
try
{
return new LogArchiver(archivePath);
}
catch (System.IO.InvalidDataException)
{
try
{
Log.Logger.Warning($"Deleting corrupted {nameof(LogArchiver)} at {archivePath}");
FileUtility.SaferDelete(archivePath);
return new LogArchiver(archivePath);
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, $"Failed to open {nameof(LogArchiver)} at {archivePath}");
}
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
{
var tasks = new List<Task<List<ImportItem>>>();
foreach (var account in accounts)
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await apiExtendedfunc(account);
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions));
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);
foreach (var account in accounts)
{
try
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await ApiExtended.CreateAsync(account);
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
}
catch(Exception ex)
{
//Catch to allow other accounts to continue scanning.
Log.Logger.Error(ex, "Failed to scan account");
}
}
// import library in parallel
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
var arrayOfLists = await Task.WhenAll(tasks);
var importItems = arrayOfLists.SelectMany(a => a).ToList();
return importItems;
}
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions)
private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new
{
Account = account?.MaskedLogEntry ?? "[null]"
Account = account.MaskedLogEntry ?? "[null]"
});
logTime($"pre scanAccountAsync {account.AccountName}");
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions, Configuration.Instance.ImportEpisodes);
try
{
var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions);
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}");
await logDtoItemsAsync(dtoItems);
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
return dtoItems.Select(d => new ImportItem { DtoItem = d, AccountId = account.AccountId, LocaleName = account.Locale?.Name }).ToList();
}
catch(ImportValidationException ex)
{
await logDtoItemsAsync(ex.Items, ex.InnerExceptions.ToArray());
//If ImportValidationException is thrown, all Dto items get logged as part of the exception
throw new AggregateException(ex.InnerExceptions);
}
async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnumerable<Exception>? exceptions = null)
{
if (archiver is not null)
{
var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json";
var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson)));
var scanFile = new JObject
{
{ "Account", account.MaskedLogEntry },
{ "ScannedDateTime", DateTime.Now.ToString("u") },
};
if (exceptions?.Any() is true)
scanFile.Add("Exceptions", JArray.FromObject(exceptions));
scanFile.Add("Items", items);
await archiver.AddFileAsync(fileName, scanFile);
}
}
}
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");
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");
logTime("importIntoDbAsync -- pre db");
// this is any changes at all to the database, not just new books
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange());
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
int newCount = 0;
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)
{
@@ -242,60 +376,59 @@ namespace ApplicationServices
#endregion
#region remove/restore books
public static Task<int> RemoveBooksAsync(List<string> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(string idToRemove) => removeBooks(new() { idToRemove });
private static int removeBooks(List<string> idsToRemove)
{
try
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
lb.IsDeleted = true;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
}
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
if (idsToRemove is null || !idsToRemove.Any())
return 0;
using var context = DbContexts.GetContext();
var libBooks = context.GetLibrary_Flat_NoTracking();
var removeLibraryBooks = libBooks.Where(lb => idsToRemove.Contains(lb.Book.AudibleProductId)).ToList();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
lb.IsDeleted = true;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return qtyChanges;
}
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)
{
Log.Logger.Error(ex, "Error removing books");
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
public static int RestoreBooks(this List<LibraryBook> libraryBooks)
{
try
public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
return DoDbSizeChangeOperation(ctx =>
{
lb.IsDeleted = false;
context.Attach(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange();
return qtyChanges;
ctx.LibraryBooks.RemoveRange(libraryBooks);
ctx.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
});
}
catch (Exception ex)
{
@@ -303,36 +436,69 @@ namespace ApplicationServices
throw;
}
}
static int DoDbSizeChangeOperation(Action<LibationContext> action)
{
try
{
int qtyChanges;
List<LibraryBook>? library;
using (var context = DbContexts.GetContext())
{
action?.Invoke(context);
qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
library = qtyChanges == 0 ? null : context.GetLibrary_Flat_NoTracking(includeParents: true);
}
if (library is not null)
finalizeLibrarySizeChange(library);
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error performing DB Size change operation");
throw;
}
}
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange() => LibrarySizeChanged?.Invoke(null, null);
private static void finalizeLibrarySizeChange(List<LibraryBook> library)
{
LibrarySizeChanged?.Invoke(null, library);
}
/// <summary>Occurs when the size of the library changes. ie: books are added or removed</summary>
public static event EventHandler LibrarySizeChanged;
public static event EventHandler<List<LibraryBook>>? LibrarySizeChanged;
/// <summary>
/// Occurs when the size of the library does not change but book(s) details do. Especially when <see cref="UserDefinedItem.Tags"/>, <see cref="UserDefinedItem.BookStatus"/>, or <see cref="UserDefinedItem.PdfStatus"/> changed values are successfully persisted.
/// </summary>
public static event EventHandler<IEnumerable<Book>> BookUserDefinedItemCommitted;
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(
this Book book,
string tags = null,
public static async Task<int> UpdateUserDefinedItemAsync(
this LibraryBook lb,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
=> new[] { book }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
Rating? rating = null)
=> await UpdateUserDefinedItemAsync([lb], tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem(
this IEnumerable<Book> books,
string tags = null,
public static async Task<int> UpdateUserDefinedItemAsync(
this IEnumerable<LibraryBook> lb,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating rating = null)
=> updateUserDefinedItem(
books,
Rating? rating = null)
=> await UpdateUserDefinedItemAsync(
lb,
udi => {
// blank tags are expected. null tags are not
if (tags is not null)
@@ -346,66 +512,58 @@ namespace ApplicationServices
if (rating is not null)
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
});
public static int UpdateBookStatus(this Book book, LiberatedStatus bookStatus)
=> book.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<Book> books, LiberatedStatus bookStatus)
=> books.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> await lb.UpdateUserDefinedItemAsync(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static int UpdatePdfStatus(this Book book, LiberatedStatus pdfStatus)
=> book.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<Book> books, LiberatedStatus pdfStatus)
=> books.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static async Task<int> UpdateBookStatusAsync(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static async Task<int> UpdateBookStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static int UpdateTags(this Book book, string tags)
=> book.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<Book> books, string tags)
=> books.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this LibraryBook libraryBook, string tags)
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static async Task<int> UpdatePdfStatusAsync(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static async Task<int> UpdatePdfStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> libraryBook.Book.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> libraryBooks.Select(lb => lb.Book).updateUserDefinedItem(action);
public static async Task<int> UpdateTagsAsync(this LibraryBook libraryBook, string tags)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static int UpdateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => book.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action) => books.updateUserDefinedItem(action);
public static async Task<int> UpdateUserDefinedItemAsync(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> await UpdateUserDefinedItemAsync([libraryBook], action);
private static int updateUserDefinedItem(this Book book, Action<UserDefinedItem> action) => new[] { book }.updateUserDefinedItem(action);
private static int updateUserDefinedItem(this IEnumerable<Book> books, Action<UserDefinedItem> action)
public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> Task.Run(() => libraryBooks.updateUserDefinedItem(action));
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
{
try
{
if (books is null || !books.Any())
if (libraryBooks is null || !libraryBooks.Any())
return 0;
foreach (var book in books)
action?.Invoke(book.UserDefinedItem);
using var context = DbContexts.GetContext();
// Attach() NoTracking entities before SaveChanges()
foreach (var book in books)
int qtyChanges;
using (var context = DbContexts.GetContext())
{
context.Attach(book.UserDefinedItem).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
context.Attach(book.UserDefinedItem.Rating).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{
action?.Invoke(book.Book.UserDefinedItem);
var qtyChanges = context.SaveChanges();
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, books);
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
return qtyChanges;
}
@@ -419,7 +577,7 @@ namespace ApplicationServices
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
=> book.AudioExists ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
@@ -428,40 +586,76 @@ namespace ApplicationServices
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int pdfsDownloaded, int pdfsNotDownloaded)
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable, IEnumerable<LibraryBook> LibraryBooks)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded);
}
public static LibraryStats GetCounts()
public bool HasBookResults => 0 < (booksFullyBackedUp + booksDownloadedOnly + booksNoProgress + booksError + booksUnavailable);
public bool HasPdfResults => 0 < (pdfsNotDownloaded + pdfsDownloaded + pdfsUnavailable);
public string StatusString => HasPdfResults ? $"{toBookStatusString()} | {toPdfStatusString()}" : toBookStatusString();
private string toBookStatusString()
{
if (!HasBookResults) return "No books. Begin by importing your library";
if (!HasPendingBooks && booksError + booksUnavailable == 0) return $"All {"book".PluralizeWithCount(booksFullyBackedUp)} backed up";
var sb = new StringBuilder($"BACKUPS: No progress: {booksNoProgress} In process: {booksDownloadedOnly} Fully backed up: {booksFullyBackedUp}");
if (booksError > 0)
sb.Append($" Errors: {booksError}");
if (booksUnavailable > 0)
sb.Append($" Unavailable: {booksUnavailable}");
return sb.ToString();
}
private string toPdfStatusString()
{
if (pdfsNotDownloaded + pdfsUnavailable == 0) return $"All {pdfsDownloaded} PDFs downloaded";
var sb = new StringBuilder($"PDFs: NOT d/l'ed: {pdfsNotDownloaded} Downloaded: {pdfsDownloaded}");
if (pdfsUnavailable > 0)
sb.Append($" Unavailable: {pdfsUnavailable}");
return sb.ToString();
}
}
public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null)
{
var libraryBooks = DbContexts.GetLibrary_Flat_NoTracking();
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var results = libraryBooks
.AsParallel()
.Select(lb => Liberated_Status(lb.Book))
.WithoutParents()
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Liberated_Status(lb.Book) })
.ToList();
var booksFullyBackedUp = results.Count(r => r == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => r == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => r == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r == LiberatedStatus.Error);
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError });
var booksFullyBackedUp = results.Count(r => r.status == LiberatedStatus.Liberated);
var booksDownloadedOnly = results.Count(r => !r.absent && r.status == LiberatedStatus.PartialDownload);
var booksNoProgress = results.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var booksError = results.Count(r => r.status == LiberatedStatus.Error);
var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload);
var boolResults = libraryBooks
Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable });
var pdfResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Select(lb => Pdf_Status(lb.Book))
.Where(lb => lb.Book.HasPdf)
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
.ToList();
var pdfsDownloaded = boolResults.Count(r => r == LiberatedStatus.Liberated);
var pdfsNotDownloaded = boolResults.Count(r => r == LiberatedStatus.NotLiberated);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = boolResults.Count, pdfsDownloaded, pdfsNotDownloaded });
var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated);
var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated);
var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated);
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, pdfsDownloaded, pdfsNotDownloaded);
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
}
}
}

View File

@@ -1,11 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using NPOI.XSSF.UserModel;
using Serilog;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ApplicationServices
{
@@ -35,6 +36,9 @@ namespace ApplicationServices
[Name("Title")]
public string Title { get; set; }
[Name("Subtitle")]
public string Subtitle { get; set; }
[Name("Authors")]
public string AuthorNames { get; set; }
@@ -101,12 +105,40 @@ namespace ApplicationServices
[Name("Content Type")]
public string ContentType { get; set; }
[Name("Audio Format")]
public string AudioFormat { get; set; }
[Name("Language")]
public string Language { get; set; }
}
[Name("LastDownloaded")]
public DateTime? LastDownloaded { get; set; }
[Name("LastDownloadedVersion")]
public string LastDownloadedVersion { get; set; }
[Name("IsFinished")]
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 List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
@@ -117,31 +149,39 @@ namespace ApplicationServices
AudibleProductId = a.Book.AudibleProductId,
Locale = a.Book.Locale,
Title = a.Book.Title,
AuthorNames = a.Book.AuthorNames(),
NarratorNames = a.Book.NarratorNames(),
Subtitle = a.Book.Subtitle,
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf(),
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating,
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
CommunityRatingStory = a.Book.Rating?.StoryRating,
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = a.Book.CategoriesNames().Any() ? a.Book.CategoriesNames().Aggregate((a, b) => $"{a}, {b}") : "",
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
AudioFormat = a.Book.AudioFormat.ToString(),
Language = a.Book.Language
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
IsFinished = a.Book.UserDefinedItem.IsFinished,
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
{
@@ -150,7 +190,6 @@ namespace ApplicationServices
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
@@ -162,7 +201,7 @@ namespace ApplicationServices
public static void ToJson(string saveFilePath)
{
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);
}
@@ -170,25 +209,18 @@ namespace ApplicationServices
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Library");
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Library");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new[] {
nameof(ExportDto.Account),
nameof(ExportDto.DateAdded),
nameof(ExportDto.AudibleProductId),
nameof(ExportDto.Locale),
nameof(ExportDto.Title),
nameof(ExportDto.Subtitle),
nameof(ExportDto.AuthorNames),
nameof(ExportDto.NarratorNames),
nameof(ExportDto.LengthInMinutes),
@@ -211,89 +243,82 @@ namespace ApplicationServices
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.AudioFormat),
nameof(ExportDto.Language)
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
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)
{
var cell = row.CreateCell(col++);
var name = ExportDto.GetName(c);
cell.SetCellValue(name);
cell.CellStyle = detailSubtotalCellStyle;
var headerCell = headerRow.Cell(col++);
headerCell.Value = ExportDto.GetName(c);
headerCell.Style.Font.Bold = true;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
rowIndex++;
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var dto in dtos)
{
col = 0;
col = 1;
var row = sheet.Row(rowIndex++);
row = sheet.CreateRow(rowIndex);
row.CreateCell(col++).SetCellValue(dto.Account);
var dateAddedCell = row.CreateCell(col++);
dateAddedCell.CellStyle = dateStyle;
dateAddedCell.SetCellValue(dto.DateAdded);
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
row.CreateCell(col++).SetCellValue(dto.Locale);
row.CreateCell(col++).SetCellValue(dto.Title);
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
row.CreateCell(col++).SetCellValue(dto.Description);
row.CreateCell(col++).SetCellValue(dto.Publisher);
row.CreateCell(col++).SetCellValue(dto.HasPdf);
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
col = createCell(row, col, dto.CommunityRatingOverall);
col = createCell(row, col, dto.CommunityRatingPerformance);
col = createCell(row, col, dto.CommunityRatingStory);
row.CreateCell(col++).SetCellValue(dto.PictureId);
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
var datePubCell = row.CreateCell(col++);
datePubCell.CellStyle = dateStyle;
if (dto.DatePublished.HasValue)
datePubCell.SetCellValue(dto.DatePublished.Value);
else
datePubCell.SetCellValue("");
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
col = createCell(row, col, dto.MyRatingOverall);
col = createCell(row, col, dto.MyRatingPerformance);
col = createCell(row, col, dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
row.CreateCell(col++).SetCellValue(dto.Language);
rowIndex++;
row.Cell(col++).Value = dto.Account;
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
row.Cell(col++).Value = dto.AudibleProductId;
row.Cell(col++).Value = dto.Locale;
row.Cell(col++).Value = dto.Title;
row.Cell(col++).Value = dto.Subtitle;
row.Cell(col++).Value = dto.AuthorNames;
row.Cell(col++).Value = dto.NarratorNames;
row.Cell(col++).Value = dto.LengthInMinutes;
row.Cell(col++).Value = dto.Description;
row.Cell(col++).Value = dto.Publisher;
row.Cell(col++).Value = dto.HasPdf;
row.Cell(col++).Value = dto.SeriesNames;
row.Cell(col++).Value = dto.SeriesOrder;
row.Cell(col++).Value = dto.CommunityRatingOverall;
row.Cell(col++).Value = dto.CommunityRatingPerformance;
row.Cell(col++).Value = dto.CommunityRatingStory;
row.Cell(col++).Value = dto.PictureId;
row.Cell(col++).Value = dto.IsAbridged;
row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
row.Cell(col++).Value = dto.CategoriesNames;
row.Cell(col++).Value = dto.MyRatingOverall;
row.Cell(col++).Value = dto.MyRatingPerformance;
row.Cell(col++).Value = dto.MyRatingStory;
row.Cell(col++).Value = dto.MyLibationTags;
row.Cell(col++).Value = dto.BookStatus;
row.Cell(col++).Value = dto.PdfStatus;
row.Cell(col++).Value = dto.ContentType;
row.Cell(col++).Value = dto.Language;
row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
row.Cell(col++).Value = dto.LastDownloadedVersion;
row.Cell(col++).Value = dto.IsFinished;
row.Cell(col++).Value = dto.IsSpatial;
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.CodecString;
row.Cell(col++).Value = dto.SampleRate;
row.Cell(col++).Value = dto.ChannelCount;
row.Cell(col++).Value = dto.BitRate;
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
workbook.SaveAs(saveFilePath);
}
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)
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
else
row.CreateCell(col++).SetCellValue("");
return col;
cell.Value = value;
cell.Style.DateFormat.Format = dateFormat;
}
}
}

View File

@@ -1,10 +1,11 @@
using AudibleApi.Common;
using ClosedXML.Excel;
using CsvHelper;
using DataLayer;
using Newtonsoft.Json.Linq;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ApplicationServices
@@ -16,19 +17,10 @@ namespace ApplicationServices
if (!records.Any())
return;
using var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Records");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
using var workbook = new XLWorkbook();
var worksheet = workbook.AddWorksheet("Records");
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new List<string>
{
nameof(Type.Name),
@@ -49,56 +41,52 @@ namespace ApplicationServices
if (records.OfType<Clip>().Any())
columns.Add(nameof(Clip.Title));
var col = 0;
int rowIndex = 1, col = 1;
var headerRow = worksheet.Row(rowIndex++);
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
cell.SetCellValue(c);
cell.CellStyle = detailSubtotalCellStyle;
var headerCell = headerRow.Cell(col++);
headerCell.Value = c;
headerCell.Style.Font.Bold = true;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var record in records)
{
col = 0;
col = 1;
var row = worksheet.Row(rowIndex++);
row = sheet.CreateRow(++rowIndex);
row.CreateCell(col++).SetCellValue(record.GetType().Name);
var dateCreatedCell = row.CreateCell(col++);
dateCreatedCell.CellStyle = dateStyle;
dateCreatedCell.SetCellValue(record.Created.DateTime);
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
row.Cell(col++).Value = record.GetType().Name;
row.Cell(col++).SetDate(record.Created.DateTime, dateFormat);
row.Cell(col++).Value = record.Start.TotalMilliseconds;
if (record is IAnnotation annotation)
{
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
var lastModifiedCell = row.CreateCell(col++);
lastModifiedCell.CellStyle = dateStyle;
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
row.Cell(col++).Value = annotation.AnnotationId;
row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat);
if (annotation is IRangeAnnotation rangeAnnotation)
{
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
row.Cell(col++).Value = rangeAnnotation.Text;
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.Write(fileData);
workbook.SaveAs(saveFilePath);
}
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)
{
if (!records.Any())
@@ -108,7 +96,7 @@ namespace ApplicationServices
var recordsObj = new JObject
{
{ "title", libraryBook.Book.Title},
{ "title", libraryBook.Book.TitleWithSubtitle},
{ "asin", libraryBook.Book.AudibleProductId},
{ "exportTime", DateTime.Now},
{ "records", JArray.FromObject(recordsEx) }

View File

@@ -34,7 +34,7 @@ namespace ApplicationServices
#region Update
private static bool isUpdating;
public static void UpdateBooks(IEnumerable<Book> books)
public static void UpdateBooks(IEnumerable<LibraryBook> books)
{
// Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs.
// I did not benchmark before choosing the number here
@@ -48,11 +48,13 @@ namespace ApplicationServices
}
public static void FullReIndex() => performSafeCommand(fullReIndex);
public static void FullReIndex(List<LibraryBook> libraryBooks)
=> performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents()));
internal static void UpdateUserDefinedItems(Book book) => performSafeCommand(e =>
internal static void UpdateUserDefinedItems(LibraryBook book) => performSafeCommand(e =>
{
e.UpdateLiberatedStatus(book);
e.UpdateTags(book.AudibleProductId, book.UserDefinedItem.Tags);
e.UpdateTags(book.Book.AudibleProductId, book.Book.UserDefinedItem.Tags);
e.UpdateUserRatings(book);
}
);
@@ -94,8 +96,11 @@ namespace ApplicationServices
private static void fullReIndex(SearchEngine engine)
{
var library = DbContexts.GetLibrary_Flat_NoTracking();
engine.CreateNewIndex(library);
fullReIndex(engine, library);
}
private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks)
=> engine.CreateNewIndex(libraryBooks);
#endregion
}
}

View File

@@ -18,68 +18,64 @@ namespace AudibleUtilities
public string AccountId { get; }
// user-friendly, non-canonical name. mutable
private string _accountName;
public string AccountName
{
get => _accountName;
get => field;
set
{
if (string.IsNullOrWhiteSpace(value))
return;
var v = value.Trim();
if (v == _accountName)
if (v == field)
return;
_accountName = v;
field = v;
update();
}
}
// 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
private bool _libraryScan = true;
public bool LibraryScan
{
get => _libraryScan;
get => field;
set
{
if (value == _libraryScan)
if (value == field)
return;
_libraryScan = value;
field = value;
update();
}
}
private string _decryptKey = "";
/// <summary>aka: activation bytes</summary>
public string DecryptKey
{
get => _decryptKey;
get => field ?? "";
set
{
var v = (value ?? "").Trim();
if (v == _decryptKey)
if (v == field)
return;
_decryptKey = v;
field = v;
update();
}
}
private Identity _identity;
public Identity IdentityTokens
{
get => _identity;
get => field;
set
{
if (_identity is null && value is null)
if (field is null && value is null)
return;
if (_identity is not null)
_identity.Updated -= update;
if (field is not null)
field.Updated -= update;
if (value is not null)
value.Updated += update;
_identity = value;
field = value;
update();
}
}

View File

@@ -6,6 +6,7 @@ using AudibleApi.Authorization;
using Dinah.Core;
using Newtonsoft.Json;
#nullable enable
namespace AudibleUtilities
{
// '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)
public class AccountsSettings : IUpdatable
{
public event EventHandler Updated;
private void update(object sender = null, EventArgs e = null)
public event EventHandler? Updated;
private void update(object? sender = null, EventArgs? e = null)
{
foreach (var account in Accounts)
validate(account);
@@ -47,12 +48,28 @@ namespace AudibleUtilities
update_no_validate();
}
}
private string? _cdm;
[JsonProperty]
public string? Cdm
{
get => _cdm;
set
{
if (value is null)
return;
_cdm = value;
update_no_validate();
}
}
[JsonIgnore]
public IReadOnlyList<Account> Accounts => _accounts_json.AsReadOnly();
#endregion
#region de/serialize
public static AccountsSettings FromJson(string json)
public static AccountsSettings? FromJson(string json)
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
public string ToJson(Formatting formatting = Formatting.Indented)
@@ -91,7 +108,7 @@ namespace AudibleUtilities
account.Updated += update;
}
public Account GetAccount(string accountId, string locale)
public Account? GetAccount(string accountId, string? locale)
{
if (locale is null)
return null;

View File

@@ -1,346 +1,292 @@
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Threading;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Diagnostics;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Converters;
using Polly;
using Polly.Retry;
using System.Threading;
using LibationFileManager;
#nullable enable
namespace AudibleUtilities
{
/// <summary>USE THIS from within Libation. It wraps the call with correct JSONPath</summary>
public class ApiExtended
{
public static Func<Account, ILoginChoiceEager>? LoginChoiceFactory { get; set; }
public Api Api { get; private set; }
private const int MaxConcurrency = 10;
private const int BatchSize = 50;
private ApiExtended(Api api) => Api = api;
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginChoiceEager loginChoiceEager)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginChoiceEager,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with native api callbacks.</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginCallback loginCallback)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginCallback),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginCallback,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens else login with external browser</summary>
public static async Task<ApiExtended> CreateAsync(Account account, ILoginExternal loginExternal)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginExternal),
Account = account?.MaskedLogEntry ?? "[null]",
LocaleName = account?.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
loginExternal,
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(Account account)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale));
Serilog.Log.Logger.Information("{@DebugInfo}", new
try
{
AccountMaskedLogEntry = account.MaskedLogEntry
});
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
AccountMaskedLogEntry = account.MaskedLogEntry
});
return await CreateAsync(account.AccountId, account.Locale.Name);
}
/// <summary>Get api from existing tokens. Assumes you have valid login tokens. Else exception</summary>
public static async Task<ApiExtended> CreateAsync(string username, string localeName)
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
var api = await EzApiCreator.GetApiAsync(
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
catch
{
Username = username.ToMask(),
LocaleName = localeName,
});
if (LoginChoiceFactory is null)
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
var api = await EzApiCreator.GetApiAsync(
Localization.Get(localeName),
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = account.Locale?.Name
});
var api = await EzApiCreator.GetApiAsync(
LoginChoiceFactory(account),
account.Locale,
AudibleApiStorage.AccountsSettingsFile,
AudibleApiStorage.GetIdentityTokensJsonPath(username, localeName));
return new ApiExtended(api);
}
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
}
private static AsyncRetryPolicy policy { get; }
= Policy.Handle<Exception>()
// 2 retries == 3 total
.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
// worse, this 1st dummy call doesn't seem to help:
// var page = await api.GetLibraryAsync(new AudibleApi.LibraryOptions { NumberOfResultPerPage = 1, PageNumber = 1, PurchasedAfter = DateTime.Now.AddYears(-20), ResponseGroups = AudibleApi.LibraryOptions.ResponseGroupOptions.ALL_OPTIONS });
// i don't want to incur the cost of making a full dummy call every time because it fails sometimes
return policy.ExecuteAsync(() => getItemsAsync(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)
{
var items = new List<Item>();
Serilog.Log.Logger.Debug("Beginning library scan.");
List<Task<List<Item>>> getChildEpisodesTasks = new();
List<Item> items = new();
var sw = Stopwatch.StartNew();
var totalTime = TimeSpan.Zero;
using var semaphore = new SemaphoreSlim(MaxConcurrency);
int count = 0, maxConcurrentEpisodeScans = 5;
using SemaphoreSlim concurrencySemaphore = new(maxConcurrentEpisodeScans);
var episodeChannel = Channel.CreateUnbounded<string>(new UnboundedChannelOptions { SingleReader = true, SingleWriter = true });
var batchReaderTask = readAllAsinsAsync(episodeChannel.Reader, semaphore);
await foreach (var item in Api.GetLibraryItemAsyncEnumerable(libraryOptions))
//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.
await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore))
{
if ((item.IsEpisodes || item.IsSeriesParent) && importEpisodes)
if (Configuration.Instance.ImportEpisodes)
{
//Get child episodes asynchronously and await all at the end
getChildEpisodesTasks.Add(getChildEpisodesAsync(concurrencySemaphore, item));
var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList();
var series = itemsBatch.Where(i => i.IsSeriesParent).ToList();
var parentAsins = episodes
.SelectMany(i => i.Relationships)
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(r => r.Asin);
var episodeAsins = series
.SelectMany(i => i.Relationships)
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin);
foreach (var asin in parentAsins.Concat(episodeAsins))
episodeChannel.Writer.TryWrite(asin);
items.AddRange(episodes);
items.AddRange(series);
}
else if (!item.IsEpisodes && !item.IsSeriesParent)
items.Add(item);
count++;
var booksInBatch
= itemsBatch
.Where(i => !i.IsSeriesParent && !i.IsEpisodes)
.Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles);
items.AddRange(booksInBatch);
}
Serilog.Log.Logger.Debug("Library scan complete. Found {count} books and series. Waiting on {getChildEpisodesTasksCount} series episode scans to complete.", count, getChildEpisodesTasks.Count);
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Debug("Library scan complete after {elappsed_ms} ms. Found {count} books and series. Waiting on series episode scans to complete.", sw.ElapsedMilliseconds, items.Count);
sw.Restart();
//await and add all episodes from all parents
foreach (var epList in await Task.WhenAll(getChildEpisodesTasks))
items.AddRange(epList);
//Signal that we're done adding asins
episodeChannel.Writer.Complete();
Serilog.Log.Logger.Debug("Completed library scan.");
//Wait for all episodes/parents to be retrived
var allEps = await batchReaderTask;
#if DEBUG
//// this will not work for multi accounts
//var library_json = "library.json";
//library_json = System.IO.Path.GetFullPath(library_json);
//if (System.IO.File.Exists(library_json))
// items = AudibleApi.Common.Converter.FromJson<List<Item>>(System.IO.File.ReadAllText(library_json));
//System.IO.File.WriteAllText(library_json, AudibleApi.Common.Converter.ToJson(items));
#endif
var validators = new List<IValidator>();
validators.AddRange(getValidators());
foreach (var v in validators)
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Debug("Episode scan complete after {elappsed_ms} ms. Found {count} episodes and series .", sw.ElapsedMilliseconds, allEps.Count);
sw.Restart();
Serilog.Log.Logger.Debug("Begin indexing series episodes");
items.AddRange(allEps);
//Set the Item.Series info for episodes and parents.
foreach (var parent in items.Where(i => i.IsSeriesParent))
{
var exceptions = v.Validate(items);
if (exceptions is not null && exceptions.Any())
throw new AggregateException(exceptions);
var children = items.Where(i => i.IsEpisodes && i.Relationships.Any(r => r.Asin == parent.Asin));
SetSeries(parent, children);
}
int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null);
if (orphansRemoved > 0)
Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved);
sw.Stop();
totalTime += sw.Elapsed;
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
if (allExceptions?.Count > 0)
throw new ImportValidationException(items, allExceptions);
return items;
}
private static List<IValidator> getValidators()
{
var type = typeof(IValidator);
var types = AppDomain.CurrentDomain.GetAssemblies()
.SelectMany(s => s.GetTypes())
.Where(p => type.IsAssignableFrom(p) && !p.IsInterface);
return types.Select(t => Activator.CreateInstance(t) as IValidator).ToList();
}
#region episodes and podcasts
private async Task<List<Item>> getChildEpisodesAsync(SemaphoreSlim concurrencySemaphore, Item parent)
/// <summary>
/// Read asins from the channel and request catalog item info in batches of <see cref="BatchSize"/>. Blocks until <paramref name="channelReader"/> is closed.
/// </summary>
/// <param name="channelReader">Input asins to batch</param>
/// <param name="semaphore">Shared semaphore to limit concurrency</param>
/// <returns>All <see cref="Item"/>s of asins written to the channel.</returns>
private async Task<List<Item>> readAllAsinsAsync(ChannelReader<string> channelReader, SemaphoreSlim semaphore)
{
await concurrencySemaphore.WaitAsync();
int batchNum = 1;
List<Task<List<Item>>> getTasks = new();
while (await channelReader.WaitToReadAsync())
{
List<string> asins = new();
while (asins.Count < BatchSize && await channelReader.WaitToReadAsync())
{
var asin = await channelReader.ReadAsync();
if (!asins.Contains(asin))
asins.Add(asin);
}
await semaphore.WaitAsync();
getTasks.Add(getProductsAsync(batchNum++, asins, semaphore));
}
var completed = await Task.WhenAll(getTasks);
//We only want Series parents and Series episodes. Explude other relationship types (e.g. 'season')
return completed.SelectMany(l => l).Where(i => i.IsSeriesParent || i.IsEpisodes).ToList();
}
private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins, SemaphoreSlim semaphore)
{
Serilog.Log.Logger.Debug($"Batch {batchNum} Begin: Fetching {asins.Count} asins");
try
{
Serilog.Log.Logger.Debug("Beginning episode scan for {parent}", parent);
var sw = Stopwatch.StartNew();
var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media
| CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc
| CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview
| CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series
| CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs);
sw.Stop();
List<Item> children;
Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms");
if (parent.IsEpisodes)
return items;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new { asins });
throw;
}
finally { semaphore.Release(); }
}
public static void SetSeries(Item parent, IEnumerable<Item> children)
{
ArgumentValidator.EnsureNotNull(parent, nameof(parent));
ArgumentValidator.EnsureNotNull(children, nameof(children));
//A series parent will always have exactly 1 Series
parent.Series = new[]
{
new Series
{
//The 'parent' is a single episode that was added to the library.
//Get the episode's parent and add it to the database.
Asin = parent.Asin,
Sequence = "-1",
Title = parent.TitleWithSubtitle
}
};
Serilog.Log.Logger.Debug("Supplied Parent is an episode. Beginning parent scan for {parent}", parent);
if (parent.PurchaseDate == default)
{
parent.PurchaseDate = children.Select(c => c.PurchaseDate).Order().FirstOrDefault(d => d != default);
children = new() { parent };
if (parent.PurchaseDate == default)
{
Serilog.Log.Logger.Warning("{series} doesn't have a purchase date. Using UtcNow", parent);
parent.PurchaseDate = DateTimeOffset.UtcNow;
}
}
var parentAsins = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Parent)
.Select(p => p.Asin);
var seriesParents = await Api.GetCatalogProductsAsync(parentAsins, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
int numSeriesParents = seriesParents.Count(p => p.IsSeriesParent);
if (numSeriesParents != 1)
{
//There should only ever be 1 top-level parent per episode. If not, log
//so we can figure out what to do about those special cases, and don't
//import the episode.
JsonSerializerSettings Settings = new()
{
MetadataPropertyHandling = MetadataPropertyHandling.Ignore,
DateParseHandling = DateParseHandling.None,
Converters =
{
new IsoDateTimeConverter { DateTimeStyles = DateTimeStyles.AssumeUniversal }
},
};
Serilog.Log.Logger.Error($"Found {numSeriesParents} parents for {parent.Asin}\r\nEpisode Product:\r\n{JsonConvert.SerializeObject(parent, Formatting.None, Settings)}");
return new List<Item>();
}
var realParent = seriesParents.Single(p => p.IsSeriesParent);
realParent.PurchaseDate = parent.PurchaseDate;
Serilog.Log.Logger.Debug("Completed parent scan for {parent}", parent);
parent = realParent;
int lastEpNum = -1, dupeCount = 0;
foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime))
{
string sequence;
if (child.EpisodeNumber is null)
{
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0";
}
else
{
children = await getEpisodeChildrenAsync(parent);
if (!children.Any())
return new();
//multipart episodes may have the same episode number
if (child.EpisodeNumber == lastEpNum)
dupeCount++;
else
lastEpNum = child.EpisodeNumber.Value;
sequence = (lastEpNum + dupeCount).ToString();
}
//A series parent will always have exactly 1 Series
parent.Series = new Series[]
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new[]
{
new Series
{
Asin = parent.Asin,
Sequence = "-1",
Sequence = sequence,
Title = parent.TitleWithSubtitle
}
};
foreach (var child in children)
{
// use parent's 'DateAdded'. DateAdded is just a convenience prop for: PurchaseDate.UtcDateTime
child.PurchaseDate = parent.PurchaseDate;
// parent is essentially a series
child.Series = new Series[]
{
new Series
{
Asin = parent.Asin,
// This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible
Sequence = parent.Relationships.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0",
Title = parent.TitleWithSubtitle
}
};
}
children.Add(parent);
Serilog.Log.Logger.Debug("Completed episode scan for {parent}", parent);
return children;
}
finally
{
concurrencySemaphore.Release();
}
}
private async Task<List<Item>> getEpisodeChildrenAsync(Item parent)
{
var childrenIds = parent.Relationships
.Where(r => r.RelationshipToProduct == RelationshipToProduct.Child && r.RelationshipType == RelationshipType.Episode)
.Select(r => r.Asin)
.ToList();
// fetch children in batches
const int batchSize = 20;
var results = new List<Item>();
for (var i = 1; ; i++)
{
var idBatch = childrenIds.Skip((i - 1) * batchSize).Take(batchSize).ToList();
if (!idBatch.Any())
break;
List<Item> childrenBatch;
try
{
childrenBatch = await Api.GetCatalogProductsAsync(idBatch, CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
#if DEBUG
//var childrenBatchDebug = childrenBatch.Select(i => i.ToJson()).Aggregate((a, b) => $"{a}\r\n\r\n{b}");
//System.IO.File.WriteAllText($"children of {parent.Asin}.json", childrenBatchDebug);
#endif
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error fetching batch of episodes. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
BatchNumber = i,
ChildIdBatch = idBatch
});
throw;
}
Serilog.Log.Logger.Debug($"Batch {i}: {childrenBatch.Count} results\t({{parent}})", parent);
// the service returned no results. probably indicates an error. stop running batches
if (!childrenBatch.Any())
break;
results.AddRange(childrenBatch);
}
Serilog.Log.Logger.Debug("Parent episodes/podcasts series. Children found. {@DebugInfo}", new
{
ParentId = parent.Asin,
ParentTitle = parent.Title,
ChildCount = childrenIds.Count
});
if (childrenIds.Count != results.Count)
{
var ex = new ApplicationException($"Mis-match: Children defined by parent={childrenIds.Count}. Children returned by batches={results.Count}");
Serilog.Log.Logger.Error(ex, "{parent} - Quantity of series episodes defined by parent does not match quantity returned by batch fetching.", parent);
throw ex;
}
return results;
}
#endregion
}

View File

@@ -5,19 +5,58 @@ using Newtonsoft.Json;
namespace AudibleUtilities
{
public class AccountSettingsLoadErrorEventArgs : ErrorEventArgs
{
/// <summary>
/// Create a new, empty <see cref="AccountsSettings"/> file if true, otherwise throw
/// </summary>
public bool Handled { get; set; }
/// <summary>
/// The file path of the AccountsSettings.json file
/// </summary>
public string SettingsFilePath { get; }
public AccountSettingsLoadErrorEventArgs(string path, Exception exception)
: base(exception)
{
SettingsFilePath = path;
}
}
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 void EnsureAccountsSettingsFileExists()
{
// saves. BEWARE: this will overwrite an existing file
if (!File.Exists(AccountsSettingsFile))
_ = new AccountsSettingsPersister(new AccountsSettings(), AccountsSettingsFile);
{
//Save the JSON file manually so that AccountsSettingsPersister.Saving and AccountsSettingsPersister.Saved
//are not fired. There's no need to fire those events on an empty AccountsSettings file.
var accountSerializerSettings = AudibleApi.Authorization.Identity.GetJsonSerializerSettings();
File.WriteAllText(AccountsSettingsFile, JsonConvert.SerializeObject(new AccountsSettings(), Formatting.Indented, accountSerializerSettings));
}
}
/// <summary>If you use this, be a good citizen and DISPOSE of it</summary>
public static AccountsSettingsPersister GetAccountsSettingsPersister() => new AccountsSettingsPersister(AccountsSettingsFile);
public static AccountsSettingsPersister GetAccountsSettingsPersister()
{
try
{
return new AccountsSettingsPersister(AccountsSettingsFile);
}
catch (Exception ex)
{
var args = new AccountSettingsLoadErrorEventArgs(AccountsSettingsFile, ex);
LoadError?.Invoke(null, args);
if (args.Handled)
return GetAccountsSettingsPersister();
throw;
}
}
public static string GetIdentityTokensJsonPath(this Account account)
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);

View File

@@ -8,7 +8,18 @@ namespace AudibleUtilities
public interface IValidator
{
IEnumerable<Exception> Validate(IEnumerable<Item> items);
public static IValidator[] GetAllValidators()
=> new IValidator[]
{
new LibraryValidator(),
new BookValidator(),
new CategoryValidator(),
new ContributorValidator(),
new SeriesValidator(),
};
}
public class LibraryValidator : IValidator
{
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
@@ -17,10 +28,11 @@ namespace AudibleUtilities
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)));
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)));
//// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date'
//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
@@ -79,8 +91,10 @@ namespace AudibleUtilities
var distinct = items.GetSeriesDistinct();
if (distinct.Any(s => s.SeriesId is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
if (distinct.Any(s => s.SeriesName is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
//// unfortunately, an actual user has a series with no name
//if (distinct.Any(s => s.SeriesName is null))
// exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));
return exceptions;
}

View File

@@ -1,11 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net7.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="7.3.3.1" />
<PackageReference Include="AudibleApi" Version="10.1.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
</ItemGroup>
<ItemGroup>
@@ -20,4 +21,9 @@
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<Compile Update="Widevine\Cdm.*.cs">
<DependentUpon>Cdm.cs</DependentUpon>
</Compile>
</ItemGroup>
</Project>

View File

@@ -0,0 +1,15 @@
using AudibleApi.Common;
using System;
using System.Collections.Generic;
namespace AudibleUtilities
{
public class ImportValidationException : AggregateException
{
public List<Item> Items { get; }
public ImportValidationException(List<Item> items, IEnumerable<Exception> exceptions) : base(exceptions)
{
Items = items;
}
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi.Authorization;
using AudibleApi.Cryptography;
using Dinah.Core;
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
@@ -42,6 +43,9 @@ namespace AudibleUtilities
[JsonProperty("locale_code")]
public string LocaleCode { get; private set; }
[JsonProperty("with_username")]
public bool WithUsername { get; private set; }
[JsonProperty("activation_bytes")]
public string ActivationBytes { get; private set; }
@@ -67,7 +71,8 @@ namespace AudibleUtilities
}
[JsonIgnore] public ISystemDateTime SystemDateTime { get; } = new SystemDateTime();
[JsonIgnore] public Locale Locale => Localization.Get(LocaleCode);
[JsonIgnore]
public Locale Locale => Localization.Locales.Where(l => l.WithUsername == WithUsername).Single(l => l.CountryCode == LocaleCode);
[JsonIgnore] public string DeviceSerialNumber => DeviceInfo.DeviceSerialNumber;
[JsonIgnore] public string DeviceType => DeviceInfo.DeviceType;
[JsonIgnore] public string AmazonAccountId => CustomerInfo.UserId;
@@ -176,9 +181,10 @@ namespace AudibleUtilities
DevicePrivateKey = account.IdentityTokens.PrivateKey,
AccessTokenExpires = account.IdentityTokens.ExistingAccessToken.Expires,
LocaleCode = account.Locale.CountryCode,
WithUsername = account.Locale.WithUsername,
RefreshToken = account.IdentityTokens.RefreshToken.Value,
StoreAuthenticationCookie = account.IdentityTokens.StoreAuthenticationCookie,
WebsiteCookies = new(account.IdentityTokens.Cookies.ToKeyValuePair()),
WebsiteCookies = new(account.IdentityTokens.Cookies),
};
}

View File

@@ -0,0 +1,189 @@
using AudibleApi;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Net.Http;
using AudibleApi.Cryptography;
using Newtonsoft.Json.Linq;
using Dinah.Core.Net.Http;
using System.Text.Json.Nodes;
#nullable enable
namespace AudibleUtilities.Widevine;
public partial class Cdm
{
/// <summary>
/// Get a <see cref="Cdm"/> from <see cref="AccountsSettings"/> or from the API.
/// </summary>
/// <returns>A <see cref="Cdm"/> if successful, otherwise <see cref="null"/></returns>
public static async Task<Cdm?> GetCdmAsync()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
//Check if there are any Android accounts. If not, we can't use Widevine.
if (!persister.Target.Accounts.Any(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
return null;
if (!string.IsNullOrEmpty(persister.Target.Cdm))
{
try
{
var cdm = Convert.FromBase64String(persister.Target.Cdm);
return new Cdm(new Device(cdm));
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error loading CDM from account settings.");
persister.Target.Cdm = string.Empty;
//Clear the stored Cdm and try getting a fresh one from the server.
}
}
if (string.IsNullOrEmpty(persister.Target.Cdm))
{
using var client = new HttpClient();
if (await GetCdmUris(client) is not Uri[] uris)
return null;
//try to get a CDM file for any account that's registered as an android device.
//CDMs are not account-specific, so it doesn't matter which account we're successful with.
foreach (var account in persister.Target.Accounts.Where(a => a.IdentityTokens.DeviceType == Resources.DeviceType))
{
try
{
var requestMessage = CreateApiRequest(account);
await TestApiRequest(client, new JsonObject { { "body", requestMessage.ToString() } });
//Try all CDM URIs until a CDM has been retrieved successfully
foreach (var uri in uris)
{
try
{
var resp = await client.PostAsync(uri, ((HttpBody)requestMessage).Content);
if (!resp.IsSuccessStatusCode)
{
var message = await resp.Content.ReadAsStringAsync();
throw new ApiErrorException(uri, null, message);
}
var cdmBts = await resp.Content.ReadAsByteArrayAsync();
var device = new Device(cdmBts);
persister.Target.Cdm = Convert.ToBase64String(cdmBts);
return new Cdm(device);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error getting a CDM from URI: " + uri);
//try the next URI
}
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error getting a CDM for account: " + account.MaskedLogEntry);
//try the next Account
}
}
}
return null;
}
/// <summary>
/// Get a list of CDM API URIs from the main Gitgub repository's .cdmurls.json file.
/// </summary>
/// <returns>If successful, an array of URIs to try. Otherwise null</returns>
private static async Task<Uri[]?> GetCdmUris(HttpClient httpClient)
{
const string CdmUrlListFile = "https://raw.githubusercontent.com/rmcrackan/Libation/refs/heads/master/.cdmurls.json";
try
{
var fileContents = await httpClient.GetStringAsync(CdmUrlListFile);
var releaseIndex = JObject.Parse(fileContents);
var urlArray = releaseIndex["CdmUrls"] as JArray;
if (urlArray is null)
throw new System.IO.InvalidDataException("CDM url list not found in JSON: " + fileContents);
var uris = urlArray.Select(u => u.Value<string>()).OfType<string>().Select(u => new Uri(u)).ToArray();
if (uris.Length == 0)
throw new System.IO.InvalidDataException("No CDM url found in JSON: " + fileContents);
return uris;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error getting CDM URLs");
return null;
}
}
static readonly string[] TLDs = ["com", "co.uk", "com.au", "com.br", "ca", "fr", "de", "in", "it", "co.jp", "es"];
//Ensure that the request can be made successfully before sending it to the API
//The API uses System.Text.Json, so perform test with same.
private static async Task TestApiRequest(HttpClient client, JsonObject input)
{
if (input["body"]?.GetValue<string>() is not string body
|| JsonNode.Parse(body) is not JsonNode bodyJson)
throw new Exception("Api request doesn't contain a body");
if (bodyJson?["Url"]?.GetValue<string>() is not string url
|| !Uri.TryCreate(url, UriKind.Absolute, out var uri))
throw new Exception("Api request doesn't contain a url");
if (!TLDs.Select(tld => "api.audible." + tld).Contains(uri.Host.ToLower()))
throw new Exception($"Unknown Audible Api domain: {uri.Host}");
if (bodyJson?["Headers"] is not JsonObject headers)
throw new Exception($"Api request doesn't contain any headers");
using var request = new HttpRequestMessage(HttpMethod.Get, uri);
Dictionary<string, string>? headersDict = null;
try
{
headersDict = System.Text.Json.JsonSerializer.Deserialize<Dictionary<string, string>>(headers);
}
catch (Exception ex)
{
throw new Exception("Failed to read Audible Api headers.", ex);
}
if (headersDict is null)
throw new Exception("Failed to read Audible Api headers.");
foreach (var kvp in headersDict)
request.Headers.Add(kvp.Key, kvp.Value);
using var resp = await client.SendAsync(request);
resp.EnsureSuccessStatusCode();
}
/// <summary>
/// Create a request body to send to the API
/// </summary>
/// <param name="account">An authenticated account</param>
private static JObject CreateApiRequest(Account account)
{
const string ACCOUNT_INFO_PATH = "/1.0/account/information";
var message = new HttpRequestMessage(HttpMethod.Get, ACCOUNT_INFO_PATH);
message.SignRequest(
DateTime.UtcNow,
account.IdentityTokens.AdpToken,
account.IdentityTokens.PrivateKey);
return new JObject
{
{ "Url", new Uri(account.Locale.AudibleApiUri(), ACCOUNT_INFO_PATH) },
{ "Headers", JObject.FromObject(message.Headers.ToDictionary(kvp => kvp.Key, kvp => kvp.Value.Single())) }
};
}
}

View File

@@ -0,0 +1,300 @@
using Google.Protobuf;
using System;
using System.Collections.Concurrent;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Security.Cryptography;
using System.Text;
#nullable enable
namespace AudibleUtilities.Widevine;
public enum KeyType
{
/// <summary>
/// Exactly one key of this type must appear.
/// </summary>
Signing = 1,
/// <summary>
/// Content key.
/// </summary>
Content = 2,
/// <summary>
/// Key control block for license renewals. No key.
/// </summary>
KeyControl = 3,
/// <summary>
/// wrapped keys for auxiliary crypto operations.
/// </summary>
OperatorSession = 4,
/// <summary>
/// Entitlement keys.
/// </summary>
Entitlement = 5,
/// <summary>
/// Partner-specific content key.
/// </summary>
OemContent = 6,
}
public interface ISession : IDisposable
{
string? GetLicenseChallenge(MpegDash dash);
WidevineKey[] ParseLicense(string licenseMessage);
}
public class WidevineKey
{
public Guid Kid { get; }
public KeyType Type { get; }
public byte[] Key { get; }
internal WidevineKey(Guid kid, License.Types.KeyContainer.Types.KeyType type, byte[] key)
{
Kid = kid;
Type = (KeyType)type;
Key = key;
}
public override string ToString() => $"{Convert.ToHexString(Kid.ToByteArray(bigEndian: true)).ToLower()}:{Convert.ToHexString(Key).ToLower()}";
}
public partial class Cdm
{
public static Guid WidevineContentProtection { get; } = new("edef8ba9-79d6-4ace-a3c8-27dcd51d21ed");
private const int MAX_NUM_OF_SESSIONS = 16;
internal Device Device { get; }
private ConcurrentDictionary<Guid, Session> Sessions { get; } = new(-1, MAX_NUM_OF_SESSIONS);
internal Cdm(Device device)
{
Device = device;
}
public ISession OpenSession()
{
if (Sessions.Count == MAX_NUM_OF_SESSIONS)
throw new Exception("Too Many Sessions");
var session = new Session(Sessions.Count + 1, this);
var ddd = Sessions.TryAdd(session.Id, session);
return session;
}
#region Session
internal class Session : ISession
{
public Guid Id { get; } = Guid.NewGuid();
private int SessionNumber { get; }
private Cdm Cdm { get; }
private byte[]? EncryptionContext { get; set; }
private byte[]? AuthenticationContext { get; set; }
public Session(int number, Cdm cdm)
{
SessionNumber = number;
Cdm = cdm;
}
private string GetRequestId()
=> $"{RandomUint():x8}00000000{Convert.ToHexString(BitConverter.GetBytes((long)SessionNumber)).ToLowerInvariant()}";
public void Dispose()
{
if (Cdm.Sessions.ContainsKey(Id))
Cdm.Sessions.TryRemove(Id, out var session);
}
public string? GetLicenseChallenge(MpegDash dash)
{
if (!dash.TryGetPssh(Cdm.WidevineContentProtection, out var pssh))
return null;
var licRequest = new LicenseRequest
{
ClientId = Cdm.Device.ClientId,
ContentId = new()
{
WidevinePsshData = new()
{
LicenseType = LicenseType.Offline,
RequestId = ByteString.CopyFrom(GetRequestId(), Encoding.ASCII)
}
},
Type = LicenseRequest.Types.RequestType.New,
RequestTime = DateTimeOffset.UtcNow.ToUnixTimeSeconds(),
ProtocolVersion = ProtocolVersion.Version21,
KeyControlNonce = RandomUint()
};
licRequest.ContentId.WidevinePsshData.PsshData.Add(ByteString.CopyFrom(pssh.InitData));
var licRequestBts = licRequest.ToByteArray();
EncryptionContext = CreateContext("ENCRYPTION", 128, licRequestBts);
AuthenticationContext = CreateContext("AUTHENTICATION", 512, licRequestBts);
var signedMessage = new SignedMessage
{
Type = SignedMessage.Types.MessageType.LicenseRequest,
Msg = ByteString.CopyFrom(licRequestBts),
Signature = ByteString.CopyFrom(Cdm.Device.SignMessage(licRequestBts))
};
return Convert.ToBase64String(signedMessage.ToByteArray());
}
public WidevineKey[] ParseLicense(string licenseMessage)
{
if (EncryptionContext is null || AuthenticationContext is null)
throw new InvalidOperationException($"{nameof(GetLicenseChallenge)}() must be called before calling {nameof(ParseLicense)}()");
var signedMessage = SignedMessage.Parser.ParseFrom(Convert.FromBase64String(licenseMessage));
if (signedMessage.Type != SignedMessage.Types.MessageType.License)
throw new InvalidDataException("Invalid license");
var sessionKey = Cdm.Device.DecryptSessionKey(signedMessage.SessionKey.ToByteArray());
if (!VerifySignature(signedMessage, AuthenticationContext, sessionKey))
throw new InvalidDataException("Message signature is invalid");
var license = License.Parser.ParseFrom(signedMessage.Msg);
var keyToTheKeys = DeriveKey(sessionKey, EncryptionContext, 1);
return DecryptKeys(keyToTheKeys, license.Key);
}
private static WidevineKey[] DecryptKeys(byte[] keyToTheKeys, IList<License.Types.KeyContainer> licenseKeys)
{
using var aes = Aes.Create();
aes.Key = keyToTheKeys;
var keys = new WidevineKey[licenseKeys.Count];
for (int i = 0; i < licenseKeys.Count; i++)
{
var keyContainer = licenseKeys[i];
var keyBytes = aes.DecryptCbc(keyContainer.Key.ToByteArray(), keyContainer.Iv.ToByteArray(), PaddingMode.PKCS7);
var id = keyContainer.Id.ToByteArray();
if (id.Length > 16)
{
var tryB64 = new byte[id.Length * 3 / 4];
if (Convert.TryFromBase64String(Encoding.ASCII.GetString(id), tryB64, out int bytesWritten))
{
id = tryB64;
}
Array.Resize(ref id, 16);
}
else if (id.Length < 16)
{
id = id.Append(new byte[16 - id.Length]);
}
keys[i] = new WidevineKey(new Guid(id,bigEndian: true), keyContainer.Type, keyBytes);
}
return keys;
}
private static bool VerifySignature(SignedMessage signedMessage, byte[] authContext, byte[] sessionKey)
{
var mac_key_server = DeriveKey(sessionKey, authContext, 1).Append(DeriveKey(sessionKey, authContext, 2));
var hmacData = (signedMessage.OemcryptoCoreMessage?.ToByteArray() ?? []).Append(signedMessage.Msg?.ToByteArray() ?? []);
var computed_signature = HMACSHA256.HashData(mac_key_server, hmacData);
return computed_signature.SequenceEqual(signedMessage.Signature);
}
private static byte[] DeriveKey(byte[] session_key, byte[] context, int counter)
{
var data = new byte[context.Length + 1];
Array.Copy(context, 0, data, 1, context.Length);
data[0] = (byte)counter;
return AESCMAC(session_key, data);
}
private static byte[] AESCMAC(byte[] key, byte[] data)
{
using var aes = Aes.Create();
aes.Key = key;
// SubKey generation
// step 1, AES-128 with key K is applied to an all-zero input block.
byte[] subKey = aes.EncryptCbc(new byte[16], new byte[16], PaddingMode.None);
nextSubKey();
// MAC computing
if ((data.Length == 0) || (data.Length % 16 != 0))
{
// If the size of the input message block is not equal to a positive
// multiple of the block size (namely, 128 bits), the last block shall
// be padded with 10^i
nextSubKey();
var padLen = 16 - data.Length % 16;
Array.Resize(ref data, data.Length + padLen);
data[^padLen] = 0x80;
}
// the last block shall be exclusive-OR'ed with K1 before processing
for (int j = 0; j < subKey.Length; j++)
data[data.Length - 16 + j] ^= subKey[j];
// The result of the previous process will be the input of the last encryption.
byte[] encResult = aes.EncryptCbc(data, new byte[16], PaddingMode.None);
byte[] HashValue = new byte[16];
Array.Copy(encResult, encResult.Length - HashValue.Length, HashValue, 0, HashValue.Length);
return HashValue;
void nextSubKey()
{
const byte const_Rb = 0x87;
if (Rol(subKey) != 0)
subKey[15] ^= const_Rb;
static int Rol(byte[] b)
{
int carry = 0;
for (int i = b.Length - 1; i >= 0; i--)
{
ushort u = (ushort)(b[i] << 1);
b[i] = (byte)((u & 0xff) + carry);
carry = (u & 0xff00) >> 8;
}
return carry;
}
}
}
private static byte[] CreateContext(string label, int keySize, byte[] licRequestBts)
{
var contextSize = label.Length + 1 + licRequestBts.Length + sizeof(int);
var context = new byte[contextSize];
var numChars = Encoding.ASCII.GetBytes(label.AsSpan(), context);
Array.Copy(licRequestBts, 0, context, numChars + 1, licRequestBts.Length);
var numBts = BitConverter.GetBytes(keySize);
if (BitConverter.IsLittleEndian)
Array.Reverse(numBts);
Array.Copy(numBts, 0, context, context.Length - sizeof(int), sizeof(int));
return context;
}
private static uint RandomUint()
{
var bts = new byte[4];
new Random().NextBytes(bts);
return BitConverter.ToUInt32(bts, 0);
}
}
#endregion
}

View File

@@ -0,0 +1,155 @@
using System;
using System.IO;
using System.Numerics;
using System.Security.Cryptography;
#nullable enable
namespace AudibleUtilities.Widevine;
internal enum DeviceTypes : byte
{
Unknown = 0,
Chrome = 1,
Android = 2
}
internal class Device
{
public DeviceTypes Type { get; }
public int FileVersion { get; }
public int SecurityLevel { get; }
public int Flags { get; }
public RSA CdmKey { get; }
internal ClientIdentification ClientId { get; }
public Device(Span<byte> fileData)
{
if (fileData.Length < 7 || fileData[0] != 'W' || fileData[1] != 'V' || fileData[2] != 'D')
throw new InvalidDataException();
FileVersion = fileData[3];
Type = (DeviceTypes)fileData[4];
SecurityLevel = fileData[5];
Flags = fileData[6];
if (FileVersion != 2)
throw new InvalidDataException($"Unknown CDM File Version: '{FileVersion}'");
if (Type != DeviceTypes.Android)
throw new InvalidDataException($"Unknown CDM Type: '{Type}'");
if (SecurityLevel != 3)
throw new InvalidDataException($"Unknown CDM Security Level: '{SecurityLevel}'");
var privateKeyLength = (fileData[7] << 8) | fileData[8];
if (privateKeyLength <= 0 || fileData.Length < 9 + privateKeyLength + 2)
throw new InvalidDataException($"Invalid private key length: '{privateKeyLength}'");
var clientIdLength = (fileData[9 + privateKeyLength] << 8) | fileData[10 + privateKeyLength];
if (clientIdLength <= 0 || fileData.Length < 11 + privateKeyLength + clientIdLength)
throw new InvalidDataException($"Invalid client id length: '{clientIdLength}'");
ClientId = ClientIdentification.Parser.ParseFrom(fileData.Slice(11 + privateKeyLength));
CdmKey = RSA.Create();
CdmKey.ImportRSAPrivateKey(fileData.Slice(9, privateKeyLength), out _);
}
public byte[] SignMessage(byte[] message)
{
var digestion = SHA1.HashData(message);
return PssSha1Signer.SignHash(CdmKey, digestion);
}
public bool VerifyMessage(byte[] message, byte[] signature)
{
var digestion = SHA1.HashData(message);
return CdmKey.VerifyHash(digestion, signature, HashAlgorithmName.SHA1, RSASignaturePadding.Pss);
}
public byte[] DecryptSessionKey(byte[] sessionKey)
=> 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);
}
}
}

View File

@@ -0,0 +1,15 @@
using System;
#nullable enable
namespace AudibleUtilities.Widevine;
internal static class Extensions
{
public static T[] Append<T>(this T[] message, T[] appendData)
{
var origLength = message.Length;
Array.Resize(ref message, origLength + appendData.Length);
Array.Copy(appendData, 0, message, origLength, appendData.Length);
return message;
}
}

View File

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,70 @@
using Mpeg4Lib.Boxes;
using System;
using System.Diagnostics.CodeAnalysis;
using System.IO;
using System.Xml;
using System.Xml.Linq;
using System.Xml.XPath;
#nullable enable
namespace AudibleUtilities.Widevine;
public class MpegDash
{
private const string MpegDashNamespace = "urn:mpeg:dash:schema:mpd:2011";
private const string CencNamespace = "urn:mpeg:cenc:2013";
private const string UuidPreamble = "urn:uuid:";
private XElement DashMpd { get; }
private static XmlNamespaceManager NamespaceManager { get; } = new(new NameTable());
static MpegDash()
{
NamespaceManager.AddNamespace("dash", MpegDashNamespace);
NamespaceManager.AddNamespace("cenc", CencNamespace);
}
public MpegDash(Stream contents)
{
DashMpd = XElement.Load(contents);
}
public bool TryGetUri(Uri baseUri, [NotNullWhen(true)] out Uri? fileUri)
{
foreach (var baseUrl in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:Representation/dash:BaseURL", NamespaceManager))
{
try
{
fileUri = new Uri(baseUri, baseUrl.Value);
return true;
}
catch
{
fileUri = null;
return false;
}
}
fileUri = null;
return false;
}
public bool TryGetPssh(Guid protectionSystemId, [NotNullWhen(true)] out PsshBox? pssh)
{
foreach (var psshEle in DashMpd.XPathSelectElements("/dash:Period/dash:AdaptationSet/dash:ContentProtection/cenc:pssh", NamespaceManager))
{
if (psshEle?.Value?.Trim() is string psshStr
&& psshEle.Parent?.Attribute(XName.Get("schemeIdUri")) is XAttribute scheme
&& scheme.Value is string uuid
&& uuid.Equals(UuidPreamble + protectionSystemId.ToString(), StringComparison.OrdinalIgnoreCase))
{
Span<byte> buffer = new byte[psshStr.Length * 3 / 4];
if (Convert.TryFromBase64String(psshStr, buffer, out var written))
{
using var ms = new MemoryStream(buffer.Slice(0, written).ToArray());
pssh = BoxFactory.CreateBox(ms, null) as PsshBox;
return pssh is not null;
}
}
}
pssh = null;
return false;
}
}

View File

@@ -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>

View File

@@ -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
}
}
}

View File

@@ -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");
}
}
}

View File

@@ -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
}
}
}

View File

@@ -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);
}
}
}

View File

@@ -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>

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