Compare commits

...
183 Commits
Author SHA1 Message Date
rmcrackan 791425670b Merge pull request #1924 from rmcrackan/cursor/release-13.7.7-f0df
release: bump version to 13.7.7
2026-08-09 14:53:23 -04:00
Cursor Agentandrmcrackan a40e9e7cf7 release: bump version to 13.7.7
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 18:48:30 +00:00
rmcrackan e36db71534 Merge pull request #1922 from rmcrackan/cursor/1919-unix-disk-space-mount-f0df
bugfix #1919: Unix mount-aware disk-space preflight
2026-08-09 14:06:15 -04:00
Cursor Agentandrmcrackan da23a7ddb0 fix: use '/' in Unix mount prefix matching (Windows CI)
FindLongestMountPointPrefix is exercised on Windows CI with Unix-style
paths. Path.DirectorySeparatorChar is '\\' there, so '/var/home' never
matched '/var/home/...'. Hardcode '/' for Unix mount identity.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 17:55:38 +00:00
Cursor Agentandrmcrackan 16e6a6f659 bugfix #1919: resolve Unix mount points for disk-space preflight
Path.GetPathRoot always returns "/" for absolute Unix paths, so bulk
backup preflight queried composefs root free space (often 0 on Bazzite)
instead of the filesystem containing Books/In progress.

On Unix, symlink-canonicalize paths (so /home -> /var/home) and pick the
longest DriveInfo.GetDrives() mount prefix. Route GetBackupDriveSpaces
through the shared helper so grouping and free-space queries agree.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 17:51:36 +00:00
rmcrackan 742d2b140a Merge pull request #1921 from rmcrackan/cursor/setup-dev-environment-f0df
Add AGENTS.md with Cursor Cloud dev environment instructions
2026-08-09 13:41:50 -04:00
Cursor Agentandrmcrackan befd1a4c3c docs: correct AGENTS.md — AudibleUtilities hang was GNOME Keyring, not network
The first slow AudibleUtilities.Tests run was blocked on a desktop keyring
password prompt for Libation's OS secret-store master key. Document that
gotcha and remove the incorrect network-timeout theory.

Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 17:39:45 +00:00
Cursor Agentandrmcrackan e859af0296 docs: add AGENTS.md with Cursor Cloud dev environment instructions
Co-authored-by: rmcrackan <rmcrackan@gmail.com>
2026-08-09 14:36:11 +00:00
rmcrackan eebc96f439 Merge pull request #1916 from rmcrackan/rmcrackan/fail-fast-invalid-settings
Fail fast on invalid Settings.json enums and broken Serilog structure
2026-08-07 11:15:57 -04:00
Robert McRackan 065118cf6c Fail fast on invalid Settings.json enums and broken Serilog structure: reject unknown enum values at startup with clear errors, accept case-insensitive names, migrate ZipFile sinks, and validate Serilog shape without blocking hand-edited custom sinks 2026-08-07 11:10:43 -04:00
rmcrackan 8ea95ee812 Merge pull request #1915 from rmcrackan/rmcrackan/1914-series-entry-sort-crash
bugfix #1914 : fix race when linking podcast episodes under series parents
2026-08-06 16:09:18 -04:00
Robert McRackan c144c20c53 bugfix #1914 : fix race when linking podcast episodes under series parents 2026-08-06 16:08:57 -04:00
rmcrackan 7b09a3bfef Merge pull request #1913 from rmcrackan/rmcrackan/docs-encrypt-fail-plaintext-fallback
Encrypt-fail plaintext fallback
2026-08-05 10:08:35 -04:00
Robert McRackan e0d49b57e1 Encrypt-fail plaintext fallback 2026-08-05 10:02:53 -04:00
rmcrackan 6ba0741393 Merge pull request #1912 from rmcrackan/rmcrackan/last-resort-portable-master-key
Last-resort portable master key when OS secret store is unavailable
2026-08-03 10:32:16 -04:00
rmcrackan ea3fc17cfa Merge pull request #1911 from rmcrackan/dependabot/github_actions/docker/login-action-4.6.0
Bump docker/login-action from 4.5.2 to 4.6.0
2026-08-03 10:25:09 -04:00
Robert McRackan f64824e283 Document last-resort portable master key as a Docker fallback 2026-08-03 10:22:31 -04:00
dependabot[bot] 5334827c09 Bump docker/login-action from 4.5.2 to 4.6.0
Bumps [docker/login-action](https://github.com/docker/login-action) from 4.5.2 to 4.6.0.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4.5.2...v4.6.0)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-08-03 14:12:42 +00:00
Robert McRackan fcefc27a29 Warn loudly when auto-creating a last-resort portable master key 2026-08-03 10:10:06 -04:00
Robert McRackan 6bfcfb239c Merge remote-tracking branch 'origin/master' into rmcrackan/last-resort-portable-master-key 2026-08-03 10:01:28 -04:00
rmcrackan 917f44fe57 Merge pull request #1910 from rmcrackan/rmcrackan/docker-cli-noise-and-docs
Quiet Docker ConfigApp.dll noise and document login-external via /config
2026-08-03 09:59:43 -04:00
Robert McRackan 134f895c82 Add last-resort portable master key when OS secret store is unavailable 2026-08-03 09:59:02 -04:00
Robert McRackan 4513a8007b Quiet Docker ConfigApp.dll noise and document login-external via /config 2026-08-03 09:09:59 -04:00
rmcrackan 8581c0e1af Merge pull request #1909 from rmcrackan/rmcrackan/cli-scan-auth-and-locale
Surface real CLI/Docker auth failures and fix login-external locales
2026-08-02 12:01:01 -04:00
Robert McRackan 3872c10c81 incr ver 2026-08-02 11:56:17 -04:00
Robert McRackan 712568b188 Surface real CLI/Docker auth failures and fix login-external locales 2026-08-02 11:55:48 -04:00
rmcrackan 5d1a31f876 Merge pull request #1908 from rmcrackan/rmcrackan/add-oxivanisher-contributor
Add @oxivanisher to contributors
2026-08-02 11:14:08 -04:00
Robert McRackan 00fdd903aa Add @oxivanisher to contributors 2026-08-02 11:14:06 -04:00
Robert McRackan 8a61e4d7f4 incr ver 2026-08-02 11:08:19 -04:00
rmcrackan c02563204d Merge pull request #1907 from oxivanisher/feature/improve_environment_in_docker
Feature/improve environment in docker and documentation
2026-08-02 11:03:14 -04:00
Marc Urben 2e4c0a10b0 Setup libation-master.key when not set via variable 2026-08-02 10:04:26 +02:00
Marc Urben ccc814ce1c Ensure .sh files are unix style 2026-08-02 03:22:16 +02:00
Marc Urben 98f657a40b Correct log file name and remove superfluous spaces. 2026-08-02 03:03:02 +02:00
Marc Urben 28ce71c1b7 Document on how to access or redirect log files from within docker. 2026-08-02 03:00:17 +02:00
Marc Urben a438dfd82f Offer occational support for docker problems ;:) 2026-08-02 02:51:43 +02:00
Marc Urben 12875993fa Add missing LOG_LEVEL variable to the env variables table. 2026-08-02 02:35:56 +02:00
Marc Urben 40b75185c2 Support user specified cmd by not entering the loop and running the cmd while still setting up the environment. Also expand documentation and example for the interactive shell. 2026-08-02 02:27:21 +02:00
Marc Urben c5a88de8a6 Use entrypoint instead of cmd to allow the user to supply their own cmd 2026-08-02 02:23:21 +02:00
Marc Urben cb1074e7fd Create a symlink to LibationCli to not require the full path for commands 2026-08-02 02:21:04 +02:00
rmcrackan f5ed61fa65 Merge pull request #1906 from rmcrackan/rmcrackan/incr-ver
incr ver
2026-08-01 12:22:03 -04:00
Robert McRackan 680e48638a incr ver 2026-08-01 12:21:51 -04:00
rmcrackan b49384cfcf Merge pull request #1905 from rmcrackan/rmcrackan/portable-master-key
Make encrypted account tokens portable for Docker
2026-08-01 12:20:08 -04:00
Robert McRackan 2b4cde62e2 Document portable master-key export for Docker and CLI 2026-08-01 12:14:22 -04:00
Robert McRackan 636b85f8e4 UI: add Settings export for the portable encryption master key 2026-08-01 12:09:13 -04:00
Robert McRackan 961ee3372e Harden docker CI against GHA cache write failures 2026-08-01 11:55:38 -04:00
Robert McRackan 7d9882c500 Add LibationCli export-master-key for portable encryption keys 2026-08-01 11:43:29 -04:00
Robert McRackan 785ec7bf96 Resolve portable master key from file or env before OS store 2026-08-01 11:39:51 -04:00
rmcrackan 6c917ad751 Merge pull request #1904 from rmcrackan/rmcrackan/safer-identity-token-handling
Detect identity decrypt failures by exception type
2026-08-01 11:29:30 -04:00
Robert McRackan 5f4a75aade Detect identity decrypt failures by exception type 2026-08-01 11:25:56 -04:00
rmcrackan e78a4392f6 Merge pull request #1903 from rmcrackan/rmcrackan/docker-decrypt-feedback
surfaced encrypted-token decrypt failure for Docker and CLI
2026-08-01 09:47:38 -04:00
Robert McRackan f12f5d42a1 incr ver 2026-08-01 09:40:20 -04:00
Robert McRackan 79b6521a4e surfaced encrypted-token decrypt failure for Docker and CLI 2026-08-01 09:38:50 -04:00
rmcrackan cdcbd28cca Merge pull request #1902 from rmcrackan/rmcrackan/docker-tokens
warn that Windows-encrypted tokens fail in Docker
2026-08-01 09:19:11 -04:00
Robert McRackan f35530d18b warn that Windows-encrypted tokens fail in Docker 2026-08-01 09:19:00 -04:00
rmcrackan d8dd86123d Merge pull request #1901 from cclements/update-aaxclean-codecs-3.1.0
Update AAXClean.Codecs to 3.1.0 — fixes xHE-AAC seeking in Apple players
2026-08-01 08:58:22 -04:00
Chris ClementsandClaude Fable 5 0dfee1ff5c Update AAXClean.Codecs to 3.1.0
Fixes xHE-AAC (USAC) seeking in Apple players: AAXClean now writes the
sync sample table (stss) required by ISO/IEC 23003-3 for USAC output,
and chapter-split files are sample-accurate via edit lists
(Mbucari/AAXClean#18, Mbucari/AAXClean#19).

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-31 17:24:53 -05:00
rmcrackan 842b0c0933 Merge pull request #1900 from rmcrackan/rmcrackan/audiobookshelf-connect-hardening
Harden and refactor ABS code
2026-07-31 14:21:33 -04:00
Robert McRackan 1c5119e24d Share Audiobookshelf connect orchestration across Classic and Chardonnay 2026-07-31 14:08:57 -04:00
Robert McRackan a6d637c718 Harden Audiobookshelf Server URL handling and connection errors 2026-07-31 14:05:36 -04:00
rmcrackan 9579c3157b Merge pull request #1899 from rmcrackan/dependabot/github_actions/docker/login-action-4.5.2
Bump docker/login-action from 4 to 4.5.2
2026-07-31 10:34:54 -04:00
dependabot[bot] 31258c7951 Bump docker/login-action from 4 to 4.5.2
Bumps [docker/login-action](https://github.com/docker/login-action) from 4 to 4.5.2.
- [Release notes](https://github.com/docker/login-action/releases)
- [Commits](https://github.com/docker/login-action/compare/v4...v4.5.2)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-31 14:13:32 +00:00
rmcrackan 16379168d4 Merge pull request #1898 from rmcrackan/rmcrackan/abs-docs
Update docs for new Audiobookshelf feature
2026-07-28 14:43:26 -04:00
Robert McRackan 6bc82f7c6e missing entries in index.md 2026-07-28 14:41:25 -04:00
Robert McRackan 43216b2947 Update docs for new Audiobookshelf feature 2026-07-28 14:39:08 -04:00
Robert McRackan a079752c71 incr ver. add contributor @Demoniskk 2026-07-28 14:22:35 -04:00
rmcrackan a2b4e432b5 Merge pull request #1896 from Demoniskk/feature/audiobookshelf-uploader
Add Audiobookshelf auto-upload integration
2026-07-28 14:18:34 -04:00
rmcrackan 272dcd87be Merge pull request #1897 from rmcrackan/dependabot/github_actions/actions/stale-11
Bump actions/stale from 10 to 11
2026-07-28 10:36:06 -04:00
dependabot[bot] 6268fb0332 Bump actions/stale from 10 to 11
Bumps [actions/stale](https://github.com/actions/stale) from 10 to 11.
- [Release notes](https://github.com/actions/stale/releases)
- [Changelog](https://github.com/actions/stale/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/stale/compare/v10...v11)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-28 14:12:41 +00:00
John Doe a7f6075a12 fix: ABS duplicate detection, Connect button toggle, token encryption edge cases 2026-07-28 04:44:13 +02:00
John Doe b5e87847ff fix: Address review feedback - UI parity, soft-fail, token encryption, pagination, CLI wiring 2026-07-26 19:12:31 +02:00
John Doe ad61618cc9 Add Audiobookshelf auto-upload integration
- Persist settings: enabled, server URL, API token, library/folder IDs

- Add AudiobookshelfApiService for login, library listing, and multipart upload

- Add UploadToAudiobookshelf post-download processable

- Add settings tab to WinForms and Avalonia with library/folder dropdowns

- Match Avalonia layout to WinForms with aligned columns
2026-07-26 09:58:12 +02:00
rmcrackan 3580f6d53a Merge pull request #1895 from rmcrackan/dependabot/npm_and_yarn/postcss-8.5.23
Bump postcss from 8.5.12 to 8.5.23
2026-07-25 22:45:58 -04:00
dependabot[bot] 1493777396 Bump postcss from 8.5.12 to 8.5.23
Bumps [postcss](https://github.com/postcss/postcss) from 8.5.12 to 8.5.23.
- [Release notes](https://github.com/postcss/postcss/releases)
- [Changelog](https://github.com/postcss/postcss/blob/main/CHANGELOG.md)
- [Commits](https://github.com/postcss/postcss/compare/8.5.12...8.5.23)

---
updated-dependencies:
- dependency-name: postcss
  dependency-version: 8.5.23
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-26 02:17:31 +00:00
rmcrackan 0836a79990 Merge pull request #1894 from rmcrackan/rmcrackan/token-encryption
Add encrypted AccountsSettings token storage with Important settings UI
2026-07-25 22:16:32 -04:00
Robert McRackan 2c52513ea9 incr ver 2026-07-25 22:08:49 -04:00
Robert McRackan 20150cc4ed update dependencies 2026-07-25 22:03:23 -04:00
Robert McRackan 3e03fad490 'welcome to libation' box should appear in windows taskbar 2026-07-25 21:33:31 -04:00
Robert McRackan abea874db8 Add Important settings UI for encrypted token storage 2026-07-25 21:32:42 -04:00
Robert McRackan bb18e9adc4 Add TokenStorageMethod preference and AudibleApi token-storage wiring 2026-07-25 21:17:11 -04:00
Robert McRackan a9e6f75443 update dependencies 2026-07-25 20:57:52 -04:00
Robert McRackan b5485f77bf update dependencies 2026-07-25 20:08:11 -04:00
Robert McRackan 936b307d3e Add characterization tests for legacy plaintext AccountsSettings tokens 2026-07-25 18:14:19 -04:00
rmcrackan 1c33a253a2 Merge pull request #1893 from rmcrackan/rmcrackan/pre-amazon-account-docs
Pre-Amazon Audible login. Walkthrough refactor
2026-07-25 12:42:04 -04:00
Robert McRackan b4f7cf4288 Shared walkthrough helpers and constants 2026-07-25 12:35:22 -04:00
Robert McRackan e1bede5a6a Centralize walkthrough copy/text 2026-07-25 12:28:43 -04:00
Robert McRackan 5c67351aba Document pre-Amazon Audible username login in docs and guided walk-through 2026-07-25 12:18:31 -04:00
rmcrackan df6db12f77 Merge pull request #1892 from rmcrackan/rmcrackan/localhost-link
don't live-link localhost
2026-07-25 11:39:00 -04:00
Robert McRackan 89155e0b0b don't live-link localhost 2026-07-25 11:38:41 -04:00
rmcrackan 934e7c6618 Merge pull request #1891 from rmcrackan/rmcrackan/tips
Add donate page and wire it through site, docs, and About dialogs
2026-07-25 11:33:16 -04:00
Robert McRackan e372bae144 Add donate link to 'About' form 2026-07-25 11:22:45 -04:00
Robert McRackan 270985341a add icon to external links on main /docs/ 2026-07-25 11:18:46 -04:00
Robert McRackan fd03c1bd9c point to new donate page 2026-07-25 11:15:24 -04:00
Robert McRackan 123da3c653 new donate page 2026-07-25 11:11:09 -04:00
rmcrackan 15838fb2cf Create CNAME 2026-07-25 10:24:45 -04:00
rmcrackan 746f620f95 Merge pull request #1890 from rmcrackan/rmcrackan/homepage-edit
edit homepage
2026-07-25 10:16:04 -04:00
Robert McRackan 1117eb378a edit homepage 2026-07-25 10:15:47 -04:00
Robert McRackan 07c2f2b2a1 incr ver 2026-07-17 14:53:27 -04:00
rmcrackan a0c59f15e1 Merge pull request #1889 from rmcrackan/rmcrackan/homebrew-fix
Mark macOS app bundle as APPL
2026-07-17 14:52:14 -04:00
Robert McRackan 353a5b8800 Mark macOS app bundle as APPL so Gatekeeper and Homebrew cask audits accept it. Thanks, @m-j-r ! 2026-07-17 14:46:38 -04:00
rmcrackan 190818323d Merge pull request #1888 from rmcrackan/dependabot/github_actions/actions/setup-dotnet-6
Bump actions/setup-dotnet from 5 to 6
2026-07-16 10:31:43 -04:00
dependabot[bot] f180430a3f Bump actions/setup-dotnet from 5 to 6
Bumps [actions/setup-dotnet](https://github.com/actions/setup-dotnet) from 5 to 6.
- [Release notes](https://github.com/actions/setup-dotnet/releases)
- [Commits](https://github.com/actions/setup-dotnet/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-16 14:12:50 +00:00
rmcrackan dec86bdee1 Merge pull request #1887 from rmcrackan/dependabot/github_actions/actions/setup-node-7
Bump actions/setup-node from 6 to 7
2026-07-14 10:25:15 -04:00
dependabot[bot] 6e4acd8046 Bump actions/setup-node from 6 to 7
Bumps [actions/setup-node](https://github.com/actions/setup-node) from 6 to 7.
- [Release notes](https://github.com/actions/setup-node/releases)
- [Commits](https://github.com/actions/setup-node/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-07-14 14:12:49 +00:00
rmcrackan 80c6c6b9d4 Merge pull request #1886 from rmcrackan/rmcrackan/pause-autoscan
Pause auto-scan on auth failure instead of spamming login dialogs
2026-07-07 09:45:57 -04:00
Robert McRackan 3cdaa09aff Pause auto-scan on auth failure instead of spamming login dialogs 2026-07-07 09:40:14 -04:00
rmcrackan ecc7e49a21 Merge pull request #1883 from rmcrackan/dependabot/github_actions/actions/checkout-7
Bump actions/checkout from 6 to 7
2026-06-21 08:55:22 -04:00
dependabot[bot] fb41955015 Bump actions/checkout from 6 to 7
Bumps [actions/checkout](https://github.com/actions/checkout) from 6 to 7.
- [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/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2026-06-19 14:12:34 +00:00
rmcrackan a8e3404500 Merge pull request #1882 from rmcrackan/rmcrackan/1881-linux-upgrade
#1881 - Fix Linux in-app upgrades
2026-06-17 15:51:26 -04:00
rmcrackan 9ff15a07aa #1881 - Fix Linux in-app upgrades on RHEL/Fedora by selecting .rpm packages instead of .deb when dnf is the native package manager. 2026-06-17 15:36:28 -04:00
rmcrackan 674abd1707 Merge pull request #1880 from rmcrackan/rmcrackan/1878-upgrader
#1878 : Detect incomplete in-app upgrades, roll back install files, and show clear recovery messages
2026-06-17 10:45:12 -04:00
rmcrackan d30086e53a bugfix 2026-06-17 10:38:24 -04:00
rmcrackan 82d4001f0a #1878 : Detect incomplete in-app upgrades, roll back install files, and show clear recovery messages 2026-06-17 10:18:52 -04:00
rmcrackan 8ed4874c76 Merge pull request #1879 from rmcrackan/rmcrackan/1876-troubleshooting
#1876 - update troubleshooting doc
2026-06-17 09:07:12 -04:00
rmcrackan 8898174507 updtae troubleshooting doc 2026-06-17 09:06:21 -04:00
rmcrackan 2b4923112e Merge pull request #1877 from rmcrackan/rmcrackan/win-app-ctrl
#1873 , #1876 - Handle Windows Application Control blocking after in-app upgrades
2026-06-16 17:30:01 -04:00
rmcrackan d955cb7605 #1873 , #1876 - Handle Windows Application Control blocking after in-app upgrades 2026-06-16 17:25:18 -04:00
rmcrackan 23f36e2ef1 Merge pull request #1875 from rmcrackan/rmcrackan/vite-security
fix vite vulnerability
2026-06-16 11:28:57 -04:00
rmcrackan 46dc400b14 fix vite vulnerability 2026-06-16 11:28:39 -04:00
rmcrackan 4f2e832327 Merge pull request #1874 from rmcrackan/rmcrackan/1872-widevine
#1872 : Suggest Widevine when ADRM licenserequest fails with Sable acr:null
2026-06-16 11:20:12 -04:00
rmcrackan 4bd7796f70 #1872 : Suggest Widevine when ADRM licenserequest fails with Sable acr:null 2026-06-16 11:12:58 -04:00
rmcrackan b6d8378728 Merge pull request #1871 from rmcrackan/rmcrackan/security
Fix esbuild GHSA-gv7w-rqvm-qjhr
2026-06-14 15:25:54 -04:00
rmcrackan 348056691f Fix esbuild GHSA-gv7w-rqvm-qjhr by pinning 0.28.1 : Override transitive esbuild to the patched release and add a VitePress esbuild compatibility setting so docs builds keep working with 0.28.x. 2026-06-14 15:24:21 -04:00
rmcrackan 7bd14bd5af Merge pull request #1870 from rmcrackan/rmcrackan/incr-ver
incr ver
2026-06-12 11:54:35 -04:00
rmcrackan a85179f50a incr ver 2026-06-12 11:54:14 -04:00
rmcrackan 158ee3d5ba Merge pull request #1869 from rmcrackan/rmcrackan/1867-duplicate-asin
#1867 - Fix duplicate-ASIN crashes. New Hangover recovery
2026-06-12 11:52:42 -04:00
rmcrackan b654a8b551 #1867 - Fix duplicate-ASIN crashes. New Hangover recovery 2026-06-12 11:48:32 -04:00
rmcrackan bc28398e1c Merge pull request #1868 from rmcrackan/rmcrackan/1865-bad-book-setting
#1865 - Add in-dialog "Apply to all" and "Remember in Settings"
2026-06-12 10:10:29 -04:00
rmcrackan 4d1b78f7e4 #1865 - Add in-dialog "Apply to all" and "Remember in Settings" for bad-book errors. Also a testing section when run in debug (ie: from visual studio) 2026-06-12 09:53:57 -04:00
rmcrackan a50712256c Merge pull request #1864 from rmcrackan/rmcrackan/1862-security
Rmcrackan/1862 security
2026-06-10 14:31:47 -04:00
rmcrackan 4345971e81 escape string to avoid accidental json injection 2026-06-10 14:29:57 -04:00
rmcrackan 1ad458fe5b CSPRNG-safe 2026-06-10 12:12:38 -04:00
Robert 1547e6bec3 incr ver 2026-06-08 15:41:20 -04:00
rmcrackan 81175e5427 The edit targets two problems in the macOS bundle script.
Reliability: `hdiutil create` can fail on GitHub’s macOS runners with `Resource busy` even when the app build is fine. The retry wrapper gives it several attempts, waits between tries, and cleans up partial DMGs so transient runner issues are less likely to break the release.

Correctness: The script used to keep going after `hdiutil` failed and still exit successfully, which left `./bundle/` empty and made artifact upload fail with a misleading error. `set -euo pipefail` and explicit failure exits make a DMG creation failure stop the job immediately instead of pretending the bundle step succeeded.
2026-06-08 14:42:05 -04:00
rmcrackan 14551c745f Merge pull request #1861 from rmcrackan/rmcrackan/elevate-upgrader
Only elevate upgrader when necessary
2026-06-08 14:31:09 -04:00
rmcrackan e3cfe3a8f0 Only elevate upgrader when necessary 2026-06-08 14:30:35 -04:00
rmcrackan 51fbe73ea1 make vitepress happy 2026-06-08 14:23:00 -04:00
rmcrackan 41fa7b7142 Merge pull request #1860 from rmcrackan/rmcrackan/win-installer-phase-1
Finish windows installers, phase 1
2026-06-08 14:19:41 -04:00
rmcrackan b7ce96e91d Finish windows installers, phase 1 2026-06-08 14:03:59 -04:00
rmcrackan a25fd35f29 Fix release upload path for merge-multiple artifact download 2026-06-08 13:19:10 -04:00
rmcrackan 20a99c6e20 Exclude Docker cache blobs from release artifact download 2026-06-08 11:59:03 -04:00
rmcrackan f39cfaccc7 Fix release artifact pattern for Libation-Classic assets 2026-06-08 11:21:20 -04:00
rmcrackan 6db78e4268 Merge pull request #1859 from rmcrackan/rmcrackan/windows-installer-fix
Fix arm64 Windows zip artifact name in CI matrix
2026-06-08 11:03:49 -04:00
rmcrackan 74e9cf24a8 Fix arm64 Windows zip artifact name in CI matrix 2026-06-08 11:01:42 -04:00
rmcrackan 6ce88072c5 Merge pull request #1858 from rmcrackan/rmcrackan/windows-installer-fix
yaml bug fix
2026-06-08 10:53:50 -04:00
rmcrackan 9058c7368c yaml bug fix 2026-06-08 10:53:05 -04:00
rmcrackan 37d5517756 Merge pull request #1857 from rmcrackan/rmcrackan/windows-installer
Add Windows installers to release build
2026-06-08 10:45:39 -04:00
rmcrackan 0a33863635 Merge branch 'master' into rmcrackan/windows-installer 2026-06-08 10:44:58 -04:00
rmcrackan d03955a2ca Add Windows installers to release build 2026-06-08 10:43:32 -04:00
Robert b2c5ae3c2e incr ver 2026-06-08 10:27:17 -04:00
rmcrackan fb7e0857c6 Merge pull request #1856 from rmcrackan/rmcrackan/releaseindex
update backwards compat releaseindex in prep for future changes
2026-06-08 10:15:26 -04:00
rmcrackan 60965902ca update backwards compat releaseindex in prep for future changes 2026-06-08 10:15:00 -04:00
rmcrackan ef62ee15e2 Merge branch 'master' of https://github.com/rmcrackan/Libation 2026-06-07 12:56:21 -04:00
rmcrackan c285522a0c Add contributor from PR #1854 2026-06-07 12:56:13 -04:00
rmcrackan 0fb3eb152f Merge pull request #1854 from matalvernaz/fix/1729-sqlite-migration-lock-deadlock
Skip EF migration lock when no migrations are pending (fixes SQLite scan-hang #1729)
2026-06-07 12:55:20 -04:00
Matthew Alvernaz 63325b9cad Skip EF migration lock when no migrations are pending (#1729)
DbContexts.GetContext() runs ApplyMigrations on every context creation,
calling Database.Migrate(). Migrate() acquires the EF migration lock
before checking whether anything is pending. For SQLite that lock is a
persisted row in __EFMigrationsLock with no timeout (SQLite has no
connection-scoped lock that frees on disconnect). A process killed after
acquiring the lock but before releasing it - even during an otherwise
no-op Migrate() - orphans the row, and every later Migrate() then spins
forever in SqliteHistoryRepository.AcquireDatabaseLock(). The row lives
in the db file, so restarts never clear it, matching the "scan hangs,
reboot doesn't help" reports in #1729.

Guard Migrate()/MigrateAsync() behind GetPendingMigrations(), which only
reads __EFMigrationsHistory and never acquires the lock. The steady-state
path (schema already current) no longer touches the lock at all; first
run and real migrations are unchanged.
2026-06-06 22:23:12 -07:00
rmcrackan 37a60ca654 Merge pull request #1853 from rmcrackan/rmcrackan-patch-1
increm ver
2026-06-05 10:42:50 -04:00
rmcrackan 6623300883 Update AppScaffolding.csproj 2026-06-05 10:42:21 -04:00
rmcrackan 329df0882a Merge pull request #1852 from rmcrackan/rmcrackan/1851-threading
#1851 - threading bug
2026-06-04 10:23:11 -04:00
rmcrackan da000694d7 upgrade dependency 2026-06-04 10:17:23 -04:00
rmcrackan f1a7a837f3 CI bug. try upgrading 2026-06-04 10:13:22 -04:00
rmcrackan 869917e0ce #1851 - threading bug 2026-06-04 09:59:12 -04:00
rmcrackan d532e40fe9 Merge pull request #1850 from rmcrackan/rmcrackan/doc-cleanup
Clean up docs
2026-06-03 11:05:49 -04:00
rmcrackan 9ef5e95801 Clean up docs 2026-06-03 11:04:59 -04:00
rmcrackan c1c3c33ea8 Merge pull request #1849 from rmcrackan/rmcrackan/docs-better-atmos
better atmos docs
2026-06-02 16:45:36 -04:00
rmcrackan b31e7cb819 better atmos docs 2026-06-02 16:44:54 -04:00
rmcrackan 314a46d59d Merge pull request #1848 from rmcrackan/rmcrackan/docs-spacial-audio
new page for spacial audio info
2026-06-02 16:39:21 -04:00
rmcrackan 721f09a62b new page for spacial audio info 2026-06-02 16:38:12 -04:00
rmcrackan 545b3057d4 Merge pull request #1847 from rmcrackan/rmcrackan/1845-better-non-json
#1845 - improve error message for common VPN blocking errors
2026-06-02 12:58:30 -04:00
rmcrackan 2c2ae2c8b0 #1845 - improve error message for common VPN blocking errors. improve troubleshooting docs 2026-06-02 12:53:20 -04:00
rmcrackan f456c68176 Merge pull request #1846 from rmcrackan/rmcrackan/1844-nre
#1844 - add lock to avoid race/NRE
2026-06-02 10:59:25 -04:00
rmcrackan e6c17053f7 #1844 - add lock to avoid race/NRE 2026-06-02 10:54:14 -04:00
rmcrackan 0c22b821bf incr ver 2026-05-25 11:16:38 -04:00
rmcrackan 8902ddc565 Merge pull request #1838 from rmcrackan/rmcrackan/1836-disk-space
#1836 - Fix misleading "Not enough disk space" warnings
2026-05-25 11:15:08 -04:00
rmcrackan 9ed98cbfff unit test fix: this is always going be a weird test. it's trying to balance machine speed vs long running process -- and test 'unreasonable' response time 2026-05-25 10:58:57 -04:00
rmcrackan 3cf5d0b3fa unit test bug fix: culture info 2026-05-25 10:46:34 -04:00
rmcrackan 6bd1ea7ca4 fix unit tests for windows-only logic 2026-05-25 10:41:20 -04:00
rmcrackan f8c5f0da68 #1836 - Fix misleading "Not enough disk space" warnings when Books and In progress are on different drives 2026-05-25 10:25:46 -04:00
rmcrackan a3f272a48e #1835 - add log file guidance to FAQ 2026-05-22 15:00:34 -04:00
Robert 9ee59738eb incr ver 2026-05-21 10:36:21 -04:00
rmcrackan f57839c104 Merge pull request #1834 from rmcrackan/rmcrackan/1833-pdf-bug-fix
Download works but queue goes red with Download PDF
2026-05-21 10:33:52 -04:00
rmcrackan d63fabd73d Download works but queue goes red with Download PDF 2026-05-21 10:33:13 -04:00
rmcrackan 54a97cc3c4 Merge pull request #1830 from rmcrackan/fix/docs-vite-security-override
Pin vite 6.4.2 for docs site (Dependabot GHSA-4w7w-66w2-5vf9)
2026-05-19 09:44:28 -04:00
rmcrackanandCursor 1e3daa40dc Pin vite 6.4.2 for docs site to resolve Dependabot alert.
VitePress 1.6.4 requires vite ^5.4.14, which has no backport for
GHSA-4w7w-66w2-5vf9. An npm override keeps VitePress 1.x while
using a patched vite. Deploy workflow now watches package files.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-05-19 09:40:44 -04:00
197 changed files with 10698 additions and 1169 deletions

No files matched your search

+1
View File
@@ -0,0 +1 @@
*.sh text eol=lf
+1
View File
@@ -0,0 +1 @@
blank_issues_enabled: false
+2 -2
View File
@@ -37,9 +37,9 @@ jobs:
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
+2 -2
View File
@@ -40,9 +40,9 @@ jobs:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
+47 -4
View File
@@ -16,6 +16,10 @@ on:
type: boolean
publish-r2r:
type: boolean
build-installer:
type: boolean
description: "Build *-setup.exe on all Windows matrix jobs when release (zips always built for .releaseindex.json)"
default: false
retention-days:
type: number
@@ -28,18 +32,20 @@ jobs:
ui: [Avalonia]
architecture: [x64]
release_name: [chardonnay]
artifact_stem: [Libation]
include:
- architecture: x64
ui: WinForms
release_name: classic
prefix: Classic-
artifact_stem: Libation-Classic
- architecture: arm64
ui: Avalonia
release_name: chardonnay
artifact_stem: Libation
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- uses: actions/setup-dotnet@v5
- uses: actions/setup-dotnet@v6
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
@@ -75,7 +81,7 @@ jobs:
"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="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path * -DestinationPath "$artifact"
@@ -85,3 +91,40 @@ jobs:
path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
- name: Install Inno Setup
if: ${{ inputs.build-installer }}
run: choco install innosetup -y --no-progress
- name: Build Windows installer
id: setup
if: ${{ inputs.build-installer }}
shell: pwsh
run: |
if ("${{ matrix.ui }}" -eq "WinForms") {
$setupName = "Libation-Classic.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
$ui = "WinForms"
} else {
$setupName = "Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
$ui = "Avalonia"
}
./Scripts/Windows/Build-WindowsInstaller.ps1 `
-Version "${{ inputs.libation-version }}" `
-Ui $ui `
-Architecture "${{ matrix.architecture }}" `
-SkipPublish `
-BinDir "${{ github.workspace }}/bin"
if (-not (Test-Path "${{ github.workspace }}/bin/$setupName")) {
throw "Installer not found: bin/$setupName"
}
"setup=$setupName" >> $env:GITHUB_OUTPUT
- name: Upload installer artifact
if: ${{ inputs.build-installer }}
uses: actions/upload-artifact@v7
with:
name: ${{ steps.setup.outputs.setup }}
path: ./bin/${{ steps.setup.outputs.setup }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}
+3
View File
@@ -35,6 +35,7 @@ jobs:
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
build-installer: ${{ inputs.release }}
retention-days: ${{ inputs.retention-days }}
macOS:
@@ -67,3 +68,5 @@ jobs:
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }}
+4 -2
View File
@@ -10,6 +10,8 @@ on:
- docs/**
- .vitepress/**
- index.md
- package.json
- package-lock.json
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
@@ -32,7 +34,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
with:
fetch-depth: 0 # Not needed if lastUpdated is not enabled
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
@@ -40,7 +42,7 @@ jobs:
# 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
uses: actions/setup-node@v7
with:
node-version: 24
cache: npm # or pnpm / yarn
+5 -3
View File
@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v6
uses: actions/checkout@v7
- name: Set up QEMU
uses: docker/setup-qemu-action@v4
@@ -36,7 +36,7 @@ jobs:
- name: Login to Docker Hub
if: ${{ inputs.release }}
uses: docker/login-action@v4
uses: docker/login-action@v4.6.0
with:
username: ${{ secrets.docker_username }}
password: ${{ secrets.docker_token }}
@@ -59,7 +59,9 @@ jobs:
context: .
platforms: linux/amd64,linux/arm64
push: ${{ steps.metadata.outputs.tags != ''}}
# Always read cache. Only write on release so PR validate cannot fail
# when GHA cache is full or the token cannot write cache.
cache-from: type=gha
cache-to: type=gha,mode=max
cache-to: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }}
tags: ${{ steps.metadata.outputs.tags }}
labels: ${{ steps.metadata.outputs.labels }}
+11 -3
View File
@@ -41,11 +41,19 @@ jobs:
needs: [prerelease, build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
- name: Download Chardonnay, Linux, and Mac artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
pattern: "*(Classic-)Libation.*"
pattern: "Libation.*"
merge-multiple: true
- name: Download Classic Windows artifacts
uses: actions/download-artifact@v8
with:
path: artifacts
pattern: "Libation-Classic.*"
merge-multiple: true
- name: Release
id: release
@@ -57,4 +65,4 @@ jobs:
draft: true
prerelease: false
files: |
artifacts/*/*
artifacts/*
+1 -1
View File
@@ -17,7 +17,7 @@ jobs:
stale:
runs-on: ubuntu-latest
steps:
- uses: actions/stale@v10
- uses: actions/stale@v11
with:
days-before-stale: 30
days-before-close: 14
+1 -1
View File
@@ -18,7 +18,7 @@ jobs:
sync-labels:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Sync labels
uses: micnncim/action-label-syncer@v1
@@ -17,6 +17,6 @@ jobs:
container:
image: ghcr.io/flathub/flatpak-builder-lint:latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
+1 -1
View File
@@ -15,7 +15,7 @@ jobs:
validate-desktop-file:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/checkout@v7
- run: sudo apt --yes install desktop-file-utils
- name: Check the desktop file
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
+1 -1
View File
@@ -1,5 +1,5 @@
{
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
"WindowsClassic": "(?:Classic-Libation|Libation-Classic)\\.\\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",
+34 -3
View File
@@ -2,6 +2,29 @@ import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
vite: {
// esbuild 0.28+ will not downlevel destructuring for safari14 / chrome87.
// Raise the target so docs:dev and docs:build do not need that transform.
build: {
target: "es2022",
},
optimizeDeps: {
esbuildOptions: {
target: "es2022",
},
},
esbuild: {
supported: {
destructuring: true,
},
},
// Avoid watching the C# solution / VS locks (EBUSY on .vsidx files).
server: {
watch: {
ignored: ["**/Source/**", "**/.vs/**", "**/bin/**", "**/obj/**"],
},
},
},
title: "Libation",
description:
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
@@ -35,7 +58,7 @@ export default defineConfig({
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
{ text: "Donate", link: "/donate" },
],
sidebar: [
{
@@ -47,7 +70,7 @@ export default defineConfig({
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
{ text: "Donate", link: "/donate" },
],
},
{
@@ -67,6 +90,10 @@ export default defineConfig({
text: "Audio File Formats",
link: "/docs/features/audio-file-formats",
},
{
text: "Audiobookshelf Auto-Upload",
link: "/docs/features/audiobookshelf",
},
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
{
text: "Searching & Filtering",
@@ -84,7 +111,8 @@ export default defineConfig({
items: [
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" }
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
],
},
{
@@ -113,5 +141,8 @@ export default defineConfig({
search: {
provider: "local",
},
// Show the external-link arrow on outbound links in markdown (nav/sidebar already do).
externalLinkIcon: true,
},
});
+45
View File
@@ -9,6 +9,51 @@
"Liberation Mono", "Courier New", monospace;
}
/* Donate page: primary options */
.donate-options {
display: flex;
flex-wrap: wrap;
gap: 1rem;
margin: 1.25rem 0 0.5rem;
}
.donate-option {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 0.15rem;
min-width: 11rem;
padding: 1rem 1.25rem;
border: 1px solid var(--vp-c-divider);
border-radius: 8px;
background: var(--vp-c-bg-soft);
text-decoration: none !important;
color: var(--vp-c-text-1) !important;
transition: border-color 0.15s ease, background-color 0.15s ease;
}
.donate-option:hover {
border-color: var(--vp-c-brand-1);
background: var(--vp-c-bg-elv);
}
.donate-option__icon {
width: 1.75rem;
height: 1.75rem;
margin-bottom: 0.35rem;
}
.donate-option__label {
font-size: 1.1rem;
font-weight: 600;
line-height: 1.3;
}
.donate-option__hint {
font-size: 0.85rem;
color: var(--vp-c-text-2);
}
/* 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 */
+25 -3
View File
@@ -102,6 +102,7 @@ export function getDetectOS() {
if (lower.endsWith('.deb')) packageType = 'deb'
else if (lower.endsWith('.rpm')) packageType = 'rpm'
else if (lower.endsWith('.dmg')) packageType = 'dmg'
else if (lower.endsWith('-setup.exe')) packageType = 'installer'
else if (lower.endsWith('.zip') || lower.endsWith('.msi')) packageType = 'archive'
return { platform, arch, packageType: packageType || 'unknown', name }
}
@@ -141,6 +142,8 @@ export function getDetectOS() {
if (wantOs === 'linux' && p.packageType === 'deb' && preferDeb) s += 20
if (wantOs === 'linux' && p.packageType === 'rpm' && preferRpm) s += 20
if (wantOs === 'linux' && !preferDeb && !preferRpm && p.packageType === 'deb') s += 5
if (wantOs === 'windows' && p.packageType === 'installer') s += 30
if (wantOs === 'windows' && p.packageType === 'archive') s -= 10
if (nameLower.includes('chardonnay')) s += 10
if (nameLower.includes('classic')) s -= 5
return s
@@ -154,6 +157,20 @@ export function getDetectOS() {
if (wantArch) altCandidates = altCandidates.filter(e => e.parsed.arch === wantArch)
const alternatives = altCandidates.map(e => ({ name: e.name, url: e.url }))
const classicVsChardonnayFaqUrl = 'https://getlibation.com/docs/frequently-asked-questions#what-s-the-difference-between-classic-and-chardonnay'
function isWindowsSetup(name) {
return (name || '').toLowerCase().endsWith('-setup.exe')
}
function preferWindowsAsset(current, candidate) {
if (!candidate) return current
if (!current) return candidate
const candSetup = isWindowsSetup(candidate.name)
const currSetup = isWindowsSetup(current.name)
if (candSetup && !currSetup) return candidate
return current
}
function isClassicWindowsAsset(nameLower) {
return nameLower.includes('libation-classic') || nameLower.includes('classic-libation')
}
let windowsX64Choice = null
if (wantOs === 'windows' && wantArch === 'x64') {
let winChardonnay = null
@@ -162,10 +179,15 @@ export function getDetectOS() {
const e = parsed[j]
if (e.parsed.platform !== 'windows' || e.parsed.arch !== 'x64') continue
const n = (e.name || '').toLowerCase()
if (n.includes('chardonnay')) winChardonnay = { name: e.name, url: e.url }
if (n.includes('classic')) winClassic = { name: e.name, url: e.url }
if (n.includes('chardonnay')) winChardonnay = preferWindowsAsset(winChardonnay, e)
else if (isClassicWindowsAsset(n)) winClassic = preferWindowsAsset(winClassic, e)
}
if (winChardonnay && winClassic) {
windowsX64Choice = {
chardonnay: { name: winChardonnay.name, url: winChardonnay.url },
classic: { name: winClassic.name, url: winClassic.url },
}
}
if (winChardonnay && winClassic) windowsX64Choice = { chardonnay: winChardonnay, classic: winClassic }
}
if (!recommended && wantOs) {
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
+57
View File
@@ -0,0 +1,57 @@
# AGENTS.md
## Cursor Cloud specific instructions
Libation is a cross-platform .NET desktop/CLI app for downloading and de-DRMing Audible
audiobooks. The core product is the Avalonia GUI (`LibationAvalonia`, assembly name `Libation`)
plus a headless CLI (`LibationCli`) that shares the same config and SQLite library database.
### Toolchain / environment
- Requires the **.NET 10 SDK** pinned by `global.json` (`10.0.101`). It is preinstalled at
`~/.dotnet`; `~/.bashrc` exports `DOTNET_ROOT` and adds `~/.dotnet` (and `~/.dotnet/tools`) to
`PATH`. New non-login shells may not have it — use `dotnet` after a login shell, or call
`~/.dotnet/dotnet` directly.
- The startup update script runs `dotnet tool restore` (restores `dotnet-ef`) and
`dotnet restore Source/Libation.slnx`. No other setup is needed.
- The solution file is the new XML format: `Source/Libation.slnx` (there is no classic `.sln`).
### Building — Linux only builds the cross-platform projects
- Do **not** build the whole solution on Linux. Several projects are Windows/OS-specific and will
not build here: `LibationWinForms`, `HangoverWinForms` (target `net10.0-windows7.0`) and the
`LoadByOS/{Windows,MacOS}ConfigApp` helpers.
- Build the runnable cross-platform apps directly:
- `dotnet build Source/LibationAvalonia/LibationAvalonia.csproj`
- `dotnet build Source/LibationCli/LibationCli.csproj`
- There is no dedicated lint step; the repo's `.editorconfig` is minimal and CI
(`.github/workflows/validate.yml`) only builds and tests. The compiler/analyzer warnings from a
normal build serve as the static-analysis check. (A known transitive `NU1903` SQLite
vulnerability warning is expected and harmless.)
### Testing
- Test projects live under `Source/_Tests/` and use MSTest on **Microsoft.Testing.Platform**
(configured via `global.json` `test.runner`). Because of this runner, `dotnet test` requires
`--project` for a single project (a positional project path is rejected):
`dotnet test --project Source/_Tests/FileManager.Tests/FileManager.Tests.csproj`
- CI runs `dotnet test` from `Source/`, which builds the entire solution first and is slow;
prefer running the seven test projects individually on Linux.
- **GNOME Keyring / OS secret store:** Libation's default `TokenStorageMethod` is `Encrypted`,
and the AES-GCM master key is stored via the OS secret store (`OsSecretStore` /
`IdentityTokenStorageWiring`). On Linux that is GNOME Keyring (Secret Service). Some tests in
`AudibleUtilities.Tests` exercise the real OS store when it is available. If the login keyring
has never been created/unlocked, those calls can **block for minutes** waiting on a desktop
password prompt (looks like a hung test suite). A human must set a keyring password once via
the Desktop pane when the prompt appears; after that, subsequent runs are fast. The keyring
lives at `~/.local/share/keyrings/`. Do not "kill and retry" a stalled `AudibleUtilities.Tests`
run until you have checked for a keyring dialog on `DISPLAY=:1`.
### Running the apps
- GUI: a display is available on `DISPLAY=:1`. Run with
`cd Source/LibationAvalonia && dotnet run`. First launch shows a Welcome/walkthrough and creates
config under `~/.local/share/Libation/` (`Settings.json`, `AccountsSettings.json`,
`LibationContext.db`). The same keyring note above applies when the GUI/CLI first encrypts
account tokens.
- CLI: `cd Source/LibationCli && dotnet run -- <command>` (e.g. `version`, `get-setting`,
`list-accounts`, `scan`, `liberate`). The CLI reads the same `~/.local/share/Libation/` config
and database as the GUI.
- Actually scanning/downloading a library requires signing into a real Audible account, so full
end-to-end liberation cannot be exercised without credentials.
+1
View File
@@ -0,0 +1 @@
getlibation.com
+34 -2
View File
@@ -38,6 +38,25 @@ init_config_file() {
fi
}
init_master_key() {
FILE=libation-master.key
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
if [ -n "${LIBATION_MASTER_KEY_FILE}" ]; then
debug "LIBATION_MASTER_KEY_FILE is already set, not looking for ${FILE}"
return 0
fi
if [ -n "${LIBATION_MASTER_KEY}" ]; then
debug "LIBATION_MASTER_KEY is already set, not looking for ${FILE}"
return 0
fi
if [ -f ${FULLPATH} ]; then
info "loading ${FILE}"
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
else
debug "${FULLPATH} not found, assuming accounts don't use encrypted tokens"
fi
}
update_settings() {
FILE=$1
KEY=$2
@@ -141,6 +160,11 @@ setup_db() {
run() {
info "scanning accounts"
/libation/LibationCli scan
local scan_exit=$?
if [ "${scan_exit}" -ne 0 ]; then
error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"
exit "${scan_exit}"
fi
info "liberating books"
/libation/LibationCli liberate
}
@@ -149,7 +173,8 @@ main() {
info "initializing libation"
init_config_file AccountsSettings.json
init_config_file Settings.json
init_master_key
info "loading settings"
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
update_settings Settings.json InProgress /tmp
@@ -171,6 +196,13 @@ main() {
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
fi
# If a command was passed to the container (e.g. `docker run -it libation bash`),
# run that instead of the liberate loop so the environment is still set up
if [ "$#" -gt 0 ]; then
info "executing '$*' instead of the liberate loop"
exec "$@"
fi
# Let the user know what the run type will be
if [[ -z "${SLEEP_TIME}" ]]; then
SLEEP_TIME=-1
@@ -198,4 +230,4 @@ main() {
info "exiting"
}
main
main "$@"
+3 -1
View File
@@ -37,6 +37,8 @@ RUN apt-get update && apt-get -y upgrade && \
COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation
COPY Docker/* /libation
RUN ln -s /libation/LibationCli /usr/local/bin/LibationCli
USER ${USER_UID}:${USER_GID}
CMD ["/libation/liberate.sh"]
ENTRYPOINT ["/libation/liberate.sh"]
+2 -1
View File
@@ -32,6 +32,7 @@ Comprehensive documentation is available in the `docs` directory and on our [Doc
- [Getting Started](https://getlibation.com/docs/getting-started)
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
- [Spatial Audio & DRM](https://getlibation.com/docs/advanced/spatial-audio)
## Development & Contributing
@@ -53,5 +54,5 @@ Libation is released under the GPL-3.0 License
---
If you found this useful, tell a friend. If you found this REALLY useful, you can click here to PayPal.me
If you found this useful, tell a friend. If you found this REALLY useful, you can [donate here](https://getlibation.com/donate)
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
+49 -9
View File
@@ -1,4 +1,5 @@
#!/bin/bash
set -euo pipefail
BIN_DIR=$1; shift
VERSION=$1; shift
@@ -8,25 +9,25 @@ 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
exit 1
fi
if [ ! -d "$BIN_DIR" ]
then
echo "The directory \"$BIN_DIR\" does not exist."
exit
exit 1
fi
if [ -z $VERSION ]
if [ -z "${VERSION:-}" ]
then
echo "This script must be called with the Libation version number as an argument."
exit
exit 1
fi
if [ -z $ARCH ]
if [ -z "${ARCH:-}" ]
then
echo "This script must be called with the Libation cpu architecture as an argument."
exit
exit 1
fi
if [ "$SIGN_WITH_KEY" != "true" ]
@@ -40,9 +41,46 @@ echo "Bundle dir: $BUNDLE"
if [[ -d $BUNDLE ]]
then
echo "$BUNDLE directory already exists, aborting."
exit
exit 1
fi
# hdiutil create intermittently fails on CI with "Resource busy" (macOS background scanning).
create_dmg_with_retry() {
local src_folder="$1"
local output_path="$2"
local max_attempts=10
local attempt=1
local delay=3
while [ "$attempt" -le "$max_attempts" ]; do
echo "Creating disk image (attempt ${attempt}/${max_attempts}): ${output_path}"
rm -f "${output_path}"
sync
sleep 1
if hdiutil create -srcFolder "${src_folder}" -ov -format UDZO -o "${output_path}"; then
if [ -f "${output_path}" ]; then
return 0
fi
fi
echo "hdiutil create failed on attempt ${attempt}"
hdiutil detach -force /Volumes/Libation 2>/dev/null || true
rm -f "${output_path}" "${output_path}.temp.dmg" 2>/dev/null || true
sync
if [ "$attempt" -lt "$max_attempts" ]; then
echo "Waiting ${delay}s before retry..."
sleep "${delay}"
delay=$((delay + 2))
fi
attempt=$((attempt + 1))
done
echo "Error: hdiutil create failed after ${max_attempts} attempts"
return 1
}
BUNDLE_CONTENTS=$BUNDLE/Contents
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
@@ -72,6 +110,9 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
echo "Writing PkgInfo file..."
printf 'APPL????' > $BUNDLE_CONTENTS/PkgInfo
echo "Moving Libation_DS_Store file..."
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
@@ -118,8 +159,7 @@ mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
sync
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
create_dmg_with_retry ./Libation "./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.
+164
View File
@@ -0,0 +1,164 @@
#Requires -Version 5.1
<#
.SYNOPSIS
Builds a Libation Windows setup.exe from a published bin directory using Inno Setup.
.DESCRIPTION
Optionally runs dotnet publish (same projects as CI), removes standalone WindowsConfigApp
artifacts, then compiles Scripts/Windows/Libation.iss.
.PARAMETER Version
Libation version string (e.g. 13.4.1).
.PARAMETER Ui
Avalonia (Chardonnay) or WinForms (Classic). Classic is x64 only.
.PARAMETER Architecture
x64 or arm64 (Avalonia only).
.PARAMETER BinDir
Folder containing published files. Defaults to repo-root bin.
.PARAMETER SkipPublish
Skip dotnet publish; BinDir must already contain a complete publish output.
.EXAMPLE
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
.EXAMPLE
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
#>
[CmdletBinding()]
param(
[Parameter(Mandatory = $true)]
[string] $Version,
[ValidateSet('Avalonia', 'WinForms')]
[string] $Ui = 'Avalonia',
[ValidateSet('x64', 'arm64')]
[string] $Architecture = 'x64',
[string] $BinDir = '',
[switch] $SkipPublish
)
$ErrorActionPreference = 'Stop'
if ($Ui -eq 'WinForms' -and $Architecture -ne 'x64') {
throw 'Classic (WinForms) is only published for x64.'
}
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
$sourceDir = Join-Path $repoRoot 'Source'
if (-not $BinDir) {
$BinDir = Join-Path $repoRoot 'bin'
}
if (-not (Test-Path $BinDir)) {
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
}
$BinDir = (Resolve-Path $BinDir).Path
$runtime = "win-$Architecture"
$releaseName = if ($Ui -eq 'WinForms') { 'classic' } else { 'chardonnay' }
$outputBase = if ($Ui -eq 'WinForms') {
"Libation-Classic.$Version-windows-$releaseName-$Architecture-setup"
} else {
"Libation.$Version-windows-$releaseName-$Architecture-setup"
}
$architecturesAllowed = if ($Architecture -eq 'arm64') { 'arm64' } else { 'x64compatible' }
function Find-ISCC {
$candidates = @(
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
"${env:LocalAppData}\Programs\Inno Setup 6\ISCC.exe"
)
foreach ($path in $candidates) {
if (Test-Path $path) { return $path }
}
return $null
}
if (-not $SkipPublish -and (Test-Path $BinDir)) {
Write-Host "Clearing $BinDir before publish ..."
Get-ChildItem -LiteralPath $BinDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
}
if (-not $SkipPublish) {
Write-Host "Publishing Libation ($Ui, $runtime) to $BinDir ..."
Push-Location $sourceDir
try {
$publishArgs = @(
'--runtime', $runtime,
'--configuration', 'Release',
'--output', $BinDir,
'-p:PublishProtocol=FileSystem',
'-p:PublishReadyToRun=false',
'-p:SelfContained=true'
)
dotnet publish "Libation$Ui/Libation$Ui.csproj" @publishArgs
dotnet publish 'LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj' @publishArgs
dotnet publish 'LibationCli/LibationCli.csproj' @publishArgs
dotnet publish "Hangover$Ui/Hangover$Ui.csproj" @publishArgs
}
finally {
Pop-Location
}
}
Write-Host 'Removing standalone WindowsConfigApp launcher artifacts ...'
$removeFiles = @(
'WindowsConfigApp.exe',
'WindowsConfigApp.runtimeconfig.json',
'WindowsConfigApp.deps.json'
)
Push-Location $BinDir
try {
foreach ($file in $removeFiles) {
if (Test-Path $file) { Remove-Item $file -Force }
}
if (-not (Test-Path 'Libation.exe')) {
throw "Libation.exe not found in $BinDir. Publish or point -BinDir at a complete output folder."
}
if (-not (Test-Path 'ZipExtractor.exe')) {
throw "ZipExtractor.exe not found in $BinDir. It is required for in-app upgrades."
}
}
finally {
Pop-Location
}
$iscc = Find-ISCC
if (-not $iscc) {
throw @"
Inno Setup 6 (ISCC.exe) was not found. Install from https://jrsoftware.org/isdl.php
and ensure ISCC.exe is under Program Files (x86)\Inno Setup 6\ or similar.
"@
}
$setupIcon = Join-Path $PSScriptRoot 'libation.ico'
if (-not (Test-Path $setupIcon)) {
throw "Installer icon not found: $setupIcon"
}
$iss = Join-Path $PSScriptRoot 'Libation.iss'
$defines = @(
"/DMyVersion=$Version",
"/DMySetupIcon=$setupIcon",
"/DMyReleaseName=$releaseName",
"/DMyArchitecture=$Architecture",
"/DOutputBaseFilename=$outputBase",
"/DSourceDir=$BinDir",
"/DOutputDir=$BinDir",
"/DArchitecturesAllowed=$architecturesAllowed"
)
Write-Host "Compiling $outputBase.exe ..."
& $iscc $defines $iss
if ($LASTEXITCODE -ne 0) {
throw "ISCC failed with exit code $LASTEXITCODE."
}
$setupPath = Join-Path $BinDir "$outputBase.exe"
Write-Host "Done: $setupPath"
+93
View File
@@ -0,0 +1,93 @@
; Libation Windows installer (Inno Setup 6.x)
;
; Compile with ISCC and /D defines. See Scripts/Windows/README.md and
; Build-WindowsInstaller.ps1 for local builds; CI will pass the same defines.
;
; Required /D defines from build script:
; MyVersion, MyReleaseName, MyArchitecture, MySetupIcon,
; OutputBaseFilename, SourceDir, OutputDir, ArchitecturesAllowed
#ifndef MyVersion
#define MyVersion "0.0.0-dev"
#endif
#ifndef MyReleaseName
#define MyReleaseName "chardonnay"
#endif
#ifndef MyArchitecture
#define MyArchitecture "x64"
#endif
#ifndef MySetupIcon
#define MySetupIcon "libation.ico"
#endif
#ifndef OutputBaseFilename
#define OutputBaseFilename "Libation.0.0.0-dev-windows-chardonnay-x64-setup"
#endif
#ifndef SourceDir
#define SourceDir "."
#endif
#ifndef OutputDir
#define OutputDir "."
#endif
#ifndef ArchitecturesAllowed
#define ArchitecturesAllowed "x64compatible"
#endif
#if MyReleaseName == 'classic'
#define InstallFolderName "Libation-Classic"
#define AppName "Libation (Classic)"
#else
#define InstallFolderName "Libation"
#define AppName "Libation (Chardonnay)"
#endif
[Setup]
#if MyReleaseName == 'classic'
AppId={{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}}
#elif MyArchitecture == 'arm64'
AppId={{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}}
#else
AppId={{A7B8C9D0-E1F2-4A3B-8C9D-0E1F2A3B4C5D}}
#endif
AppName={#AppName}
AppVersion={#MyVersion}
AppVerName={#AppName} {#MyVersion}
AppPublisher=Libation
AppPublisherURL=https://getlibation.com/
AppSupportURL=https://github.com/rmcrackan/Libation/issues
AppUpdatesURL=https://github.com/rmcrackan/Libation/releases
DefaultDirName={localappdata}\{#InstallFolderName}
DefaultGroupName={#AppName}
PrivilegesRequired=lowest
OutputDir={#OutputDir}
OutputBaseFilename={#OutputBaseFilename}
SourceDir={#SourceDir}
ArchitecturesAllowed={#ArchitecturesAllowed}
ArchitecturesInstallIn64BitMode=x64compatible
DisableProgramGroupPage=no
Compression=lzma2
SolidCompression=yes
WizardStyle=modern
SetupIconFile={#MySetupIcon}
UninstallDisplayIcon={app}\Libation.exe
UninstallDisplayName={#AppName}
VersionInfoVersion={#MyVersion}
VersionInfoProductName={#AppName}
VersionInfoCompany=Libation
[Languages]
Name: "english"; MessagesFile: "compiler:Default.isl"
[Tasks]
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
[Files]
Source: "*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
[Icons]
Name: "{group}\Libation"; Filename: "{app}\Libation.exe"; Comment: "Libation audiobook manager"
Name: "{group}\Hangover"; Filename: "{app}\Hangover.exe"; Comment: "Hangover license viewer"
Name: "{group}\Libation CLI"; Filename: "{app}\LibationCli.exe"; Comment: "Libation command-line tools"
Name: "{autodesktop}\Libation"; Filename: "{app}\Libation.exe"; Tasks: desktopicon; Comment: "Libation audiobook manager"
[Run]
Filename: "{app}\Libation.exe"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
+89
View File
@@ -0,0 +1,89 @@
# Windows installer (Inno Setup)
Step 8.1 of the Windows installers plan: a parameterized Inno Setup script that installs the same flat publish layout as the release zip, under a per-user directory, so the in-app upgrader can still overlay files via `ZipExtractor.exe`.
## Prerequisites
1. [.NET SDK](https://dotnet.microsoft.com/download) (same version as the repo; see `build.yml`).
2. [Inno Setup 6](https://jrsoftware.org/isdl.php) with `ISCC.exe` on PATH or in a default install location.
## Quick build (publish + installer)
From PowerShell, repo root or this folder:
```powershell
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
```
Outputs:
- Publish tree: `bin\` (repo root)
- Installer: `bin\Libation.13.4.1-windows-chardonnay-x64-setup.exe`
### Variants
| UI | `-Ui` | `-Architecture` | Install folder | Output prefix |
|----|-------|-----------------|----------------|---------------|
| Chardonnay x64 | `Avalonia` | `x64` | `%LocalAppData%\Libation` | `Libation.*` |
| Chardonnay arm64 | `Avalonia` | `arm64` | `%LocalAppData%\Libation` | `Libation.*` |
| Classic x64 | `WinForms` | `x64` (default) | `%LocalAppData%\Libation-Classic` | `Libation-Classic.*-setup.exe` |
Examples:
```powershell
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture arm64
```
### Reuse an existing publish folder
```powershell
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -BinDir C:\path\to\bin -SkipPublish
```
`BinDir` must already match CI layout (including `ZipExtractor.exe`, without standalone `WindowsConfigApp.exe`).
## Manual ISCC (without the helper script)
After publish and cleanup (remove `WindowsConfigApp.exe`, `WindowsConfigApp.runtimeconfig.json`, `WindowsConfigApp.deps.json` from `bin`), from `Scripts\Windows`:
```text
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" Libation.iss ^
/DMyVersion=13.4.1 ^
/DMyPrefix= ^
/DMyReleaseName=chardonnay ^
/DMyArchitecture=x64 ^
/DInstallFolderName=Libation ^
/DAppName=Libation ^
/DOutputBaseFilename=Libation.13.4.1-windows-chardonnay-x64-setup ^
/DSourceDir=C:\Dev\_rmcrackan\Libation\bin ^
/DOutputDir=C:\Dev\_rmcrackan\Libation\bin ^
/DArchitecturesAllowed=x64compatible
```
Classic x64: use `MyReleaseName=classic` and matching `OutputBaseFilename` (`Libation-Classic.{version}-windows-classic-x64-setup`). Install folder and display name are set in `Libation.iss`. GitHub zip assets use `Libation-Classic.{version}-windows-classic-x64.zip`; `.releaseindex.json` also accepts legacy `Classic-Libation.*` zips on older releases.
arm64: set `MyArchitecture=arm64` and `ArchitecturesAllowed=arm64`.
## Installer behavior
- **Per-user install** (`PrivilegesRequired=lowest`, default dir under `{localappdata}`).
- **Shortcuts** target `{app}\Libation.exe` (required for upgrade path detection).
- **Does not** create `appsettings.json` or Libation Files; same as zip install.
- **Includes** full publish output so `ZipExtractor.exe` remains for in-app updates.
- **Settings -> Apps:** shows `Libation (Chardonnay)` or `Libation (Classic)` with version in `DisplayVersion` (Inno at install; synced after in-app zip upgrades via `WindowsUninstallRegistrySync`); icon from `SetupIconFile` / `UninstallDisplayIcon` (`Scripts/Windows/libation.ico` and `{app}\Libation.exe`).
## Next plan step (8.2)
Install via `*-setup.exe`, run the app, then verify an older release can upgrade in-app using the existing zip-based upgrader.
CI wiring (`build-windows.yml`) is step 8.3.
## Files in this folder
| File | Purpose |
|------|---------|
| `Libation.iss` | Parameterized Inno Setup script |
| `libation.ico` | Setup/uninstaller icon (copied from Avalonia assets) |
| `Build-WindowsInstaller.ps1` | Local publish + ISCC helper |
| `README.md` | This document |
Binary file not shown.

After

Width:  |  Height:  |  Size: 35 KiB

+1 -1
View File
@@ -14,7 +14,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="3.0.2" />
<PackageReference Include="AAXClean.Codecs" Version="3.1.0" />
</ItemGroup>
<ItemGroup>
+1 -1
View File
@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>13.4.2.1</Version>
<Version>13.7.7</Version>
<Nullable>enable</Nullable>
</PropertyGroup>
<ItemGroup>
+22 -172
View File
@@ -41,6 +41,7 @@ public static class LibationScaffolding
{
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
public const string DonateUrl = WebsiteUrl + "/donate";
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
/// <summary>Documentation for naming template syntax</summary>
public const string NamingTemplatesDocUrl = WebsiteUrl + "/docs/features/naming-templates";
@@ -69,6 +70,8 @@ public static class LibationScaffolding
// // outdated. kept here as an example of what belongs in this area
// // Migrations.migrate_to_v5_2_0__pre_config();
InstallFolderUnblock.TryUnblockProcessDirectoryIfWindows();
Configuration.SetLibationVersion(BuildVersion);
//***********************************************//
@@ -91,16 +94,15 @@ public static class LibationScaffolding
{
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
}
config.ValidateEnumSettings();
DeleteOpenSqliteFiles(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists();
IdentityTokenStorageWiring.Apply(config);
//
// migrations go below here
//
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);
}
@@ -159,65 +161,7 @@ public static class LibationScaffolding
}
private static void ensureSerilogConfig(Configuration config)
{
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
{
{ "Name", "File" },
{ "Args",
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "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}"
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
// {Properties:j} needed for expanded exception logging
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
}
}
}
}
},
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
};
config.SetNonString(serilogObj, "Serilog");
}
=> config.EnsureSerilogConfig();
// to restore original: Console.SetOut(origOut);
private static TextWriter origOut { get; } = Console.Out;
@@ -347,7 +291,7 @@ public static class LibationScaffolding
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
@@ -421,85 +365,44 @@ public static class LibationScaffolding
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
string? regexPattern;
string? releaseIdString = null;
try
{
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
releaseIdString = InteropFactory.Create().ReleaseIdString;
regexPattern = releaseIndex.Value<string>(releaseIdString);
}
catch
{
regexPattern = null;
}
if (string.IsNullOrEmpty(regexPattern) && Configuration.IsLinux)
{
var baseId = ReleaseIdentifier.ToString();
regexPattern = releaseIndex.Value<string>($"{baseId}_RPM")
?? releaseIndex.Value<string>($"{baseId}_DEB");
releaseIdString ??= $"{baseId}_RPM";
}
if (string.IsNullOrEmpty(regexPattern))
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
if (string.IsNullOrEmpty(regexPattern))
{
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}). Version check inconclusive.", ReleaseIdentifier);
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}, ReleaseIdString: {ReleaseIdString}). Version check inconclusive.", ReleaseIdentifier, releaseIdString);
return (null, null, null, false);
}
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
var zip = latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name));
if (zip is not null && !string.IsNullOrEmpty(releaseIdString))
Log.Logger.Information("Update asset matched using {ReleaseIdString}: {AssetName}", releaseIdString, zip.Name);
return (releaseVersion, latestRelease, zip, true);
}
}
internal static class Migrations
{
public static void migrate_to_v6_6_9(Configuration config)
{
var writeToPath = $"Serilog.WriteTo";
// remove WriteTo[].Name == Console
{
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
{
for (var i = length1 - 1; i >= 0; i--)
{
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
if (exists && value == "Console")
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
}
}
}
// add Serilog.Exceptions -- WithExceptionDetails
{
// outputTemplate should contain "{Properties:j}"
{
// re-calculate. previous loop may have changed the length
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
{
var propertyName = "outputTemplate";
for (var i = 0; i < length2; i++)
{
var jsonPath = $"{writeToPath}[{i}].Args";
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
if (exists && value != newValue)
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
}
}
}
// Serilog.Using must include "Serilog.Exceptions"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
// Serilog.Enrich must include "WithExceptionDetails"
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
}
}
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)
{
//Migrate from version 1 file cache to the dictionary-based version 2 cache
@@ -518,7 +421,7 @@ internal static class Migrations
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
return;
Dictionary<string, JArray> cache = new();
Dictionary<string, JArray> cache = [];
//Convert to c# objects to speed up searching by ID inside the iterator
var allItems
@@ -572,57 +475,4 @@ internal static class Migrations
catch { /* eat */ }
}
}
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
}
}
}
@@ -0,0 +1,566 @@
using Newtonsoft.Json;
using Newtonsoft.Json.Linq;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net;
using System.Net.Http;
using System.Net.Http.Headers;
using System.Threading.Tasks;
namespace ApplicationServices;
public static class AudiobookshelfApiService
{
public record Library(string Id, string Name, string MediaType, List<Folder> Folders);
public record Folder(string Id, string FullPath);
public enum UploadResult { Success, AlreadyExists, Failed }
/// <summary>
/// Audiobookshelf client (browser) route segments. If present in a pasted URL,
/// everything from that segment onward is UI path, not part of the API base.
/// </summary>
private static readonly HashSet<string> ClientRouteSegments = new(StringComparer.OrdinalIgnoreCase)
{
"library",
"item",
"collection",
"playlist",
"author",
"series",
"audiobook",
"config",
"login",
"account",
};
/// <summary>
/// Normalize a user-supplied Audiobookshelf server URL to an API base address.
/// Compensates for common mistakes such as pasting a browser library/item page URL,
/// including a trailing <c>/api</c> path, query strings, or omitting the scheme.
/// </summary>
/// <returns>Absolute http(s) base URL with no trailing slash.</returns>
public static string NormalizeServerUrl(string? serverUrl)
{
if (string.IsNullOrWhiteSpace(serverUrl))
throw new ArgumentException("Server URL is required.", nameof(serverUrl));
var trimmed = serverUrl.Trim();
// Allow host:port without a scheme (common when copying from docs or local setups).
if (!trimmed.Contains("://", StringComparison.Ordinal))
trimmed = "http://" + trimmed;
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|| string.IsNullOrWhiteSpace(uri.Host))
{
throw new ArgumentException(
"Server URL must be an absolute http(s) address, for example http://localhost:13378 or https://abs.example.com",
nameof(serverUrl));
}
var path = StripClientRoutesAndApi(uri.AbsolutePath);
var authority = uri.GetLeftPart(UriPartial.Authority);
if (string.IsNullOrEmpty(path) || path == "/")
return authority;
return authority + path.TrimEnd('/');
}
private static string StripClientRoutesAndApi(string absolutePath)
{
if (string.IsNullOrEmpty(absolutePath) || absolutePath == "/")
return "";
var parts = absolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
var prefixLength = 0;
for (var i = 0; i < parts.Length; i++)
{
var part = parts[i];
// Trailing or mid-path /api/... means the user included the API root.
if (string.Equals(part, "api", StringComparison.OrdinalIgnoreCase))
break;
if (ClientRouteSegments.Contains(part))
break;
prefixLength += 1 + part.Length;
}
if (prefixLength <= 0)
return "";
return absolutePath[..prefixLength];
}
private static HttpClient CreateClient(string serverUrl)
{
var normalized = NormalizeServerUrl(serverUrl);
var client = new HttpClient();
client.BaseAddress = new Uri(normalized + "/");
client.Timeout = TimeSpan.FromSeconds(30);
return client;
}
public static async Task<List<Library>> GetLibrariesAsync(string serverUrl, string apiToken)
{
var normalizedUrl = NormalizeServerUrl(serverUrl);
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
using var client = CreateClient(normalizedUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
HttpResponseMessage response;
try
{
response = await client.GetAsync("api/libraries");
}
catch (TaskCanceledException ex)
{
throw new HttpRequestException(
$"Timed out connecting to Audiobookshelf at {normalizedUrl}. Check the Server URL and that the server is running.",
ex);
}
catch (HttpRequestException ex)
{
throw new HttpRequestException(
$"Could not reach Audiobookshelf at {normalizedUrl}. Check the Server URL, network, and that the server is running. Details: {ex.Message}",
ex);
}
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw CreateLibrariesFetchException(normalizedUrl, response.StatusCode, errorBody);
}
var json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
var libraries = obj["libraries"] as JArray ?? new JArray();
var allLibraries = libraries.Select(l => new Library(
l["id"]?.Value<string>() ?? "",
l["name"]?.Value<string>() ?? "",
l["mediaType"]?.Value<string>() ?? "book",
(l["folders"] as JArray ?? new JArray())
.Select(f => new Folder(
f["id"]?.Value<string>() ?? "",
f["fullPath"]?.Value<string>() ?? ""))
.ToList()))
.ToList();
// Only return libraries with book media type
return allLibraries.Where(l => string.Equals(l.MediaType, "book", StringComparison.OrdinalIgnoreCase)).ToList();
}
private static HttpRequestException CreateLibrariesFetchException(string normalizedUrl, HttpStatusCode statusCode, string errorBody)
{
var code = (int)statusCode;
var bodyPreview = SummarizeErrorBody(errorBody);
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
{
return new HttpRequestException(
$"Audiobookshelf rejected the API token ({code} {statusCode}). " +
$"Check that the token is correct and not expired. Server: {normalizedUrl}");
}
if (statusCode == HttpStatusCode.NotFound)
{
return new HttpRequestException(
$"Audiobookshelf returned 404 when calling {normalizedUrl}/api/libraries. " +
"Server URL should be the Audiobookshelf base address only " +
"(for example http://localhost:13378 or https://abs.example.com), " +
"not a library or book page from the browser. " +
"If Audiobookshelf is served under a subpath, include that subpath " +
$"(for example http://host/audiobookshelf). Response: {bodyPreview}");
}
return new HttpRequestException(
$"Audiobookshelf API returned {code} ({statusCode}) when fetching libraries from {normalizedUrl}. Response: {bodyPreview}");
}
private static string SummarizeErrorBody(string errorBody)
{
if (string.IsNullOrWhiteSpace(errorBody))
return "(empty)";
var trimmed = errorBody.Trim();
const int maxLen = 280;
if (trimmed.Length <= maxLen)
return trimmed;
return trimmed[..maxLen] + "...";
}
public sealed record ConnectResult(
bool Success,
string StatusMessage,
string? NormalizedServerUrl,
bool ServerUrlAdjusted,
IReadOnlyList<Library> Libraries);
/// <summary>
/// Normalize URL, fetch book libraries, and produce UI status text.
/// Callers apply busy-state and bind <see cref="ConnectResult.Libraries"/> to controls.
/// </summary>
public static async Task<ConnectResult> ConnectAsync(string? serverUrl, string? apiToken)
{
if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiToken))
{
return new ConnectResult(
Success: false,
StatusMessage: "Please enter both server URL and API token.",
NormalizedServerUrl: null,
ServerUrlAdjusted: false,
Libraries: []);
}
string normalizedUrl;
bool urlAdjusted;
try
{
var trimmedUrl = serverUrl.Trim();
normalizedUrl = NormalizeServerUrl(trimmedUrl);
urlAdjusted = !string.Equals(trimmedUrl.TrimEnd('/'), normalizedUrl, StringComparison.OrdinalIgnoreCase);
}
catch (Exception ex)
{
return new ConnectResult(
Success: false,
StatusMessage: $"Connection failed: {ex.Message}",
NormalizedServerUrl: null,
ServerUrlAdjusted: false,
Libraries: []);
}
try
{
var libraries = await GetLibrariesAsync(normalizedUrl, apiToken.Trim());
if (libraries.Count == 0)
{
return new ConnectResult(
Success: false,
StatusMessage: "No book libraries found. Check your settings.",
NormalizedServerUrl: normalizedUrl,
ServerUrlAdjusted: urlAdjusted,
Libraries: []);
}
return new ConnectResult(
Success: true,
StatusMessage: FormatConnectedStatus(libraries.Count, urlAdjusted),
NormalizedServerUrl: normalizedUrl,
ServerUrlAdjusted: urlAdjusted,
Libraries: libraries);
}
catch (Exception ex)
{
return new ConnectResult(
Success: false,
StatusMessage: $"Connection failed: {ex.Message}",
NormalizedServerUrl: normalizedUrl,
ServerUrlAdjusted: urlAdjusted,
Libraries: []);
}
}
public static string FormatConnectedStatus(int libraryCount, bool urlAdjusted)
{
var libraryWord = libraryCount == 1 ? "library" : "libraries";
var message = $"Connected. Found {libraryCount} {libraryWord}.";
if (urlAdjusted)
message += " Server URL adjusted to the API base address.";
return message;
}
/// <summary>
/// Normalize for persistence when possible; otherwise keep the trimmed raw value
/// so the user can fix it on the next connect attempt.
/// </summary>
public static string TryNormalizeServerUrlForSave(string? url)
{
if (string.IsNullOrWhiteSpace(url))
return url?.Trim() ?? "";
try
{
return NormalizeServerUrl(url);
}
catch (ArgumentException)
{
return url.Trim();
}
}
public static async Task<bool> BookExistsAsync(string serverUrl, string apiToken, string libraryId, string title, string? author = null)
{
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromSeconds(30);
var normalizedTitle = NormalizeTitle(title);
var baseTitle = GetBaseTitle(normalizedTitle);
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: looking for '{Title}' (base: '{BaseTitle}') in library {LibraryId}", normalizedTitle, baseTitle, libraryId);
try
{
// Search with base title first (shorter = broader fuzzy matches, better recall)
var candidates = await SearchLibraryAsync(client, libraryId, baseTitle, 20);
// Also search with full title to catch cases where ABS stores the longer/subtitled version
if (!string.Equals(normalizedTitle, baseTitle, StringComparison.Ordinal))
{
var fullCandidates = await SearchLibraryAsync(client, libraryId, normalizedTitle, 10);
foreach (var c in fullCandidates)
{
if (!candidates.Any(existing => string.Equals(existing.Id, c.Id, StringComparison.Ordinal)))
candidates.Add(c);
}
}
if (candidates.Count == 0)
{
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: no candidates found for '{Title}' in library {LibraryId}", normalizedTitle, libraryId);
return false;
}
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: found {Count} candidate(s) for '{Title}' in library {LibraryId}", candidates.Count, normalizedTitle, libraryId);
foreach (var candidate in candidates)
{
if (string.IsNullOrWhiteSpace(candidate.Title))
continue;
bool titleMatch = false;
// 1) Exact match against normalized title
if (string.Equals(candidate.Title, normalizedTitle, StringComparison.OrdinalIgnoreCase))
titleMatch = true;
// 2) Exact match against base title (without subtitle)
if (!titleMatch && string.Equals(candidate.Title, baseTitle, StringComparison.OrdinalIgnoreCase))
titleMatch = true;
// 3) Exact match against combined title:subtitle
if (!titleMatch && !string.IsNullOrWhiteSpace(candidate.FullTitle)
&& string.Equals(candidate.FullTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
titleMatch = true;
if (titleMatch && AuthorsCompatible(author, candidate.Authors))
{
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: found match for '{Title}' (id={ItemId})", title, candidate.Id);
return true;
}
}
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: '{Title}' not found in library {LibraryId}", normalizedTitle, libraryId);
return false;
}
catch (Exception ex)
{
Serilog.Log.Logger.Debug(ex, "Audiobookshelf duplicate check failed for '{Title}' in library {LibraryId}", title, libraryId);
throw; // Let caller decide whether to proceed with upload
}
}
private record SearchCandidate(string Id, string Title, string FullTitle, List<string> Authors);
private static async Task<List<SearchCandidate>> SearchLibraryAsync(HttpClient client, string libraryId, string query, int limit)
{
var url = $"api/libraries/{Uri.EscapeDataString(libraryId)}/search?q={Uri.EscapeDataString(query)}&limit={limit}";
var response = await client.GetAsync(url);
if (!response.IsSuccessStatusCode)
{
var errorBody = await response.Content.ReadAsStringAsync();
throw new HttpRequestException($"Audiobookshelf search returned {(int)response.StatusCode} for query '{query}'. Body: {errorBody}");
}
var json = await response.Content.ReadAsStringAsync();
var obj = JObject.Parse(json);
var books = obj["book"] as JArray ?? new JArray();
var results = new List<SearchCandidate>();
foreach (var entry in books)
{
var item = entry["libraryItem"] ?? entry;
var id = item["id"]?.Value<string>() ?? entry["id"]?.Value<string>() ?? "";
var itemTitle = item["media"]?["metadata"]?["title"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
var itemSubtitle = item["media"]?["metadata"]?["subtitle"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
var itemFullTitle = string.IsNullOrWhiteSpace(itemSubtitle)
? itemTitle
: $"{itemTitle}: {itemSubtitle}";
var authors = item["media"]?["metadata"]?["authors"] as JArray;
var authorList = authors?.Select(a => a["name"]?.Value<string>()?.Trim() ?? "")
.Where(n => !string.IsNullOrWhiteSpace(n))
.ToList() ?? [];
results.Add(new SearchCandidate(
id,
NormalizeTitle(itemTitle),
NormalizeTitle(itemFullTitle),
authorList));
}
return results;
}
private static bool AuthorsCompatible(string? libationAuthor, List<string> absAuthors)
{
// If either side has no author info, we can't use authors to disambiguate
if (string.IsNullOrWhiteSpace(libationAuthor))
return true;
if (absAuthors == null || absAuthors.Count == 0)
return true;
var libationAuthors = libationAuthor.Split(',', StringSplitOptions.RemoveEmptyEntries)
.Select(a => NormalizeAuthor(a))
.Where(a => !string.IsNullOrWhiteSpace(a))
.ToList();
if (libationAuthors.Count == 0)
return true;
foreach (var absAuthor in absAuthors.Select(NormalizeAuthor))
{
foreach (var la in libationAuthors)
{
if (string.Equals(absAuthor, la, StringComparison.OrdinalIgnoreCase))
return true;
}
}
return false;
}
private static string NormalizeAuthor(string? author)
{
if (string.IsNullOrWhiteSpace(author))
return author ?? "";
return author.Replace("\u00A0", " ").Trim();
}
private static string NormalizeTitle(string? title)
{
if (string.IsNullOrWhiteSpace(title))
return title ?? "";
var normalized = title
.Replace("\u00A0", " ")
.Replace("(Unabridged)", "", StringComparison.OrdinalIgnoreCase)
.Replace("(Abridged)", "", StringComparison.OrdinalIgnoreCase)
.Trim();
// Collapse multiple spaces to single space
while (normalized.Contains(" ", StringComparison.Ordinal))
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
return normalized;
}
private static string GetBaseTitle(string fullTitle)
{
if (string.IsNullOrWhiteSpace(fullTitle))
return fullTitle;
var colonIndex = fullTitle.IndexOf(": ", StringComparison.Ordinal);
return colonIndex > 0 ? fullTitle[..colonIndex].Trim() : fullTitle;
}
public static async Task<UploadResult> UploadBookAsync(
string serverUrl,
string apiToken,
string libraryId,
string folderId,
string title,
string? author,
string? series,
IEnumerable<string> filePaths)
{
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
// Pre-check for existing item
try
{
if (await BookExistsAsync(serverUrl, apiToken, libraryId, title, author))
{
Serilog.Log.Logger.Information("Skipping Audiobookshelf upload: book '{Title}' already exists in library {LibraryId}", title, libraryId);
return UploadResult.AlreadyExists;
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Warning(ex, "Pre-check for existing book on Audiobookshelf failed; will still attempt upload");
// Continue to upload attempt below
}
using var client = CreateClient(serverUrl);
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
client.Timeout = TimeSpan.FromMinutes(30);
using var form = new MultipartFormDataContent();
form.Add(new StringContent(title), "title");
if (!string.IsNullOrWhiteSpace(author))
form.Add(new StringContent(author), "author");
if (!string.IsNullOrWhiteSpace(series))
form.Add(new StringContent(series), "series");
form.Add(new StringContent(libraryId), "library");
form.Add(new StringContent(folderId), "folder");
int fileIndex = 0;
var streams = new List<Stream>();
try
{
foreach (var path in filePaths.Where(File.Exists))
{
var stream = File.OpenRead(path);
streams.Add(stream);
var fileContent = new StreamContent(stream);
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path));
fileIndex++;
}
if (fileIndex == 0)
{
Serilog.Log.Logger.Warning("No audio files found to upload to Audiobookshelf for '{Title}'", title);
return UploadResult.Failed;
}
var response = await client.PostAsync("api/upload", form);
if (response.IsSuccessStatusCode)
return UploadResult.Success;
var responseBody = await response.Content.ReadAsStringAsync();
Serilog.Log.Logger.Error("Audiobookshelf upload failed for '{Title}' with status {(int)response.StatusCode} ({StatusCode}). Response body: {ResponseBody}",
title, (int)response.StatusCode, response.StatusCode, responseBody);
// Treat "already exists" 500 errors as skip/success
if (response.StatusCode == System.Net.HttpStatusCode.InternalServerError
&& responseBody.Contains("already exists", StringComparison.OrdinalIgnoreCase))
{
return UploadResult.AlreadyExists;
}
return UploadResult.Failed;
}
finally
{
foreach (var stream in streams)
{
try { stream.Dispose(); } catch { /* ignored */ }
}
}
}
}
@@ -0,0 +1,68 @@
using AudibleApi.Authorization;
using LibationFileManager;
namespace ApplicationServices;
public static class AudiobookshelfTokenStorage
{
private const string EncryptedPrefix = "enc:";
private const string AssociatedData = "audiobookshelf-api-token";
public static string? EncryptToken(string? plaintextToken)
{
if (string.IsNullOrEmpty(plaintextToken))
return plaintextToken;
// Guard against double-encrypting an already-encrypted value
if (plaintextToken.StartsWith(EncryptedPrefix))
return plaintextToken;
if (Configuration.Instance.TokenStorageMethod != TokenStorageMethod.Encrypted)
return plaintextToken;
var protector = IdentityTokenStorage.Protector;
if (protector is null)
{
Serilog.Log.Logger.Warning("Audiobookshelf API token encryption requested but no protector is available; storing plaintext");
return plaintextToken;
}
try
{
var encrypted = protector.Protect(plaintextToken, AssociatedData);
return EncryptedPrefix + encrypted;
}
catch (System.Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to encrypt Audiobookshelf API token; storing plaintext");
return plaintextToken;
}
}
public static string? DecryptToken(string? storedToken)
{
if (string.IsNullOrEmpty(storedToken))
return storedToken;
if (!storedToken.StartsWith(EncryptedPrefix))
return storedToken;
var payload = storedToken[EncryptedPrefix.Length..];
var protector = IdentityTokenStorage.Protector;
if (protector is null)
{
Serilog.Log.Logger.Warning("Audiobookshelf API token decryption requested but no protector is available; returning stored value");
return storedToken;
}
try
{
return protector.Unprotect(payload, AssociatedData);
}
catch (System.Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to decrypt Audiobookshelf API token; returning stored value");
return storedToken;
}
}
}
+2 -2
View File
@@ -132,9 +132,9 @@ public static class DbContexts
return context.GetDeletedLibraryBooks();
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
using var context = GetContext();
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
}
}
+140 -111
View File
@@ -12,6 +12,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
using static DtoImporterService.PerfLogger;
@@ -23,7 +24,12 @@ public static class LibraryCommands
public static event EventHandler<int>? ScanEnd;
public static bool Scanning { get; private set; }
private static object _lock { get; } = new();
/// <summary>
/// Serializes library scan and import operations so only one path reads/writes
/// <see cref="Book"/> / <see cref="LibraryBook"/> rows at a time (prevents duplicate ASIN inserts).
/// </summary>
private static readonly SemaphoreSlim ImportGate = new(1, 1);
static LibraryCommands()
{
@@ -35,151 +41,170 @@ public static class LibraryCommands
{
logRestart();
lock (_lock)
{
if (Scanning)
return new();
}
ScanBegin?.Invoke(null, accounts.Length);
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
if (accounts is null || accounts.Length == 0)
return new List<LibraryBook>();
await ImportGate.WaitAsync();
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
ScanBegin?.Invoke(null, accounts.Length);
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
return missingBookList;
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
//These are the minimum response groups required for the
//library scanner to pass all validation and filtering.
var libraryOptions = new LibraryOptions
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
ResponseGroups
= LibraryOptions.ResponseGroupOptions.ProductAttrs
| LibraryOptions.ResponseGroupOptions.ProductDesc
| LibraryOptions.ResponseGroupOptions.Relationships
};
try
{
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = libraryItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error scanning library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, 0);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
ImportGate.Release();
}
}
#region FULL LIBRARY scan and import
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Account[]? accounts, bool allowInteractiveLogin)
{
logRestart();
if (accounts is null || accounts.Length == 0)
return (0, 0);
await ImportGate.WaitAsync();
int newCount = 0;
try
{
lock (_lock)
{
if (Scanning)
return (0, 0);
}
ScanBegin?.Invoke(null, accounts.Length);
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
try
{
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(accounts, libraryOptions);
logTime($"post {nameof(scanAccountsAsync)} all");
logTime($"pre {nameof(scanAccountsAsync)} all");
var libraryOptions = new LibraryOptions
{
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(accounts, libraryOptions, allowInteractiveLogin);
logTime($"post {nameof(scanAccountsAsync)} all");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
var totalCount = importItems.Count;
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
if (totalCount == 0)
return default;
if (totalCount == 0)
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
Log.Logger.Information("Begin long-running import");
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.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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
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:
// https://github.com/RehanSaeed/Serilog.Exceptions
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
{
lfEx.RequestUrl,
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
lfEx.ResponseInputFields,
lfEx.ResponseBodyFilePaths
});
throw;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error importing library");
throw;
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
}
}
finally
{
stop();
var putBreakPointHere = logOutput;
ScanEnd?.Invoke(null, newCount);
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
ImportGate.Release();
}
}
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
{
await ImportGate.WaitAsync();
try
{
return importSingleToDb(item, accountId, localeName);
}
finally
{
ImportGate.Release();
}
}
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
{
ArgumentValidator.EnsureNotNull(item, nameof(item));
@@ -250,7 +275,7 @@ public static class LibraryCommands
return null;
}
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
{
var tasks = new List<Task<List<ImportItem>>>();
@@ -266,11 +291,15 @@ public static class LibraryCommands
try
{
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
var apiExtended = await ApiExtended.CreateAsync(account);
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin);
// add scanAccountAsync as a TASK: do not await
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
}
catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
{
throw;
}
catch (Exception ex)
{
//Catch to allow other accounts to continue scanning.
@@ -0,0 +1,89 @@
using AudibleApi.Authorization;
namespace AudibleUtilities;
/// <summary>
/// Libation-facing helpers when AccountsSettings.json contains encrypted tokens
/// that cannot be unlocked on this OS/machine (common when copying Windows config into Docker).
/// </summary>
public static class AccountSettingsDecryptFailure
{
public const string LoadErrorCaption = "Error Loading Account Settings";
public const string FaqUrl
= "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken";
private static readonly string[] ThingsToTryBullets =
[
"Keep tokens encrypted: on the machine that created the file, export the master key (Settings -> Important -> Export encryption key..., or LibationCli export-master-key libation-master.key), then copy that file into the Docker/config folder next to AccountsSettings.json (or set LIBATION_MASTER_KEY_FILE / LIBATION_MASTER_KEY).",
"Or convert to plaintext: Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens when prompted, then copy the updated AccountsSettings.json again.",
"Or re-authenticate on this machine/container with LibationCli login-external or import-account.",
$"Details: {FaqUrl}",
];
/// <summary>
/// True when <paramref name="ex"/> (or an inner exception) is a decrypt failure from identity token JSON.
/// </summary>
public static bool TryFindInTree(Exception ex, out IdentityTokenDecryptException? match)
{
ArgumentNullException.ThrowIfNull(ex);
IdentityTokenDecryptException? found = null;
walk(ex);
match = found;
return found is not null;
void walk(Exception? e)
{
if (e is null || found is not null)
return;
if (e is IdentityTokenDecryptException decryptEx)
{
found = decryptEx;
return;
}
if (e is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
walk(inner);
}
walk(e.InnerException);
}
}
public static IEnumerable<string> GetExplainerLines(Exception ex)
{
ArgumentNullException.ThrowIfNull(ex);
yield return "Encrypted authentication tokens in AccountsSettings.json could not be decrypted on this machine.";
yield return "The encryption key is stored in the OS secret store where the tokens were encrypted (for example Windows DPAPI) and does not travel when you copy the file to Docker or another computer.";
if (TryFindInTree(ex, out var decryptEx) && decryptEx is not null)
yield return $"Underlying error: {decryptEx.Message.TrimEnd('.')}.";
yield return string.Empty;
yield return "Things to try:";
foreach (var bullet in ThingsToTryBullets)
yield return "• " + bullet;
}
public static string GetExplainerBody(Exception ex)
=> string.Join("\r\n", GetExplainerLines(ex));
/// <summary>
/// Dialog body after backup-and-empty recovery when load failed due to token decrypt.
/// </summary>
public static string GetRecoveredDialogBody(Exception ex, string backupPath)
{
ArgumentNullException.ThrowIfNull(ex);
ArgumentException.ThrowIfNullOrWhiteSpace(backupPath);
return $"""
{GetExplainerBody(ex)}
Libation created a new, empty account settings file so the app can start. You will need to re-add your Audible account(s) (or copy a plaintext AccountsSettings.json) before scanning or downloading.
The previous account settings file was archived at '{backupPath}'
""";
}
}
@@ -0,0 +1,59 @@
using AudibleApi.Authorization;
namespace AudibleUtilities;
/// <summary>
/// Alignment and conversion helpers for identity tokens in <c>AccountsSettings.json</c>.
/// Preference changes alone do not convert; conversion is explicit via <see cref="ConvertAllAccounts"/>.
/// </summary>
public static class AccountTokenStorage
{
/// <summary>
/// Compare persisted encryption metadata for all account identities to <paramref name="method"/>.
/// </summary>
public static TokenStorageAlignment GetAccountsAlignment(TokenStorageMethod method, string? accountsSettingsFile = null)
{
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
if (!File.Exists(path))
return TokenStorageAlignment.NoApplicableTokens;
using var persister = new AccountsSettingsPersister(path);
var alignments = new List<TokenStorageAlignment>();
foreach (var account in persister.AccountsSettings.Accounts)
{
if (account.IdentityTokens is null)
continue;
alignments.Add(IdentityTokenConversion.GetAlignment(account.IdentityTokens, method));
}
return CombineAlignments(alignments);
}
/// <summary>
/// Convert every identity in the accounts settings file to <paramref name="method"/>.
/// All-or-nothing: failure leaves the original file unchanged.
/// </summary>
public static IdentityTokenConversionResult ConvertAllAccounts(TokenStorageMethod method, string? accountsSettingsFile = null)
{
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
return IdentityTokenConversion.ConvertAllIdentitiesInFile(path, method);
}
private static TokenStorageAlignment CombineAlignments(IReadOnlyList<TokenStorageAlignment> alignments)
{
if (alignments.Count == 0)
return TokenStorageAlignment.NoApplicableTokens;
if (alignments.Any(a => a == TokenStorageAlignment.Indeterminate))
return TokenStorageAlignment.Indeterminate;
if (alignments.All(a => a is TokenStorageAlignment.AllMatch or TokenStorageAlignment.NoApplicableTokens))
{
return alignments.Any(a => a == TokenStorageAlignment.AllMatch)
? TokenStorageAlignment.AllMatch
: TokenStorageAlignment.NoApplicableTokens;
}
return TokenStorageAlignment.SomeMismatch;
}
}
+48 -16
View File
@@ -26,8 +26,13 @@ public class ApiExtended
private ApiExtended(Api api) => Api = api;
private static readonly SemaphoreSlim InteractiveLoginGate = new(1, 1);
/// <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)
public static Task<ApiExtended> CreateAsync(Account account)
=> CreateAsync(account, allowInteractiveLogin: true);
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin)
{
ArgumentValidator.EnsureNotNull(account, nameof(account));
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
@@ -46,25 +51,52 @@ public class ApiExtended
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
catch
catch (Exception ex)
{
if (LoginChoiceFactory is null)
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
Serilog.Log.Logger.Information("{@DebugInfo}", new
if (!allowInteractiveLogin || LoginChoiceFactory is null)
{
LoginType = nameof(ILoginChoiceEager),
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name
});
Serilog.Log.Logger.Error(ex,
"Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
new
{
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name,
AllowInteractiveLogin = allowInteractiveLogin,
LoginChoiceFactorySet = LoginChoiceFactory is not null
});
var api = await EzApiCreator.GetApiAsync(
LoginChoiceFactory(account),
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
throw new AuthenticationRequiredException(
account,
message: $"Stored credentials for '{account.AccountId}' could not be used"
+ (LoginChoiceFactory is null
? " and interactive login is not available in this context (CLI/Docker)."
: " and interactive login was not allowed.")
+ $" {ex.Message}",
innerException: ex);
}
return new ApiExtended(api);
await InteractiveLoginGate.WaitAsync();
try
{
Serilog.Log.Logger.Information("{@DebugInfo}", new
{
LoginType = nameof(ILoginChoiceEager),
Account = account.MaskedLogEntry ?? "[null]",
LocaleName = locale.Name
});
var api = await EzApiCreator.GetApiAsync(
LoginChoiceFactory(account),
locale,
AudibleApiStorage.AccountsSettingsFile,
account.GetIdentityTokensJsonPath());
return new ApiExtended(api);
}
finally
{
InteractiveLoginGate.Release();
}
}
}
+9 -17
View File
@@ -62,24 +62,16 @@ public static class AudibleApiStorage
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
public static string GetIdentityTokensJsonPath(string username, string? localeName)
{
var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
var usernameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(username);
var localeNameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(localeName ?? string.Empty);
return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
return $"$.Accounts[?(@.AccountId == '{usernameEscaped}' && @.IdentityTokens.LocaleName == '{localeNameEscaped}')].IdentityTokens";
}
private static string trimSurroundingQuotes(string str)
{
// SubString algo is better than .Trim("\"")
// orig string "
// json string "\""
// Eg:
// => str.Trim("\"")
// output \
// vs
// => str.Substring(1, str.Length - 2)
// output \"
// also works with surrounding single quotes
return str.Substring(1, str.Length - 2);
}
/// <summary>
/// Escape a value for use inside single-quoted Newtonsoft JSONPath filter literals.
/// See https://www.newtonsoft.com/json/help/html/QueryJsonSelectTokenEscaped.htm
/// </summary>
internal static string EscapeNewtonsoftJsonPathSingleQuotedLiteral(string value)
=> value.Replace("\\", @"\\").Replace("'", @"\'");
}
@@ -3,10 +3,11 @@
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="10.1.5.1" />
<PackageReference Include="AudibleApi" Version="10.3.5.1" />
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
</ItemGroup>
@@ -0,0 +1,27 @@
using AudibleApi.Authentication;
namespace AudibleUtilities;
public static class AuthenticationExceptionHelper
{
public static bool IsAuthenticationFailure(Exception ex)
{
if (ex is AggregateException aggregate)
{
return aggregate.InnerExceptions.Any(IsAuthenticationFailure)
|| (aggregate.InnerException is not null && IsAuthenticationFailure(aggregate.InnerException));
}
for (var current = ex; current is not null; current = current.InnerException)
{
if (current is AuthenticationRequiredException or LoginFailedException)
return true;
if (current is InvalidOperationException { Message: var message }
&& message.Contains("ADP token is null", StringComparison.Ordinal))
return true;
}
return false;
}
}
@@ -0,0 +1,14 @@
namespace AudibleUtilities;
/// <summary>
/// Stored Audible credentials are missing or invalid and interactive login is required.
/// Thrown instead of opening login UI when the caller disallows interactive login (e.g. auto-scan).
/// </summary>
public sealed class AuthenticationRequiredException : Exception
{
public Account? Account { get; }
public AuthenticationRequiredException(Account? account, string? message = null, Exception? innerException = null)
: base(message ?? "Audible authentication is required.", innerException)
=> Account = account;
}
@@ -0,0 +1,286 @@
using AudibleApi.Authorization;
using Dinah.Core.IO;
using Dinah.Core.Security;
using LibationFileManager;
using System.ComponentModel;
using System.Security.Cryptography;
namespace AudibleUtilities;
/// <summary>
/// Applies Libation's <see cref="Configuration.TokenStorageMethod"/> to AudibleApi identity persistence.
/// Changing the preference alone does not convert existing tokens.
/// </summary>
public static class IdentityTokenStorageWiring
{
public const string ApplicationName = "Libation";
/// <summary>Env var: path to a raw 32-byte master key file (from <c>export-master-key</c>).</summary>
public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE";
/// <summary>Env var: Base64-encoded 32-byte master key.</summary>
public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY";
/// <summary>Default master key file name under the Libation files directory.</summary>
public const string DefaultMasterKeyFileName = "libation-master.key";
private static Lock Gate { get; } = new();
private static Configuration? _wiredConfig;
/// <summary>
/// Configure AudibleApi token persistence from <paramref name="config"/> and keep it in sync when the preference changes.
/// </summary>
public static void Apply(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
ConfigureFrom(config);
lock (Gate)
{
if (ReferenceEquals(_wiredConfig, config))
return;
if (_wiredConfig is not null)
_wiredConfig.PropertyChanged -= OnPropertyChanged;
_wiredConfig = config;
_wiredConfig.PropertyChanged += OnPropertyChanged;
}
}
/// <summary>
/// Configure AudibleApi from the current preference without attaching change listeners.
/// Prefer <see cref="Apply"/> at application startup.
/// </summary>
public static void ConfigureFrom(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
var method = config.TokenStorageMethod;
var store = ResolveSecretStore(config);
AesGcmSecretProtector? protector = store.IsAvailable
? new AesGcmSecretProtector(store)
: null;
// Encrypted + unavailable store => protector null => fail-closed on encrypt/decrypt.
IdentityTokenStorage.Configure(method, protector);
}
/// <summary>
/// Resolve the secret store used for the AES-GCM master key.
/// Supported priority: <see cref="MasterKeyFileEnvVar"/> -> existing default <see cref="DefaultMasterKeyFileName"/>
/// under Libation files -> <see cref="MasterKeyEnvVar"/> -> OS-bound <see cref="OsSecretStore"/>.
/// If the OS store is unavailable, falls through to
/// <see cref="TryCreateLastResortPortableMasterKeyStore"/> (headless compatibility path; not the preferred setup).
/// </summary>
public static IOsSecretStore ResolveSecretStore(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
var keyFileEnv = Environment.GetEnvironmentVariable(MasterKeyFileEnvVar);
if (!string.IsNullOrWhiteSpace(keyFileEnv))
return LoadMasterKeyFileOrUnavailable(keyFileEnv.Trim());
var defaultKeyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
if (File.Exists(defaultKeyPath))
return LoadMasterKeyFileOrUnavailable(defaultKeyPath);
var keyEnv = Environment.GetEnvironmentVariable(MasterKeyEnvVar);
if (!string.IsNullOrWhiteSpace(keyEnv))
return LoadMasterKeyBase64OrUnavailable(keyEnv.Trim());
var osStore = OsSecretStore.Create(ApplicationName);
if (osStore.IsAvailable)
return osStore;
return TryCreateLastResortPortableMasterKeyStore(config);
}
/// <summary>True when the OS secret store can hold Libation's encryption master key.</summary>
public static bool IsOsSecretStoreAvailable(out string? unavailableReason)
{
var store = OsSecretStore.Create(ApplicationName);
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
return store.IsAvailable;
}
/// <summary>True when any resolved secret store (portable or OS) can supply an encryption master key.</summary>
public static bool IsEncryptionKeyAvailable(Configuration config, out string? unavailableReason)
{
ArgumentNullException.ThrowIfNull(config);
var store = ResolveSecretStore(config);
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
return store.IsAvailable;
}
/// <summary>Sidecar notice written beside an auto-minted last-resort master key.</summary>
public const string LastResortMasterKeyNoticeFileName = "libation-master.key.NOTICE.txt";
/// <summary>
/// Headless / Docker compatibility path when encryption is enabled but there is no OS secret store
/// and the user did not supply a master key.
/// Prefer setting <see cref="Configuration.TokenStorageMethod"/> to plaintext, or supplying an exported
/// <see cref="DefaultMasterKeyFileName"/> / env key, instead of relying on this path.
/// Creates <see cref="DefaultMasterKeyFileName"/> under Libation files if missing, then loads it.
/// </summary>
internal static IOsSecretStore TryCreateLastResortPortableMasterKeyStore(Configuration config)
{
ArgumentNullException.ThrowIfNull(config);
var keyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
try
{
if (!File.Exists(keyPath))
{
MintRawMasterKeyFile(keyPath);
AnnounceLastResortPortableMasterKey(keyPath);
}
return LoadMasterKeyFileOrUnavailable(keyPath);
}
catch (Exception ex)
{
return new UnavailablePortableSecretStore(
"Last-resort portable master key",
$"Could not create or load last-resort portable master key ({keyPath}): {SafeMessage(ex)}");
}
}
/// <summary>
/// Warn that a last-resort portable master key was auto-created. Not the recommended setup.
/// </summary>
internal static void AnnounceLastResortPortableMasterKey(string keyPath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(keyPath);
var fullKeyPath = Path.GetFullPath(keyPath);
var noticePath = Path.Combine(
Path.GetDirectoryName(fullKeyPath) ?? ".",
LastResortMasterKeyNoticeFileName);
var message =
"LAST-RESORT: Token encryption is enabled, but no OS secret store is available and no master key was supplied. "
+ $"Libation created a portable master key at '{fullKeyPath}'. "
+ "This is a compatibility fallback, not the recommended setup. "
+ "Prefer: set TokenStorageMethod to Plaintext in Settings, "
+ $"OR supply your own key (export from desktop and place '{DefaultMasterKeyFileName}' next to AccountsSettings.json, "
+ $"or set {MasterKeyFileEnvVar} / {MasterKeyEnvVar}). "
+ "Treat the key file like a password. It will not decrypt tokens encrypted under a different machine's key.";
try
{
AtomicFileWriter.WriteAllText(noticePath, message + Environment.NewLine);
}
catch (Exception ex)
{
Serilog.Log.Logger.Warning(ex, "Could not write last-resort master key notice file at {NoticePath}", noticePath);
}
Serilog.Log.Logger.Warning(message);
Console.Error.WriteLine(message);
}
/// <summary>Write a new raw 32-byte master key file (same format as <c>export-master-key</c>).</summary>
private static void MintRawMasterKeyFile(string keyPath)
{
var key = new byte[AesGcmSecretProtector.KeySizeBytes];
RandomNumberGenerator.Fill(key);
try
{
var directory = Path.GetDirectoryName(Path.GetFullPath(keyPath));
if (!string.IsNullOrEmpty(directory))
Directory.CreateDirectory(directory);
AtomicFileWriter.WriteAllBytes(keyPath, key);
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private static IOsSecretStore LoadMasterKeyFileOrUnavailable(string path)
{
var store = new MemoryOsSecretStore();
try
{
MasterKeyPortability.ImportFromFile(store, path);
return store;
}
catch (Exception ex)
{
return new UnavailablePortableSecretStore(
"Portable master key file",
$"Portable master key file is unusable ({path}): {SafeMessage(ex)}");
}
}
private static IOsSecretStore LoadMasterKeyBase64OrUnavailable(string base64)
{
byte[] key;
try
{
key = Convert.FromBase64String(base64);
}
catch (FormatException ex)
{
return new UnavailablePortableSecretStore(
"Portable master key env",
$"{MasterKeyEnvVar} is not valid Base64: {SafeMessage(ex)}");
}
try
{
if (key.Length != AesGcmSecretProtector.KeySizeBytes)
{
return new UnavailablePortableSecretStore(
"Portable master key env",
$"{MasterKeyEnvVar} must decode to {AesGcmSecretProtector.KeySizeBytes} bytes.");
}
var store = new MemoryOsSecretStore();
store.Set(AesGcmSecretProtector.DefaultMasterKeyName, key);
return store;
}
finally
{
CryptographicOperations.ZeroMemory(key);
}
}
private static string SafeMessage(Exception ex)
=> string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message;
private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
{
if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
return;
if (sender is not Configuration config)
return;
ConfigureFrom(config);
}
private sealed class UnavailablePortableSecretStore : IOsSecretStore
{
public UnavailablePortableSecretStore(string name, string reason)
{
Name = name;
UnavailableReason = reason;
}
public string Name { get; }
public bool IsAvailable => false;
public string? UnavailableReason { get; }
public void Set(string key, ReadOnlySpan<byte> value)
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
public bool TryGet(string key, out byte[] value)
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
public void Delete(string key)
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
}
}
@@ -0,0 +1,39 @@
using Dinah.Core.Security;
namespace AudibleUtilities;
/// <summary>
/// Export Libation's OS-bound AES-GCM master key for portable use (e.g. Docker).
/// Never creates a new key; the desktop OS store must already hold one.
/// </summary>
public static class MasterKeyExport
{
/// <summary>
/// Write the existing Libation master key as raw bytes to <paramref name="filePath"/>.
/// </summary>
/// <exception cref="InvalidOperationException">OS secret store unavailable or master key missing.</exception>
public static void ExportToFile(string filePath)
{
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason))
{
throw new InvalidOperationException(
"Cannot export the encryption master key because the OS secret store is unavailable."
+ (string.IsNullOrWhiteSpace(unavailableReason) ? "" : " " + unavailableReason));
}
var store = OsSecretStore.Create(IdentityTokenStorageWiring.ApplicationName);
try
{
MasterKeyPortability.ExportToFile(store, filePath);
}
catch (SecretProtectionException ex)
{
throw new InvalidOperationException(
"Cannot export the encryption master key because it was not found. "
+ "Encrypt tokens on this machine at least once (Settings -> Important, store tokens encrypted) so a key exists, then try again.",
ex);
}
}
}
@@ -0,0 +1,69 @@
using AudibleApi;
using System;
using System.Collections.Generic;
namespace AudibleUtilities;
/// <summary>
/// Libation-facing helpers when Audible returns HTML instead of JSON (library scan, catalog, etc.).
/// </summary>
public static class NonJsonResponseExceptionExtensions
{
public const string LibraryScanFailedCaption = "Library scan failed";
private static readonly string[] ThingsToTryBullets =
[
"Scan again after a few minutes.",
"Sign in to Audible in a browser on the same network.",
"Disable VPN or proxy and scan again.",
"Remove and re-add the account in Libation.",
"If it still fails, check the log for Full body: near the error and attach it to a bug report.",
];
public static IEnumerable<string> GetExplainerLines(this NonJsonResponseException ex)
{
ArgumentNullException.ThrowIfNull(ex);
yield return getIntro(ex);
yield return string.Empty;
yield return "Things to try:";
foreach (var bullet in ThingsToTryBullets)
yield return "• " + bullet;
}
public static string GetExplainerBody(this NonJsonResponseException ex)
=> string.Join("\r\n", ex.GetExplainerLines());
public static bool TryFindInTree(Exception ex, out NonJsonResponseException? match)
{
ArgumentNullException.ThrowIfNull(ex);
NonJsonResponseException? found = null;
walk(ex);
match = found;
return found is not null;
void walk(Exception? e)
{
if (e is null || found is not null)
return;
if (e is NonJsonResponseException nonJson)
found = nonJson;
if (found is not null)
return;
if (e is AggregateException agg)
{
foreach (var inner in agg.InnerExceptions)
walk(inner);
}
walk(e.InnerException);
}
}
private static string getIntro(NonJsonResponseException ex)
=> ex.HtmlTitle is null
? "Audible returned an HTML page instead of the expected library data."
: $"Audible returned an HTML page ({ex.HtmlTitle}) instead of the expected library data.";
}
+1 -1
View File
@@ -290,7 +290,7 @@ public partial class Cdm
private static uint RandomUint()
{
var bts = new byte[4];
new Random().NextBytes(bts);
RandomNumberGenerator.Fill(bts);
return BitConverter.ToUInt32(bts, 0);
}
}
+3 -3
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -11,8 +11,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
<PackageReference Include="Dinah.Core" Version="10.2.1.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.1.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
+13 -2
View File
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
using System;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
@@ -49,7 +50,15 @@ public class LibationContextFactory
{
try
{
context.Database.Migrate();
// Migrate() acquires the EF migration lock before checking whether anything is pending.
// For SQLite that lock is a row in __EFMigrationsLock with no timeout (SQLite has no
// connection-scoped lock that frees on disconnect). A process killed after acquiring the
// lock but before releasing it - even during an otherwise no-op Migrate() - orphans the row,
// and every later Migrate() then spins forever in SqliteHistoryRepository.AcquireDatabaseLock().
// The row is persisted in the db file, so restarting does not clear it. Skipping Migrate()
// when nothing is pending keeps the steady-state path off the lock entirely. See #1729.
if (context.Database.GetPendingMigrations().Any())
context.Database.Migrate();
}
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
@@ -63,7 +72,9 @@ public class LibationContextFactory
{
try
{
await context.Database.MigrateAsync(cancellationToken);
// See ApplyMigrations for why pending migrations are checked before acquiring the lock (#1729).
if ((await context.Database.GetPendingMigrationsAsync(cancellationToken)).Any())
await context.Database.MigrateAsync(cancellationToken);
}
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
@@ -33,7 +33,7 @@ public static class LibraryBookQueries
.AsEnumerable()
.ToList();
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
{
var libraryQuery
= context
@@ -41,8 +41,14 @@ public static class LibraryBookQueries
.AsNoTrackingWithIdentityResolution()
.GetLibrary();
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
var matches = caseSensative
? libraryQuery.Where(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.Where(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
if (account is not null)
matches = matches.Where(lb => lb.Account == account);
return matches.FirstOrDefault();
}
public List<LibraryBook> GetUnLiberated_Flat_NoTracking()
+3 -1
View File
@@ -26,7 +26,9 @@ public static class AudioFileStorageExt
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(
series.Series.AudibleSeriesId,
account: libraryBook.Account);
if (seriesParent is not null)
{
return Templates.Folder.GetFilename(seriesParent.ToDto(), books, "");
@@ -0,0 +1,30 @@
using DataLayer;
using Dinah.Core.ErrorHandling;
using LibationFileManager;
using System.Threading.Tasks;
namespace FileLiberator;
/// <summary>
/// Instantly fails processing so the bad-book error dialog can be tested without a real download failure.
/// </summary>
public class SimulateBadBookFailure : Processable, IProcessable<SimulateBadBookFailure>
{
public override string Name => "Simulate Bad Book Failure";
public override bool Validate(LibraryBook libraryBook) => true;
public override Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
var result = new StatusHandler();
result.AddError("Simulated processing failure for testing the bad-book error dialog.");
OnCompleted(libraryBook);
return Task.FromResult(result);
}
public static SimulateBadBookFailure Create(Configuration config)
=> new() { Configuration = config };
}
@@ -0,0 +1,128 @@
using ApplicationServices;
using DataLayer;
using Dinah.Core.ErrorHandling;
using FileManager;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
namespace FileLiberator;
public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudiobookshelf>
{
public override string Name => "Upload to Audiobookshelf";
public override bool Validate(LibraryBook libraryBook)
{
if (!Configuration.AudiobookshelfEnabled)
return false;
if (string.IsNullOrWhiteSpace(Configuration.AudiobookshelfServerUrl)
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfApiToken)
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfLibraryId)
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfFolderId))
return false;
return libraryBook.Book.AudioExists;
}
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
try
{
var files = GetFilesToUpload(libraryBook);
if (files.Count == 0)
{
OnStatusUpdate("No audio files found to upload");
return new StatusHandler();
}
OnStatusUpdate($"Uploading {files.Count} file(s) to Audiobookshelf...");
var title = libraryBook.Book.TitleWithSubtitle;
var author = libraryBook.Book.AuthorNames;
var series = libraryBook.Book.SeriesNames();
var result = await AudiobookshelfApiService.UploadBookAsync(
Configuration.AudiobookshelfServerUrl!,
Configuration.AudiobookshelfApiToken!,
Configuration.AudiobookshelfLibraryId!,
Configuration.AudiobookshelfFolderId!,
title,
author,
series,
files);
if (result == AudiobookshelfApiService.UploadResult.Success)
{
OnStatusUpdate("Upload to Audiobookshelf completed successfully");
return new StatusHandler();
}
else if (result == AudiobookshelfApiService.UploadResult.AlreadyExists)
{
OnStatusUpdate("Book already exists on Audiobookshelf; skipping upload");
Serilog.Log.Logger.Information("Book already exists on Audiobookshelf, skipping upload: {Book}", libraryBook.LogFriendly());
return new StatusHandler();
}
else
{
OnStatusUpdate("Upload to Audiobookshelf failed; book remains liberated on disk");
Serilog.Log.Logger.Error("Audiobookshelf upload failed for {Book}, but continuing as soft-failure", libraryBook.LogFriendly());
// Soft-fail: log the error but do not mark the book as failed
return new StatusHandler();
}
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf; continuing as soft-failure", libraryBook.LogFriendly());
OnStatusUpdate($"Audiobookshelf upload error: {ex.Message}");
// Soft-fail: log the error but do not mark the book as failed
return new StatusHandler();
}
finally
{
OnCompleted(libraryBook);
}
}
private static List<string> GetFilesToUpload(LibraryBook libraryBook)
{
var files = new List<string>();
var productId = libraryBook.Book.AudibleProductId;
// Get audio files from cache
var audioFiles = FilePathCache.GetFiles(productId)
.Where(f => f.fileType == FileType.Audio && File.Exists(f.path))
.Select(f => (string)f.path)
.Distinct()
.ToList();
files.AddRange(audioFiles);
// Use Libation's known cover art output path (same logic as DownloadDecryptBook.DownloadCoverArt)
if (audioFiles.FirstOrDefault() is { } firstAudioFile)
{
var dir = Path.GetDirectoryName(firstAudioFile);
if (dir is not null)
{
var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename(
libraryBook,
dir,
".jpg",
returnFirstExisting: false);
if (File.Exists(coverPath))
files.Add(coverPath);
}
}
return files;
}
public static UploadToAudiobookshelf Create(Configuration config) => new() { Configuration = config };
private UploadToAudiobookshelf() { }
}
@@ -0,0 +1,59 @@
using AudibleApi;
using LibationFileManager;
using System;
namespace FileLiberator;
/// <summary>
/// Detects a rare Audible API failure mode where ADRM licenserequest returns Sable error 000307
/// with acr:null. Some titles (e.g. B08XDZCH78 in the US marketplace) only deliver via Widevine.
/// Keep all detection here so this edge case does not spread through download or UI code.
/// </summary>
public static class WidevineRecommendation
{
/// <summary>
/// Audible error code seen when Sable cannot resolve asset details for ADRM licenserequest.
/// Not diagnostic on its own — also returned for unrelated API issues (e.g. empty response_groups).
/// </summary>
public const string SableAssetErrorCode = "000307";
/// <summary>
/// Substring in the Sable error message when no ADRM content reference exists for the title.
/// Together with <see cref="SableAssetErrorCode"/> on licenserequest, this is the best signal we have.
/// </summary>
public const string SableAcrNullMarker = "acr:null";
/// <summary>
/// True when <paramref name="ex"/> matches the rare ADRM licenserequest / Sable acr:null pattern.
/// </summary>
public static bool IsAdrmLicenseUnavailableError(ApiErrorException ex)
{
ArgumentNullException.ThrowIfNull(ex);
if (ex.RequestUri is not { } requestUri
|| !requestUri.Contains("/licenserequest", StringComparison.OrdinalIgnoreCase))
return false;
if (ex.JsonMessage is not { } json)
return false;
return json.Contains(SableAssetErrorCode, StringComparison.Ordinal)
&& json.Contains(SableAcrNullMarker, StringComparison.Ordinal);
}
/// <summary>
/// True when Libation should suggest enabling Widevine for this failure.
/// </summary>
public static bool ShouldRecommendWidevine(ApiErrorException ex, Configuration config)
{
ArgumentNullException.ThrowIfNull(ex);
ArgumentNullException.ThrowIfNull(config);
return !config.UseWidevine && IsAdrmLicenseUnavailableError(ex);
}
/// <summary>One line for logs and CLI when <see cref="ShouldRecommendWidevine"/> applies.</summary>
public static string BuildLogSummary(string bookTitleWithSubtitle)
=> $"{bookTitleWithSubtitle}: Audible returned no ADRM license (Sable acr:null). "
+ "Some rare titles only download with Widevine — try Settings > Audio File Options > Use Widevine DRM.";
}
+2 -2
View File
@@ -1,4 +1,4 @@
<Project Sdk="Microsoft.NET.Sdk">
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
@@ -6,7 +6,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Dinah.Core" Version="10.2.1.1" />
<PackageReference Include="Polly" Version="8.6.6" />
</ItemGroup>
+52 -8
View File
@@ -1,6 +1,5 @@
using Newtonsoft.Json.Linq;
using System;
using System.Linq;
namespace FileManager;
@@ -20,21 +19,66 @@ public interface IJsonBackedDictionary
string? GetStringFromJsonPath(string jsonPath, string propertyName)
=> GetStringFromJsonPath($"{jsonPath}.{propertyName}");
static T? UpCast<T>(object obj)
static T? UpCast<T>(object obj, string? propertyName = null)
{
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
if (obj is JObject jObject) return jObject.ToObject<T>();
if (obj is JValue jValue)
{
if (typeof(T).IsAssignableTo(typeof(Enum)))
{
return
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
? (T)enumVal
: Enum.GetValues(typeof(T)).Cast<T>().First();
}
return ParseEnum<T>(jValue, propertyName ?? typeof(T).Name);
return jValue.Value<T>();
}
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
}
private static T ParseEnum<T>(JValue jValue, string propertyPath)
{
var enumType = typeof(T);
if (TryGetEnumFromNumber(jValue, enumType, propertyPath, out var fromNumber))
return (T)fromNumber!;
var text = jValue.Type == JTokenType.String
? jValue.Value<string>()
: jValue.Value?.ToString();
if (text is not null
&& Enum.TryParse(enumType, text, ignoreCase: true, out var parsed)
&& parsed is not null
&& Enum.IsDefined(enumType, parsed))
{
return (T)parsed;
}
throw InvalidConfigurationValueException.ForEnum(propertyPath, text ?? jValue.ToString(), enumType);
}
private static bool TryGetEnumFromNumber(JValue jValue, Type enumType, string propertyPath, out object? value)
{
value = null;
if (jValue.Type is not (JTokenType.Integer or JTokenType.Float))
return false;
object? raw = jValue.Value;
if (raw is null)
return false;
object converted;
try
{
converted = Convert.ChangeType(raw, Enum.GetUnderlyingType(enumType));
}
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
{
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
}
if (!Enum.IsDefined(enumType, converted))
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
value = Enum.ToObject(enumType, converted);
return true;
}
}
@@ -0,0 +1,39 @@
using System;
namespace FileManager;
/// <summary>
/// Settings.json (or other JSON-backed config) contained a value that cannot be mapped to the expected type.
/// </summary>
public sealed class InvalidConfigurationValueException : Exception
{
public string PropertyPath { get; }
public string? InvalidValue { get; }
public Type? ExpectedType { get; }
public InvalidConfigurationValueException(string propertyPath, string? invalidValue, Type? expectedType, string message)
: base(message)
{
PropertyPath = propertyPath;
InvalidValue = invalidValue;
ExpectedType = expectedType;
}
public static InvalidConfigurationValueException ForEnum(string propertyPath, string? invalidValue, Type enumType)
{
var allowed = string.Join(", ", Enum.GetNames(enumType));
var display = FormatValue(invalidValue);
var message =
$"Invalid value for '{propertyPath}': {display}. " +
$"Expected one of: {allowed}.";
return new InvalidConfigurationValueException(propertyPath, invalidValue, enumType, message);
}
public static InvalidConfigurationValueException ForPath(string propertyPath, string? invalidValue, string message)
=> new(propertyPath, invalidValue, expectedType: null, message);
public static string FormatValue(string? value)
=> value is null ? "[null]"
: value.Length == 0 ? "[empty]"
: $"\"{value}\"";
}
+1 -1
View File
@@ -58,7 +58,7 @@ public class PersistentDictionary : IJsonBackedDictionary
objectCache[propertyName] = defaultValue;
return defaultValue;
}
return IJsonBackedDictionary.UpCast<T>(obj);
return IJsonBackedDictionary.UpCast<T>(obj, propertyName);
}
public object? GetObject(string propertyName)
@@ -73,7 +73,7 @@
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.14" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.1" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
</ItemGroup>
<ItemGroup>
@@ -0,0 +1,61 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Layout;
using Avalonia.Media;
using HangoverBase;
using System.Threading.Tasks;
namespace HangoverAvalonia;
internal static class HangoverMutationConfirm
{
public static async Task<bool> ConfirmAsync(Window owner, string actionDescription)
{
bool? result = null;
var yesButton = new Button { Content = "Yes", MinWidth = 75 };
var noButton = new Button { Content = "No", MinWidth = 75 };
var buttonPanel = new StackPanel
{
Orientation = Orientation.Horizontal,
HorizontalAlignment = HorizontalAlignment.Right,
Spacing = 10,
Margin = new Thickness(0, 15, 0, 0),
Children = { noButton, yesButton },
};
var dialog = new Window
{
Title = HangoverDbMutation.ConfirmTitle,
Width = 420,
SizeToContent = SizeToContent.Height,
WindowStartupLocation = WindowStartupLocation.CenterOwner,
CanResize = false,
Content = new Grid
{
RowDefinitions = new RowDefinitions("*,Auto"),
Margin = new Thickness(10),
Children =
{
new TextBlock
{
[Grid.RowProperty] = 0,
Text = HangoverDbMutation.BuildConfirmMessage(actionDescription),
TextWrapping = TextWrapping.Wrap,
},
buttonPanel,
},
},
};
Grid.SetRow(buttonPanel, 1);
yesButton.Click += (_, _) => { result = true; dialog.Close(); };
noButton.Click += (_, _) => { result = false; dialog.Close(); };
dialog.Closed += (_, _) => result ??= false;
await dialog.ShowDialog(owner);
return result == true;
}
}
@@ -1,5 +1,7 @@
using HangoverBase;
using ReactiveUI;
using System;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
@@ -10,26 +12,78 @@ public partial class MainVM
private string _databaseFileText;
private bool _databaseFound;
private string _sqlResults;
private string _duplicateResults;
private string _duplicateAsinStatusText;
private bool _canRemoveDuplicateAsins;
private bool _confirmRemoveDuplicateAsins;
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
public string SqlQuery { get; set; }
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
public string DuplicateResults { get => _duplicateResults; set => this.RaiseAndSetIfChanged(ref _duplicateResults, value); }
public string DuplicateAsinStatusText { get => _duplicateAsinStatusText; set => this.RaiseAndSetIfChanged(ref _duplicateAsinStatusText, value); }
public bool CanRemoveDuplicateAsins { get => _canRemoveDuplicateAsins; set => this.RaiseAndSetIfChanged(ref _canRemoveDuplicateAsins, value); }
public bool ConfirmRemoveDuplicateAsins
{
get => _confirmRemoveDuplicateAsins;
set => this.RaiseAndSetIfChanged(ref _confirmRemoveDuplicateAsins, value);
}
private void Load_databaseVM()
{
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s));
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s, s => DuplicateResults = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)
{
DatabaseFileText = $"Database file not found";
DuplicateAsinStatusText = "Duplicate ASIN cleanup unavailable (database not found).";
DatabaseFound = false;
CanRemoveDuplicateAsins = false;
return;
}
DatabaseFileText = $"Database file: {_tab.DbFile}";
DatabaseFound = true;
RefreshDuplicateAsinStatus();
}
public void ExecuteQuery() => _tab.ExecuteQuery();
public async Task ExecuteQueryAsync()
{
if (HangoverDbMutation.IsMutatingSql(SqlQuery)
&& ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverDbMutation.SqlMutatingDescription))
return;
_tab.ExecuteQuery();
}
public void RefreshDuplicateAsinStatus()
{
DuplicateAsinStatusText = _tab.GetDuplicateAsinStatusText();
CanRemoveDuplicateAsins = _tab.CanRemoveDuplicateAsins();
if (!CanRemoveDuplicateAsins)
ConfirmRemoveDuplicateAsins = false;
}
public void ScanDuplicateAsins()
{
_tab.ScanDuplicateAsins();
RefreshDuplicateAsinStatus();
}
public async Task RemoveDuplicateAsinsAsync()
{
if (!ConfirmRemoveDuplicateAsins)
return;
if (ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverDbMutation.RemoveDuplicateAsinsDescription))
return;
_tab.RemoveDuplicateAsins();
ConfirmRemoveDuplicateAsins = false;
RefreshDuplicateAsinStatus();
}
}
@@ -1,8 +1,16 @@
namespace HangoverAvalonia.ViewModels;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
public partial class MainVM
{
public TrashBinViewModel TrashBinViewModel { get; } = new();
private void Load_deletedVM() { }
private void Load_deletedVM()
{
TrashBinViewModel.ConfirmDbMutationAsync = action
=> ConfirmDbMutationAsync is { } confirm
? confirm(action)
: Task.FromResult(true);
}
}
@@ -1,7 +1,12 @@
using System;
using System.Threading.Tasks;
namespace HangoverAvalonia.ViewModels;
public partial class MainVM : ViewModelBase
{
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
public MainVM()
{
Load_databaseVM();
@@ -77,8 +77,17 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
item.IsChecked = false;
}
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
public async Task RestoreCheckedAsync()
{
if (!CheckedBooks.Any())
return;
if (ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
return;
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
if (qtyChanges > 0)
@@ -88,6 +97,13 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public async Task PermanentlyDeleteCheckedAsync()
{
if (!CheckedBooks.Any())
return;
if (ConfirmDbMutationAsync is not null
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.PermanentlyDeleteBooksDescription))
return;
ControlsEnabled = false;
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
if (qtyChanges > 0)
@@ -1,10 +0,0 @@
namespace HangoverAvalonia.Views;
public partial class MainWindow
{
private void cliTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
}
}
@@ -2,14 +2,6 @@
public partial class MainWindow
{
private void databaseTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
}
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_viewModel.ExecuteQuery();
}
public async void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.ExecuteQueryAsync();
}
@@ -0,0 +1,18 @@
namespace HangoverAvalonia.Views;
public partial class MainWindow
{
private void fixDuplicatesTab_VisibleChanged(bool isVisible)
{
if (!isVisible)
return;
_viewModel.RefreshDuplicateAsinStatus();
}
public void ScanDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> _viewModel.ScanDuplicateAsins();
public async void RemoveDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await _viewModel.RemoveDuplicateAsinsAsync();
}
+228 -6
View File
@@ -1,140 +1,362 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="using:HangoverAvalonia.ViewModels"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
xmlns:vms="clr-namespace:HangoverAvalonia.ViewModels"
x:DataType="vms:MainVM"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
Width="800" Height="500"
x:Class="HangoverAvalonia.Views.MainWindow"
Icon="/Assets/hangover.ico "
Title="Hangover: Libation debug and recovery tool">
<Design.DataContext>
<vm:MainVM/>
</Design.DataContext>
<TabControl Name="tabControl1" Grid.Row="0">
<TabControl.Styles>
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
<Setter Property="Height" Value="33"/>
</Style>
<Style Selector="TabItem#Header TextBlock">
<Setter Property="MinHeight" Value="5"/>
</Style>
<Style Selector="Button">
<Setter Property="Padding" Value="20,5,20,5"/>
</Style>
</TabControl.Styles>
<!-- Database Tab -->
<TabItem Name="databaseTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
</TabItem.Header>
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
<TextBlock
Margin="0,10,0,5"
Grid.Row="0"
Text="{Binding DatabaseFileText}" />
<TextBlock
Margin="0,5,0,5"
Grid.Row="1"
Text="SQL (database command)" />
<TextBox
Margin="0,5,0,5"
AcceptsReturn="True"
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
<Button
Grid.Row="3"
Content="Execute"
IsEnabled="{Binding DatabaseFound}"
Click="Execute_Click" />
<TextBox
Margin="0,5,0,10"
IsReadOnly="True"
Grid.Row="4"
Text="{Binding SqlResults}" />
</Grid>
</TabItem>
<!-- Deleted Books Tab -->
<TabItem Name="deletedTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
</TabItem.Header>
<Grid
DataContext="{Binding TrashBinViewModel}"
RowDefinitions="Auto,*,Auto">
<TextBlock
Grid.Row="0"
Margin="5"
Text="Check books you want to permanently delete from or restore to Libation" />
<controls:CheckedListBox
Grid.Row="1"
Margin="5,0,5,0"
BorderThickness="1"
BorderBrush="Gray"
IsEnabled="{Binding ControlsEnabled}"
Items="{Binding DeletedBooks}" />
<Grid
Grid.Row="2"
Margin="5"
ColumnDefinitions="Auto,Auto,*,Auto">
<CheckBox
IsEnabled="{Binding ControlsEnabled}"
IsThreeState="True"
Margin="0,0,20,0"
IsChecked="{Binding EverythingChecked}"
Content="Everything" />
<TextBlock
Grid.Column="1"
VerticalAlignment="Center"
Text="{Binding CheckedCountText}" />
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="2"
Margin="0,0,20,0"
HorizontalAlignment="Right"
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Restore"
Command="{Binding RestoreCheckedAsync}"/>
<Button
IsEnabled="{Binding ControlsEnabled}"
Grid.Column="3"
Command="{Binding PermanentlyDeleteCheckedAsync}" >
<TextBlock
TextAlignment="Center"
Text="Permanently Delete&#xa;from Libation" />
</Button>
</Grid>
</Grid>
</TabItem>
<!-- Command Line Interface Tab -->
<TabItem Name="cliTab">
<!-- Fix Duplicates Tab -->
<TabItem Name="fixDuplicatesTab">
<TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
</TabItem.Header>
<TextBlock FontSize="14" VerticalAlignment="Center">Fix Duplicates</TextBlock>
</TabItem.Header>
<Grid RowDefinitions="Auto,Auto,*">
<TextBlock
Margin="0,10,0,10"
Grid.Row="0"
Text="{Binding DuplicateAsinStatusText}"
TextWrapping="Wrap" />
<Grid
Grid.Row="1"
ColumnDefinitions="Auto,Auto,*">
<Button
Grid.Column="0"
Margin="0,0,10,0"
Content="Scan Duplicate ASINs"
IsEnabled="{Binding DatabaseFound}"
Click="ScanDuplicateAsins_Click" />
<CheckBox
Grid.Column="1"
Margin="0,0,10,0"
VerticalAlignment="Center"
IsEnabled="{Binding CanRemoveDuplicateAsins}"
IsChecked="{Binding ConfirmRemoveDuplicateAsins}"
Content="Confirm removal" />
<Button
Grid.Column="2"
HorizontalAlignment="Left"
Content="Remove Duplicates"
IsEnabled="{Binding ConfirmRemoveDuplicateAsins}"
Click="RemoveDuplicateAsins_Click" />
</Grid>
<TextBox
Margin="0,10,0,10"
AcceptsReturn="True"
IsReadOnly="True"
Grid.Row="2"
Text="{Binding DuplicateResults}"
TextWrapping="Wrap" />
</Grid>
</TabItem>
</TabControl>
</Window>
@@ -18,8 +18,9 @@ public partial class MainWindow : Window
public void OnLoad()
{
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
_viewModel.ConfirmDbMutationAsync = action => HangoverMutationConfirm.ConfirmAsync(this, action);
fixDuplicatesTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) fixDuplicatesTab_VisibleChanged(fixDuplicatesTab.IsSelected); };
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
}
}
+41 -1
View File
@@ -11,16 +11,19 @@ public class DatabaseTabCommands
public Func<string> SqlInput { get; }
public Action<string> SqlOutputAppend { get; }
public Action<string> SqlOutputOverwrite { get; }
public Action<string> DuplicateOutputOverwrite { get; }
public DatabaseTabCommands() { }
public DatabaseTabCommands(
Func<string> sqlInput,
Action<string> sqlDisplayAppend,
Action<string> sqlDisplayOverwrite)
Action<string> sqlDisplayOverwrite,
Action<string> duplicateOutputOverwrite)
{
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
DuplicateOutputOverwrite = ArgumentValidator.EnsureNotNull(duplicateOutputOverwrite, nameof(duplicateOutputOverwrite));
}
}
@@ -36,6 +39,7 @@ public class DatabaseTab
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.DuplicateOutputOverwrite));
}
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
@@ -151,4 +155,40 @@ public class DatabaseTab
if (results != 1) _commands.SqlOutputAppend("s");
_commands.SqlOutputAppend(" affected");
}
public string GetDuplicateAsinStatusText()
{
if (DuplicateAsinCleanup.IsCompleted())
return "Duplicate ASIN cleanup: completed (one-time migration already run).";
var scan = DuplicateAsinCleanup.Scan();
return scan.DuplicateGroupCount == 0
? "Duplicate ASIN cleanup: no duplicates found."
: $"Duplicate ASIN cleanup: {scan.DuplicateGroupCount} duplicate ASIN group(s) found. Scan for details, then remove.";
}
public bool CanRemoveDuplicateAsins()
=> !DuplicateAsinCleanup.IsCompleted() && DuplicateAsinCleanup.Scan().DuplicateGroupCount > 0;
public void ScanDuplicateAsins()
{
var scan = DuplicateAsinCleanup.Scan();
_commands.DuplicateOutputOverwrite(scan.Report);
}
public string RemoveDuplicateAsins()
{
EnsureBackup();
try
{
var result = DuplicateAsinCleanup.Execute();
_commands.DuplicateOutputOverwrite(result.Report);
return result.Report;
}
finally
{
DeleteUnneededBackups();
}
}
}
+214
View File
@@ -0,0 +1,214 @@
using ApplicationServices;
using AppScaffolding;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using System.Text;
namespace HangoverBase;
/// <summary>
/// One-time utility to find and remove duplicate <see cref="Book"/> + <see cref="LibraryBook"/> rows
/// that share the same <see cref="Book.AudibleProductId"/>.
/// </summary>
public static class DuplicateAsinCleanup
{
public const string SettingsKey = "HangoverDuplicateAsinCleanupCompleted";
public static bool IsCompleted()
=> UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out var value) && value == "true";
public static DuplicateAsinScanResult Scan()
{
using var context = DbContexts.GetContext();
var groups = GetDuplicateGroups(context);
return new DuplicateAsinScanResult(groups.Count, BuildReport(groups, previewOnly: true));
}
public static DuplicateAsinCleanupResult Execute()
{
if (IsCompleted())
return new DuplicateAsinCleanupResult(0, 0, "Duplicate ASIN cleanup has already been completed for this library.");
using var context = DbContexts.GetContext();
var groups = GetDuplicateGroups(context);
if (groups.Count == 0)
{
MarkCompleted();
return new DuplicateAsinCleanupResult(0, 0, "No duplicate ASIN rows found. Nothing to do.");
}
var removedBooks = 0;
var removedLibraryBooks = 0;
foreach (var group in groups)
{
var keeper = SelectKeeper(group);
var duplicates = group.Where(lb => !ReferenceEquals(lb, keeper)).ToList();
foreach (var duplicate in duplicates)
{
MergeIntoKeeper(context, keeper, duplicate);
context.LibraryBooks.Remove(duplicate);
context.Books.Remove(duplicate.Book);
removedLibraryBooks++;
removedBooks++;
}
}
var changes = context.SaveChanges();
MarkCompleted();
var summary = new StringBuilder();
summary.AppendLine($"Removed {removedBooks} duplicate Book row(s) and {removedLibraryBooks} LibraryBook row(s).");
summary.AppendLine($"SaveChanges reported {changes} database change(s).");
summary.AppendLine();
summary.Append(BuildReport(groups, previewOnly: false));
return new DuplicateAsinCleanupResult(groups.Count, removedBooks, summary.ToString());
}
private static List<List<LibraryBook>> GetDuplicateGroups(LibationContext context)
{
var libraryBooks = context.LibraryBooks
.Include(lb => lb.Book).ThenInclude(b => b.UserDefinedItem)
.Include(lb => lb.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
.ToList();
return libraryBooks
.GroupBy(lb => lb.Book.AudibleProductId, StringComparer.OrdinalIgnoreCase)
.Where(g => g.Count() > 1)
.Select(g => g.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).ToList())
.ToList();
}
private static LibraryBook SelectKeeper(List<LibraryBook> group)
=> group.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).First();
private static int Score(LibraryBook libraryBook)
{
var book = libraryBook.Book;
var udi = book.UserDefinedItem;
var score = 0;
if (!libraryBook.IsDeleted)
score += 1000;
score += bookStatusRank(udi.BookStatus) * 10;
score += bookStatusRank(udi.PdfStatus ?? LiberatedStatus.NotLiberated);
score += book.ContentType switch
{
ContentType.Parent => 15,
ContentType.Episode => 15,
ContentType.Product => 10,
_ => 0,
};
if (book.SeriesLink.Any())
score += 5;
if (!string.IsNullOrWhiteSpace(book.PictureId))
score += 2;
return score;
}
private static int bookStatusRank(LiberatedStatus status) => status switch
{
LiberatedStatus.Liberated => 10,
LiberatedStatus.PartialDownload => 8,
LiberatedStatus.NotLiberated => 3,
LiberatedStatus.Error => 1,
_ => 0,
};
private static void MergeIntoKeeper(LibationContext context, LibraryBook keeper, LibraryBook duplicate)
{
var keeperBook = keeper.Book;
var duplicateBook = duplicate.Book;
var keeperUdi = keeperBook.UserDefinedItem;
var duplicateUdi = duplicateBook.UserDefinedItem;
if (bookStatusRank(duplicateUdi.BookStatus) > bookStatusRank(keeperUdi.BookStatus))
keeperUdi.BookStatus = duplicateUdi.BookStatus;
if (duplicateUdi.PdfStatus is LiberatedStatus duplicatePdf
&& bookStatusRank(duplicatePdf) > bookStatusRank(keeperUdi.PdfStatus ?? LiberatedStatus.NotLiberated))
keeperUdi.SetPdfStatus(duplicatePdf);
if (!string.IsNullOrWhiteSpace(duplicateUdi.Tags))
{
var mergedTags = keeperUdi.TagsEnumerated
.Concat(duplicateUdi.TagsEnumerated)
.Distinct(StringComparer.OrdinalIgnoreCase);
keeperUdi.Tags = string.Join(" ", mergedTags);
}
if (duplicateUdi.LastDownloaded is DateTime duplicateLast
&& (keeperUdi.LastDownloaded is null || duplicateLast > keeperUdi.LastDownloaded))
keeperUdi.SetLastDownloaded(duplicateUdi.LastDownloadedVersion, duplicateUdi.LastDownloadedFormat, duplicateUdi.LastDownloadedFileVersion);
if (duplicateUdi.Rating.OverallRating > keeperUdi.Rating.OverallRating)
keeperUdi.UpdateRating(duplicateUdi.Rating.OverallRating, duplicateUdi.Rating.PerformanceRating, duplicateUdi.Rating.StoryRating);
foreach (var seriesBook in duplicateBook.SeriesLink)
{
if (keeperBook.SeriesLink.Any(sb => sb.Series.AudibleSeriesId == seriesBook.Series.AudibleSeriesId))
continue;
keeperBook.UpsertSeries(seriesBook.Series, seriesBook.Order, context);
}
}
private static string BuildReport(List<List<LibraryBook>> groups, bool previewOnly)
{
var builder = new StringBuilder();
if (groups.Count == 0)
{
builder.Append("No duplicate ASIN rows found.");
return builder.ToString();
}
var duplicateRowCount = groups.Sum(g => g.Count - 1);
builder.Append(previewOnly
? $"Found {groups.Count} duplicate ASIN group(s) ({duplicateRowCount} extra row(s) would be removed)."
: $"Processed {groups.Count} duplicate ASIN group(s).");
builder.AppendLine();
builder.AppendLine();
foreach (var group in groups.OrderBy(g => g[0].Book.AudibleProductId, StringComparer.OrdinalIgnoreCase))
{
var keeper = SelectKeeper(group);
var asin = keeper.Book.AudibleProductId;
var title = keeper.Book.TitleWithSubtitle;
var removing = group.Where(lb => !ReferenceEquals(lb, keeper))
.Select(lb => $"{lb.Book.AudibleProductId} ({lb.DateAdded:d}, {lb.Account})")
.ToList();
builder.Append(asin);
builder.Append(" | ");
builder.Append(title);
builder.AppendLine();
builder.Append(" Keep ");
builder.Append($"{keeper.DateAdded:d}, {keeper.Account}");
builder.Append(previewOnly ? "; would remove " : "; removed ");
builder.AppendLine(string.Join("; ", removing));
}
return builder.ToString().TrimEnd();
}
private static void MarkCompleted()
{
if (UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out _))
UNSAFE_MigrationHelper.Settings_Update(SettingsKey, "true");
else
UNSAFE_MigrationHelper.Settings_Insert(SettingsKey, "true");
}
}
public readonly record struct DuplicateAsinScanResult(int DuplicateGroupCount, string Report);
public readonly record struct DuplicateAsinCleanupResult(int DuplicateGroupCount, int RemovedBookCount, string Report);
+82
View File
@@ -0,0 +1,82 @@
using System.Diagnostics;
using System.Text;
namespace HangoverBase;
public static class HangoverDbMutation
{
public const string ConfirmTitle = "Modify Database";
public static string RemoveDuplicateAsinsDescription { get; }
= "This one-time cleanup will back up the database, merge useful data into the kept row, and permanently delete duplicate Book + LibraryBook pairs.";
public static string SqlMutatingDescription { get; }
= "This will run a SQL command that modifies the database.";
public static string RestoreDeletedBooksDescription { get; }
= "This will restore the selected book(s) to your Libation library.";
public static string PermanentlyDeleteBooksDescription { get; }
= "This will permanently delete the selected book(s) from Libation.";
public static bool IsLibationRunning()
{
try
{
var currentPid = Environment.ProcessId;
foreach (var process in Process.GetProcessesByName("Libation"))
{
try
{
if (process.Id != currentPid && !process.HasExited)
return true;
}
finally
{
process.Dispose();
}
}
}
catch
{
// Process enumeration can fail on some platforms; treat as unknown.
}
return false;
}
public static bool IsMutatingSql(string sql)
{
if (string.IsNullOrWhiteSpace(sql))
return false;
var trimmed = sql.Trim();
while (trimmed.StartsWith("--", StringComparison.Ordinal))
{
var lineEnd = trimmed.IndexOf('\n');
if (lineEnd < 0)
return false;
trimmed = trimmed[(lineEnd + 1)..].TrimStart();
}
var lower = trimmed.ToLowerInvariant();
return lower.StartsWith("update")
|| lower.StartsWith("insert")
|| lower.StartsWith("delete");
}
public static string BuildConfirmMessage(string actionDescription)
{
var builder = new StringBuilder();
builder.AppendLine(actionDescription);
builder.AppendLine();
if (IsLibationRunning())
builder.AppendLine("Libation is currently running.");
builder.AppendLine("Close Libation before continuing to avoid database conflicts and a stale library display.");
builder.AppendLine();
builder.Append("Proceed?");
return builder.ToString().TrimEnd();
}
}
-15
View File
@@ -1,15 +0,0 @@
namespace HangoverWinForms;
public partial class Form1
{
private void Load_cliTab()
{
}
private void cliTab_VisibleChanged(object sender, EventArgs e)
{
if (!cliTab.Visible)
return;
}
}
+33 -4
View File
@@ -1,30 +1,59 @@
using HangoverBase;
namespace HangoverWinForms;
public partial class Form1
{
private DatabaseTab _tab;
private void Load_databaseTab()
{
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s, s => duplicateResultsTb.Text = s));
_tab.LoadDatabaseFile();
if (_tab.DbFile is null)
{
databaseFileLbl.Text = $"Database file not found";
Load_fixDuplicatesTab();
return;
}
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
Load_fixDuplicatesTab();
}
private void databaseTab_VisibleChanged(object sender, EventArgs e)
private void sqlExecuteBtn_Click(object sender, EventArgs e)
{
if (!databaseTab.Visible)
if (HangoverBase.HangoverDbMutation.IsMutatingSql(sqlTb.Text)
&& !HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.SqlMutatingDescription))
return;
_tab.ExecuteQuery();
}
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
}
+6
View File
@@ -42,6 +42,12 @@ public partial class Form1
private async void saveBtn_Click(object sender, EventArgs e)
{
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
if (libraryBooksToRestore.Count == 0)
return;
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
return;
saveBtn.Enabled = false;
var qtyChanges = await libraryBooksToRestore.RestoreBooksAsync();
if (qtyChanges > 0)
+461 -89
View File
@@ -1,256 +1,628 @@
namespace HangoverWinForms
{
partial class Form1
{
/// <summary>
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// Required designer variable.
/// </summary>
private System.ComponentModel.IContainer components = null;
/// <summary>
/// Clean up any resources being used.
/// </summary>
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
protected override void Dispose(bool disposing)
{
if (disposing && (components != null))
{
components.Dispose();
}
base.Dispose(disposing);
}
#region Windows Form Designer generated code
/// <summary>
/// Required method for Designer support - do not modify
/// the contents of this method with the code editor.
/// </summary>
private void InitializeComponent()
{
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
this.tabControl1 = new System.Windows.Forms.TabControl();
this.databaseTab = new System.Windows.Forms.TabPage();
this.sqlExecuteBtn = new System.Windows.Forms.Button();
this.sqlResultsTb = new System.Windows.Forms.TextBox();
this.sqlTb = new System.Windows.Forms.TextBox();
this.sqlLbl = new System.Windows.Forms.Label();
this.databaseFileLbl = new System.Windows.Forms.Label();
this.fixDuplicatesTab = new System.Windows.Forms.TabPage();
this.removeDuplicateAsinsBtn = new System.Windows.Forms.Button();
this.scanDuplicateAsinsBtn = new System.Windows.Forms.Button();
this.duplicateAsinStatusLbl = new System.Windows.Forms.Label();
this.duplicateResultsTb = new System.Windows.Forms.TextBox();
this.deletedTab = new System.Windows.Forms.TabPage();
this.deletedCheckedLbl = new System.Windows.Forms.Label();
this.label1 = new System.Windows.Forms.Label();
this.saveBtn = new System.Windows.Forms.Button();
this.uncheckAllBtn = new System.Windows.Forms.Button();
this.checkAllBtn = new System.Windows.Forms.Button();
this.deletedCbl = new System.Windows.Forms.CheckedListBox();
this.cliTab = new System.Windows.Forms.TabPage();
this.tabControl1.SuspendLayout();
this.databaseTab.SuspendLayout();
this.fixDuplicatesTab.SuspendLayout();
this.deletedTab.SuspendLayout();
this.SuspendLayout();
//
// tabControl1
//
this.tabControl1.Controls.Add(this.databaseTab);
this.tabControl1.Controls.Add(this.deletedTab);
this.tabControl1.Controls.Add(this.cliTab);
this.tabControl1.Controls.Add(this.fixDuplicatesTab);
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
this.tabControl1.Location = new System.Drawing.Point(0, 0);
this.tabControl1.Name = "tabControl1";
this.tabControl1.SelectedIndex = 0;
this.tabControl1.Size = new System.Drawing.Size(800, 450);
this.tabControl1.TabIndex = 0;
//
// databaseTab
//
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
this.databaseTab.Controls.Add(this.sqlResultsTb);
this.databaseTab.Controls.Add(this.sqlTb);
this.databaseTab.Controls.Add(this.sqlLbl);
this.databaseTab.Controls.Add(this.databaseFileLbl);
this.databaseTab.Location = new System.Drawing.Point(4, 24);
this.databaseTab.Name = "databaseTab";
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
this.databaseTab.Size = new System.Drawing.Size(792, 422);
this.databaseTab.TabIndex = 0;
this.databaseTab.Text = "Database";
this.databaseTab.UseVisualStyleBackColor = true;
//
// sqlExecuteBtn
//
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 165);
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
this.sqlExecuteBtn.TabIndex = 3;
this.sqlExecuteBtn.Text = "Execute";
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
//
// sqlResultsTb
//
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
this.sqlResultsTb.Location = new System.Drawing.Point(8, 194);
this.sqlResultsTb.Multiline = true;
this.sqlResultsTb.Name = "sqlResultsTb";
this.sqlResultsTb.ReadOnly = true;
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
this.sqlResultsTb.Size = new System.Drawing.Size(776, 222);
this.sqlResultsTb.TabIndex = 4;
//
// sqlTb
//
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.sqlTb.Location = new System.Drawing.Point(8, 48);
this.sqlTb.Location = new System.Drawing.Point(8, 40);
this.sqlTb.Multiline = true;
this.sqlTb.Name = "sqlTb";
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.sqlTb.Size = new System.Drawing.Size(778, 99);
this.sqlTb.Size = new System.Drawing.Size(778, 119);
this.sqlTb.TabIndex = 2;
//
// sqlLbl
//
this.sqlLbl.AutoSize = true;
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
this.sqlLbl.Location = new System.Drawing.Point(6, 22);
this.sqlLbl.Name = "sqlLbl";
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
this.sqlLbl.TabIndex = 1;
this.sqlLbl.Text = "SQL (database command)";
//
// databaseFileLbl
//
this.databaseFileLbl.AutoSize = true;
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
this.databaseFileLbl.Name = "databaseFileLbl";
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
this.databaseFileLbl.TabIndex = 0;
this.databaseFileLbl.Text = "Database file: ";
//
// deletedTab
// fixDuplicatesTab
//
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
this.deletedTab.Controls.Add(this.label1);
this.deletedTab.Controls.Add(this.saveBtn);
this.deletedTab.Controls.Add(this.uncheckAllBtn);
this.deletedTab.Controls.Add(this.checkAllBtn);
this.deletedTab.Controls.Add(this.deletedCbl);
this.deletedTab.Location = new System.Drawing.Point(4, 24);
this.deletedTab.Name = "deletedTab";
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
this.deletedTab.Size = new System.Drawing.Size(792, 422);
this.deletedTab.TabIndex = 2;
this.deletedTab.Text = "Deleted Books";
this.deletedTab.UseVisualStyleBackColor = true;
this.fixDuplicatesTab.Controls.Add(this.duplicateResultsTb);
this.fixDuplicatesTab.Controls.Add(this.removeDuplicateAsinsBtn);
this.fixDuplicatesTab.Controls.Add(this.scanDuplicateAsinsBtn);
this.fixDuplicatesTab.Controls.Add(this.duplicateAsinStatusLbl);
this.fixDuplicatesTab.Location = new System.Drawing.Point(4, 24);
this.fixDuplicatesTab.Name = "fixDuplicatesTab";
this.fixDuplicatesTab.Padding = new System.Windows.Forms.Padding(3);
this.fixDuplicatesTab.Size = new System.Drawing.Size(792, 422);
this.fixDuplicatesTab.TabIndex = 2;
this.fixDuplicatesTab.Text = "Fix Duplicates";
this.fixDuplicatesTab.UseVisualStyleBackColor = true;
//
// deletedCheckedLbl
// removeDuplicateAsinsBtn
//
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.deletedCheckedLbl.AutoSize = true;
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
this.deletedCheckedLbl.TabIndex = 6;
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
this.removeDuplicateAsinsBtn.Location = new System.Drawing.Point(170, 48);
this.removeDuplicateAsinsBtn.Name = "removeDuplicateAsinsBtn";
this.removeDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
this.removeDuplicateAsinsBtn.TabIndex = 2;
this.removeDuplicateAsinsBtn.Text = "Remove Duplicates";
this.removeDuplicateAsinsBtn.UseVisualStyleBackColor = true;
this.removeDuplicateAsinsBtn.Click += new System.EventHandler(this.removeDuplicateAsinsBtn_Click);
//
// label1
// scanDuplicateAsinsBtn
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(8, 3);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(239, 15);
this.label1.TabIndex = 0;
this.label1.Text = "To restore deleted book, check box and save";
this.scanDuplicateAsinsBtn.Location = new System.Drawing.Point(8, 48);
this.scanDuplicateAsinsBtn.Name = "scanDuplicateAsinsBtn";
this.scanDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
this.scanDuplicateAsinsBtn.TabIndex = 1;
this.scanDuplicateAsinsBtn.Text = "Scan Duplicate ASINs";
this.scanDuplicateAsinsBtn.UseVisualStyleBackColor = true;
this.scanDuplicateAsinsBtn.Click += new System.EventHandler(this.scanDuplicateAsinsBtn_Click);
//
// saveBtn
// duplicateAsinStatusLbl
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(709, 391);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.TabIndex = 5;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
//
// uncheckAllBtn
//
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
this.uncheckAllBtn.Name = "uncheckAllBtn";
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
this.uncheckAllBtn.TabIndex = 4;
this.uncheckAllBtn.Text = "Uncheck All";
this.uncheckAllBtn.UseVisualStyleBackColor = true;
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
//
// checkAllBtn
//
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
this.checkAllBtn.Name = "checkAllBtn";
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
this.checkAllBtn.TabIndex = 3;
this.checkAllBtn.Text = "Check All";
this.checkAllBtn.UseVisualStyleBackColor = true;
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
//
// deletedCbl
//
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
this.duplicateAsinStatusLbl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.duplicateAsinStatusLbl.Location = new System.Drawing.Point(6, 18);
this.duplicateAsinStatusLbl.Name = "duplicateAsinStatusLbl";
this.duplicateAsinStatusLbl.Size = new System.Drawing.Size(778, 27);
this.duplicateAsinStatusLbl.TabIndex = 0;
this.duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup";
//
// duplicateResultsTb
//
this.duplicateResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.duplicateResultsTb.Location = new System.Drawing.Point(8, 77);
this.duplicateResultsTb.Multiline = true;
this.duplicateResultsTb.Name = "duplicateResultsTb";
this.duplicateResultsTb.ReadOnly = true;
this.duplicateResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
this.duplicateResultsTb.Size = new System.Drawing.Size(776, 339);
this.duplicateResultsTb.TabIndex = 3;
//
// deletedTab
//
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
this.deletedTab.Controls.Add(this.label1);
this.deletedTab.Controls.Add(this.saveBtn);
this.deletedTab.Controls.Add(this.uncheckAllBtn);
this.deletedTab.Controls.Add(this.checkAllBtn);
this.deletedTab.Controls.Add(this.deletedCbl);
this.deletedTab.Location = new System.Drawing.Point(4, 24);
this.deletedTab.Name = "deletedTab";
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
this.deletedTab.Size = new System.Drawing.Size(792, 422);
this.deletedTab.TabIndex = 1;
this.deletedTab.Text = "Deleted Books";
this.deletedTab.UseVisualStyleBackColor = true;
//
// deletedCheckedLbl
//
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.deletedCheckedLbl.AutoSize = true;
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
this.deletedCheckedLbl.TabIndex = 6;
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
//
// label1
//
this.label1.AutoSize = true;
this.label1.Location = new System.Drawing.Point(8, 3);
this.label1.Name = "label1";
this.label1.Size = new System.Drawing.Size(239, 15);
this.label1.TabIndex = 0;
this.label1.Text = "To restore deleted book, check box and save";
//
// saveBtn
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(709, 391);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(75, 23);
this.saveBtn.TabIndex = 5;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
//
// uncheckAllBtn
//
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
this.uncheckAllBtn.Name = "uncheckAllBtn";
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
this.uncheckAllBtn.TabIndex = 4;
this.uncheckAllBtn.Text = "Uncheck All";
this.uncheckAllBtn.UseVisualStyleBackColor = true;
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
//
// checkAllBtn
//
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
this.checkAllBtn.Name = "checkAllBtn";
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
this.checkAllBtn.TabIndex = 3;
this.checkAllBtn.Text = "Check All";
this.checkAllBtn.UseVisualStyleBackColor = true;
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
//
// deletedCbl
//
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.deletedCbl.FormattingEnabled = true;
this.deletedCbl.Location = new System.Drawing.Point(8, 21);
this.deletedCbl.Name = "deletedCbl";
this.deletedCbl.Size = new System.Drawing.Size(776, 364);
this.deletedCbl.TabIndex = 2;
this.deletedCbl.ItemCheck += new System.Windows.Forms.ItemCheckEventHandler(this.deletedCbl_ItemCheck);
//
// cliTab
//
this.cliTab.Location = new System.Drawing.Point(4, 24);
this.cliTab.Name = "cliTab";
this.cliTab.Size = new System.Drawing.Size(792, 422);
this.cliTab.TabIndex = 1;
this.cliTab.Text = "Command Line Interface";
this.cliTab.UseVisualStyleBackColor = true;
//
// Form1
//
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.ClientSize = new System.Drawing.Size(800, 450);
this.Controls.Add(this.tabControl1);
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
this.Name = "Form1";
this.Text = "Hangover: Libation debug and recovery tool";
this.tabControl1.ResumeLayout(false);
this.databaseTab.ResumeLayout(false);
this.databaseTab.PerformLayout();
this.fixDuplicatesTab.ResumeLayout(false);
this.deletedTab.ResumeLayout(false);
this.deletedTab.PerformLayout();
this.ResumeLayout(false);
}
#endregion
private TabControl tabControl1;
private TabPage databaseTab;
private Label databaseFileLbl;
private TextBox sqlResultsTb;
private TextBox sqlTb;
private Label sqlLbl;
private Button sqlExecuteBtn;
private TabPage cliTab;
private TabPage fixDuplicatesTab;
private Button removeDuplicateAsinsBtn;
private Button scanDuplicateAsinsBtn;
private Label duplicateAsinStatusLbl;
private TextBox duplicateResultsTb;
private TabPage deletedTab;
private CheckedListBox deletedCbl;
private Label label1;
private Button saveBtn;
private Button uncheckAllBtn;
private Button checkAllBtn;
private Label deletedCheckedLbl;
}
}
}
@@ -0,0 +1,48 @@
namespace HangoverWinForms;
public partial class Form1
{
private void Load_fixDuplicatesTab()
{
if (_tab.DbFile is null)
{
duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup unavailable (database not found).";
scanDuplicateAsinsBtn.Enabled = false;
removeDuplicateAsinsBtn.Enabled = false;
return;
}
refreshDuplicateAsinStatus();
}
private void refreshDuplicateAsinStatus()
{
duplicateAsinStatusLbl.Text = _tab.GetDuplicateAsinStatusText();
scanDuplicateAsinsBtn.Enabled = true;
removeDuplicateAsinsBtn.Enabled = _tab.CanRemoveDuplicateAsins();
}
private void fixDuplicatesTab_VisibleChanged(object sender, EventArgs e)
{
if (!fixDuplicatesTab.Visible)
return;
if (_tab.DbFile is not null)
refreshDuplicateAsinStatus();
}
private void scanDuplicateAsinsBtn_Click(object sender, EventArgs e)
{
_tab.ScanDuplicateAsins();
refreshDuplicateAsinStatus();
}
private void removeDuplicateAsinsBtn_Click(object sender, EventArgs e)
{
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RemoveDuplicateAsinsDescription))
return;
_tab.RemoveDuplicateAsins();
refreshDuplicateAsinStatus();
}
}
+1 -3
View File
@@ -12,12 +12,10 @@ public partial class Form1 : Form
LibationScaffolding.RunPostConfigMigrations(config);
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
cliTab.VisibleChanged += cliTab_VisibleChanged;
fixDuplicatesTab.VisibleChanged += fixDuplicatesTab_VisibleChanged;
deletedTab.VisibleChanged += deletedTab_VisibleChanged;
Load_databaseTab();
Load_cliTab();
Load_deletedTab();
}
}
@@ -0,0 +1,14 @@
using HangoverBase;
namespace HangoverWinForms;
internal static class HangoverMutationConfirm
{
public static bool Confirm(IWin32Window owner, string actionDescription)
=> MessageBox.Show(
owner,
HangoverDbMutation.BuildConfirmMessage(actionDescription),
HangoverDbMutation.ConfirmTitle,
MessageBoxButtons.YesNo,
MessageBoxIcon.Warning) == DialogResult.Yes;
}
+1
View File
@@ -61,6 +61,7 @@
<File Path="__README - COLLABORATORS.txt" />
</Folder>
<Folder Name="/_Tests/">
<Project Path="_Tests/ApplicationServices.Tests/ApplicationServices.Tests.csproj" />
<Project Path="_Tests/AssertionHelper/AssertionHelper.csproj" />
<Project Path="_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj" />
<Project Path="_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj" />
+10 -3
View File
@@ -47,6 +47,12 @@ public class App : Application
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
if (InstallUpgradeManager.TakeStartupRecoveryAlert() is { } recovery)
_ = MessageBox.Show(null, recovery.Body, recovery.Title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
BadBookActionDialogBase.ShowAsyncImpl = (owner, message, caption) =>
Dialogs.BadBookActionDialog.ShowAsync(owner as Window, message, caption);
if (LibraryTask is null)
{
RunSetupIfNeededAsync(desktop, Configuration.Instance);
@@ -148,13 +154,14 @@ public class App : Application
List<DataLayer.LibraryBook> library = await LibraryTask;
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
}
catch (Exception ex) when (StartupAssemblyBootstrap.IsMissingDependencyAssembly(ex))
catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex))
{
Serilog.Log.Logger.Error(ex, "Failed to load library at startup");
FatalStartupMessage failure = StartupAssemblyBootstrap.GetStartupFailureMessage(ex)!;
await MessageBox.Show(
MainWindow,
StartupAssemblyBootstrap.GetLibraryLoadFailureMessage(),
"Library load failed",
failure.Body,
failure.Title,
MessageBoxButtons.OK,
MessageBoxIcon.Error);
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync([]));
@@ -0,0 +1,94 @@
<UserControl xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudiobookshelfSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Audiobookshelf">
<StackPanel Margin="6" Spacing="0">
<CheckBox IsChecked="{Binding Enabled, Mode=TwoWay}"
Content="{Binding EnabledText}"
Margin="0,0,0,10" />
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
<!-- Server URL -->
<TextBlock Grid.Row="0" Grid.Column="0"
Text="{Binding ServerUrlText}"
VerticalAlignment="Center"
Margin="0,0,10,6" />
<TextBox Grid.Row="0" Grid.Column="1"
Text="{Binding ServerUrl, Mode=TwoWay}"
PlaceholderText="http://localhost:13378"
IsEnabled="{Binding Enabled}"
Margin="0,0,10,6" />
<!-- API Token -->
<TextBlock Grid.Row="1" Grid.Column="0"
Text="{Binding ApiTokenText}"
VerticalAlignment="Center"
Margin="0,0,10,10" />
<TextBox Grid.Row="1" Grid.Column="1"
Text="{Binding ApiToken, Mode=TwoWay}"
PasswordChar="*"
PlaceholderText="Your ABS API token"
IsEnabled="{Binding Enabled}"
Margin="0,0,10,10" />
<!-- Connect Button -->
<Button Grid.Row="0" Grid.Column="2"
Grid.RowSpan="2"
Content="{Binding ConnectButtonText}"
Command="{Binding ConnectCommand}"
IsEnabled="{Binding CanConnect}"
HorizontalAlignment="Left"
HorizontalContentAlignment="Center"
MinWidth="150"
MinHeight="34"
VerticalAlignment="Stretch"
Margin="0,0,0,10" />
<!-- Plaintext warning -->
<TextBlock Grid.Row="2" Grid.Column="1"
Text="{Binding PlaintextWarningText}"
Foreground="DarkOrange"
TextWrapping="Wrap"
IsVisible="{Binding PlaintextWarningVisible}"
Margin="0,0,0,10" />
<!-- Status -->
<ProgressBar Grid.Row="4" Grid.Column="1"
IsIndeterminate="True"
IsVisible="{Binding IsConnecting}"
Margin="0,0,0,6" />
<TextBlock Grid.Row="5" Grid.Column="1"
Text="{Binding StatusText}"
TextWrapping="Wrap"
IsVisible="{Binding !IsConnecting}"
Margin="0,0,0,8" />
<!-- Library -->
<TextBlock Grid.Row="6" Grid.Column="0"
Text="{Binding LibraryText}"
VerticalAlignment="Center"
Margin="0,0,10,7" />
<ComboBox Grid.Row="6" Grid.Column="1"
ItemsSource="{Binding LibraryNames}"
SelectedIndex="{Binding SelectedLibraryIndex, Mode=TwoWay}"
IsEnabled="{Binding Enabled}"
Margin="0,0,0,7" />
<!-- Folder -->
<TextBlock Grid.Row="7" Grid.Column="0"
Text="{Binding FolderText}"
VerticalAlignment="Center"
Margin="0,0,10,0" />
<ComboBox Grid.Row="7" Grid.Column="1"
ItemsSource="{Binding FolderNames}"
SelectedIndex="{Binding SelectedFolderIndex, Mode=TwoWay}"
IsEnabled="{Binding Enabled}"
Margin="0,0,0,0" />
</Grid>
</StackPanel>
</UserControl>
@@ -0,0 +1,18 @@
using Avalonia.Controls;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
namespace LibationAvalonia.Controls.Settings;
public partial class Audiobookshelf : UserControl
{
public Audiobookshelf()
{
InitializeComponent();
if (Design.IsDesignMode)
{
DataContext = new AudiobookshelfSettingsVM(Configuration.CreateMockInstance());
}
}
}
@@ -8,7 +8,7 @@
x:DataType="vm:ImportantSettingsVM"
x:Class="LibationAvalonia.Controls.Settings.Important">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
<controls:GroupBox
Grid.Row="0"
Margin="5"
@@ -85,8 +85,51 @@
</StackPanel>
</CheckBox>
<controls:GroupBox
Grid.Row="2"
Margin="5"
Label="Authentication tokens">
<StackPanel>
<CheckBox
IsEnabled="{Binding CanEditEncryptTokens}"
IsChecked="{Binding EncryptTokens, Mode=TwoWay}">
<TextBlock Text="{Binding EncryptTokensText}" TextWrapping="Wrap" />
</CheckBox>
<TextBlock
Margin="24,4,0,0"
Foreground="DarkRed"
IsVisible="{Binding PlaintextWarningVisible}"
Text="{Binding PlaintextWarningText}"
TextWrapping="Wrap" />
<TextBlock
Margin="24,4,0,0"
Foreground="DarkRed"
IsVisible="{Binding OsStoreUnavailableVisible}"
Text="{Binding OsStoreUnavailableMessage}"
TextWrapping="Wrap" />
<StackPanel
Margin="0,10,0,0"
Orientation="Horizontal"
HorizontalAlignment="Right"
Spacing="10">
<Button
Padding="20,0"
Content="{Binding ExportButtonText}"
ToolTip.Tip="{Binding ExportButtonToolTip}"
IsEnabled="{Binding ExportButtonEnabled}"
Click="ExportMasterKey_Click" />
<Button
Padding="20,0"
Content="{Binding ConvertButtonText}"
ToolTip.Tip="{Binding ConvertButtonToolTip}"
IsEnabled="{Binding ConvertButtonEnabled}"
Command="{Binding ConvertExistingTokensCommand}" />
</StackPanel>
</StackPanel>
</controls:GroupBox>
<StackPanel
Grid.Row="2" Margin="5"
Grid.Row="3" Margin="5"
Orientation="Horizontal">
<TextBlock
@@ -111,7 +154,7 @@
</StackPanel>
<controls:GroupBox
Grid.Row="3"
Grid.Row="4"
Margin="5"
Label="Display Settings">
<Grid
@@ -166,7 +209,7 @@
</controls:GroupBox>
<Grid
Grid.Row="4"
Grid.Row="5"
ColumnDefinitions="Auto,Auto,*"
Margin="10"
VerticalAlignment="Bottom">
@@ -1,8 +1,11 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Platform.Storage;
using AudibleUtilities;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationUiBase;
using System.Linq;
namespace LibationAvalonia.Controls.Settings;
@@ -21,6 +24,32 @@ public partial class Important : UserControl
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
}
private async void ExportMasterKey_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
var owner = this.GetParentWindow();
if (!await TokenStorageSettingsUi.ConfirmExportMasterKeyAsync(owner))
return;
var options = new FilePickerSaveOptions
{
Title = TokenStorageSettingsUi.ExportConfirmCaption,
SuggestedFileName = IdentityTokenStorageWiring.DefaultMasterKeyFileName,
DefaultExtension = "key",
ShowOverwritePrompt = true,
FileTypeChoices =
[
new("Master key (*.key)") { Patterns = ["*.key"] },
new("All files (*.*)") { Patterns = ["*"] }
]
};
var selectedFile = (await owner.StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath();
if (selectedFile is null)
return;
await TokenStorageSettingsUi.ExportMasterKeyToFileAsync(owner, selectedFile);
}
private void EditThemeColors_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
@@ -15,7 +15,10 @@
<controls:LinkLabel FontSize="16" FontWeight="Bold" Text="{Binding Version}" ToolTip.Tip="View Release Notes" Tapped="ViewReleaseNotes_Tapped" />
<controls:LinkLabel Grid.Row="1" FontSize="14" VerticalAlignment="Center" Text="https://getlibation.com" Tapped="Link_getlibation"/>
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="16">
<controls:LinkLabel FontSize="14" VerticalAlignment="Center" Text="https://getlibation.com" Tapped="Link_getlibation"/>
<controls:LinkLabel FontSize="14" VerticalAlignment="Center" Text="Donate" Tapped="Link_donate"/>
</StackPanel>
<Button Grid.Row="2" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Margin="0,10,0,0" IsEnabled="{Binding CanCheckForUpgrade}" Content="{Binding UpgradeButtonText}" Click="CheckForUpgrade_Click" />
@@ -62,6 +62,8 @@ public partial class AboutDialog : DialogWindow
private void Link_getlibation(object sender, Avalonia.Input.TappedEventArgs e) => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.WebsiteUrl);
private void Link_donate(object sender, Avalonia.Input.TappedEventArgs e) => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.DonateUrl);
private void ViewReleaseNotes_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}");
}
@@ -0,0 +1,40 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="320"
MinWidth="400" MaxWidth="520"
SizeToContent="Height"
CanMaximize="False"
CanMinimize="False"
x:Class="LibationAvalonia.Dialogs.BadBookActionDialog"
ShowInTaskbar="True"
WindowStartupLocation="CenterOwner">
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" Margin="5,10,10,10">
<Image Grid.Row="0" Grid.Column="0" Height="32" Width="32" Margin="5,0,5,0"
VerticalAlignment="Top" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
<TextBlock Grid.Row="0" Grid.Column="1" Name="messageTextBlock" Margin="5,0,0,0" MinWidth="300"
TextWrapping="Wrap" HorizontalAlignment="Stretch" VerticalAlignment="Top"
FontSize="12"/>
<StackPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="10,10,10,10" Spacing="6">
<CheckBox Name="applyToAllCheckBox" Content="Apply to all remaining books in this queue"/>
<CheckBox Name="rememberInSettingsCheckBox" Content="Remember this choice in Settings"/>
</StackPanel>
<DockPanel Height="45" Grid.Row="2" Grid.ColumnSpan="2" Background="{DynamicResource SystemChromeMediumLowColor}">
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
<Button MinWidth="75" MinHeight="28" Name="abortButton" Click="AbortButton_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="Abort"/>
</Button>
<Button MinWidth="75" MinHeight="28" Name="retryButton" Click="RetryButton_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="Retry"/>
</Button>
<Button MinWidth="75" MinHeight="28" Name="ignoreButton" Click="IgnoreButton_Click" Margin="5">
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="Ignore"/>
</Button>
</StackPanel>
</DockPanel>
</Grid>
</Window>
@@ -0,0 +1,94 @@
using Avalonia;
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Avalonia.Threading;
using LibationUiBase;
using LibationUiBase.Forms;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs;
public partial class BadBookActionDialog : DialogWindow
{
public BadBookDialogResult Result { get; private set; } = new(DialogResult.Retry, false, false);
public BadBookActionDialog()
{
InitializeComponent();
SaveOnEnter = false;
CancelOnEscape = false;
}
public BadBookActionDialog(string message, string caption) : this()
{
Title = caption;
messageTextBlock.Text = message;
ControlToFocusOnShow = retryButton;
}
private void CloseWith(DialogResult action)
{
Result = new BadBookDialogResult(
action,
applyToAllCheckBox.IsChecked == true,
rememberInSettingsCheckBox.IsChecked == true);
Close(DialogResult.None);
}
public void AbortButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
=> CloseWith(DialogResult.Abort);
public void RetryButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
=> CloseWith(DialogResult.Retry);
public void IgnoreButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
=> CloseWith(DialogResult.Ignore);
public static Task<BadBookDialogResult> ShowAsync(Window? owner, string message, string caption)
=> Dispatcher.UIThread.InvokeAsync(async () =>
{
owner = owner?.IsLoaded is true ? owner : null;
var dialog = new BadBookActionDialog(message, caption);
await DisplayDialogAsync(dialog, owner);
return dialog.Result;
});
private static async Task DisplayDialogAsync(BadBookActionDialog dialog, Window? owner)
{
if (owner is null)
{
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
{
if (desktop.MainWindow?.IsLoaded is true)
await dialog.ShowDialog(desktop.MainWindow);
else
{
var tcs = new TaskCompletionSource();
desktop.MainWindow = dialog;
dialog.Closed += (_, _) => tcs.SetResult();
dialog.Show();
await tcs.Task;
}
}
else
{
var window = new Window
{
IsVisible = false,
Height = 1,
Width = 1,
WindowDecorations = WindowDecorations.None,
ShowInTaskbar = false
};
window.Show();
await dialog.ShowDialog(window);
window.Close();
}
}
else
{
await dialog.ShowDialog(owner);
}
}
}
@@ -80,6 +80,14 @@
<settings:Audio DataContext="{Binding AudioSettings, Mode=OneTime}" />
</ScrollViewer>
</TabItem>
<TabItem>
<TabItem.Header>
<TextBlock Text="Audiobookshelf"/>
</TabItem.Header>
<ScrollViewer VerticalScrollBarVisibility="Auto">
<settings:Audiobookshelf DataContext="{Binding AudiobookshelfSettings, Mode=OneTime}" />
</ScrollViewer>
</TabItem>
</TabControl>
</Grid>
</Window>
@@ -2,6 +2,7 @@ using Avalonia.Controls;
using FileManager;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
@@ -32,8 +33,19 @@ public partial class SettingsDialog : DialogWindow
#endregion
var tokenDecision = await settingsDisp.ImportantSettings.PromptOnSaveAsync(this);
if (tokenDecision == TokenStorageSaveDecision.Abort)
return;
settingsDisp.SaveSettings(config);
if (tokenDecision == TokenStorageSaveDecision.SavePreferenceAndConvert)
{
var converted = await settingsDisp.ImportantSettings.ConvertAfterSaveAsync(this);
if (!converted)
return;
}
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
await base.SaveAndCloseAsync();
}
@@ -9,6 +9,7 @@
Width="500" Height="350"
Icon="/Assets/libation.ico"
Title="Welcome to Libation"
ShowInTaskbar="True"
x:DataType="dialogs:SetupDialog">
<Grid
@@ -77,7 +77,7 @@
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.14" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.1" />
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
</ItemGroup>
+12 -7
View File
@@ -28,7 +28,7 @@ static class Program
//We can do this because we're already executing inside the sandbox.
//Any process created in the sandbox executes in the same sandbox.
//Unfortunately, all sandbox files are read/execute, so no writing!
Process.Start("Hangover");
HangoverLauncher.Launch();
return;
}
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli")
@@ -53,6 +53,7 @@ static class Program
try
{
var config = LibationScaffolding.RunPreConfigMigrations();
StartupAssemblyBootstrap.RecoverFromIncompleteUpgradeIfNeeded();
if (config.LibationFiles.SettingsAreValid)
{
App.RunMigrations(config);
@@ -128,13 +129,17 @@ static class Program
{
var dispatcher = new DispatcherFrame();
var mbAlert = new MessageBoxAlertAdminDialog("""
Libation encountered a fatal error and must close.
var fatalMessage = StartupAssemblyBootstrap.GetFatalStartupMessage(
exception,
new FatalStartupMessage(
"Libation Crash",
"""
Libation encountered a fatal error and must close.
Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder.
""",
"Libation Crash",
exception);
Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder.
"""));
var mbAlert = new MessageBoxAlertAdminDialog(fatalMessage.Body, fatalMessage.Title, exception);
mbAlert.Closed += (_, _) => dispatcher.Continue = false;
mbAlert.Show();
Dispatcher.UIThread.PushFrame(dispatcher);
@@ -1,4 +1,5 @@
using ApplicationServices;
using Avalonia.Threading;
using DataLayer;
using LibationFileManager;
using ReactiveUI;
@@ -77,6 +78,9 @@ partial class MainVM
if (Configuration.Instance.AutoDownloadEpisodes
&& stats.PendingBooks + stats.pdfsNotDownloaded > 0)
await BackupAllBooksAsync(stats.LibraryBooks);
{
// RunWorkerCompleted has no SynchronizationContext; queue items require the UI thread.
await Dispatcher.UIThread.InvokeAsync(async () => await BackupAllBooksAsync(stats.LibraryBooks));
}
}
}
@@ -0,0 +1,39 @@
#if DEBUG
using LibationUiBase.Forms;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels;
partial class MainVM
{
public async Task SimulateBadBookFailuresAsync()
{
var books = ProductsDisplay.GetVisibleBookEntries().Take(5).ToArray();
if (books.Length == 0)
{
await MessageBox.Show(
"No books are visible in the grid.\n\nClear your filter or widen it, then try again.",
"Test bad book dialog",
MessageBoxButtons.OK,
MessageBoxIcon.Information);
return;
}
var confirm = await MessageBox.Show(
$"Queue {books.Length} visible book(s) with simulated failures?\n\n"
+ "No files will be downloaded. Each book will immediately show the bad-book error dialog.\n\n"
+ "Set error handling to \"Ask each time\" in Settings > Download/Decrypt before testing.",
"Test bad book dialog",
MessageBoxButtons.YesNo,
MessageBoxIcon.Question,
MessageBoxDefaultButton.Button1);
if (confirm != DialogResult.Yes)
return;
ProcessQueue.QueueSimulatedBadBookFailures(books);
setQueueCollapseState(false);
}
}
#endif
@@ -212,6 +212,7 @@ public partial class MainVM
try
{
var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
autoScanRunner?.OnManualScanSucceeded();
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
@@ -231,6 +232,14 @@ public partial class MainVM
WebView2LoginErrorMessage.Caption,
webViewEx);
}
else if (NonJsonResponseExceptionExtensions.TryFindInTree(ex, out var htmlEx) && htmlEx is not null)
{
await MessageBox.ShowAdminAlert(
MainWindow,
htmlEx.GetExplainerBody(),
NonJsonResponseExceptionExtensions.LibraryScanFailedCaption,
htmlEx);
}
else
{
await MessageBox.ShowAdminAlert(
@@ -2,6 +2,8 @@
using AudibleUtilities;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
using System.Linq;
@@ -11,45 +13,37 @@ namespace LibationAvalonia.ViewModels;
partial class MainVM
{
private readonly InterruptableTimer autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5));
private readonly InterruptableTimer autoScanTimer = new(TimeSpan.FromMinutes(5));
private AutoScanRunner? autoScanRunner;
private void Configure_ScanAuto()
{
// subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI
autoScanTimer.Elapsed += async (_, __) =>
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
var accounts = persister.AccountsSettings
.GetAll()
.Where(a => a.LibraryScan)
.ToArray();
autoScanRunner = new AutoScanRunner(
isAutoScanEnabled: () => Configuration.Instance.AutoScan,
pauseTimer: () => autoScanTimer.Stop(),
resumeTimer: () => autoScanTimer.PerformNow(),
notifyAuthRequired: notifyAutoScanAuthRequiredAsync);
// in autoScan, new books SHALL NOT show dialog
try
{
await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
}
catch (OperationCanceledException)
{
Serilog.Log.Information("Audible login attempt cancelled by user");
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error invoking auto-scan");
}
};
autoScanTimer.Elapsed += async (_, __) => await autoScanRunner.RunAsync();
// if enabled: begin on load
MainWindow.Loaded += startAutoScan;
// if new 'default' account is added, run autoscan
AccountsSettingsPersister.Saving += accountsPreSave;
AccountsSettingsPersister.Saved += accountsPostSave;
// when autoscan setting is changed, update menu checkbox and run autoscan
Configuration.Instance.PropertyChanged += startAutoScan;
}
private async Task notifyAutoScanAuthRequiredAsync()
{
await MessageBox.Show(
MainWindow,
"Libation could not refresh your Audible library because your login session expired.\n\n"
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
"Auto-scan paused - login required",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
private List<(string AccountId, string LocaleName)>? preSaveDefaultAccounts;
private List<(string AccountId, string LocaleName)> getDefaultAccounts()
@@ -75,6 +69,7 @@ partial class MainVM
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
private void startAutoScan(object? sender = null, EventArgs? e = null)
{
autoScanRunner?.OnAutoScanSettingChanged();
AutoScanChecked = Configuration.Instance.AutoScan;
if (AutoScanChecked)
autoScanTimer.PerformNow();
@@ -1,7 +1,9 @@
using Avalonia.Controls;
using LibationFileManager;
using LibationUiBase.Forms;
using ReactiveUI;
using System;
using System.Reactive;
using System.Threading.Tasks;
namespace LibationAvalonia.ViewModels;
@@ -10,8 +12,12 @@ partial class MainVM
{
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
public ReactiveCommand<Unit, Unit> LaunchHangover { get; private set; } = null!;
private void Configure_Settings()
{
LaunchHangover = ReactiveCommand.CreateFromTask(LaunchHangoverAsync);
if (App.Current is Avalonia.Application app &&
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
@@ -23,15 +29,21 @@ partial class MainVM
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
public void LaunchHangover()
private async Task LaunchHangoverAsync()
{
try
{
System.Diagnostics.Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : ""));
HangoverLauncher.Launch();
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
await MessageBox.Show(
MainWindow,
HangoverLauncher.GetLaunchFailureMessage(ex),
"Launch Hangover",
MessageBoxButtons.OK,
MessageBoxIcon.Warning);
}
}
@@ -271,7 +271,11 @@ public class ProductsDisplayViewModel : ViewModelBase
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
seriesEntry.Children.Add(episodeEntry);
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
var seriesBook = dbBooks.FindSeriesParent(episodeBook)
?? dbBooks.FirstOrDefault(lb =>
lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId
&& lb.Account == seriesEntry.LibraryBook.Account)
?? seriesEntry.LibraryBook;
seriesEntry.UpdateLibraryBook(seriesBook);
}
@@ -443,6 +447,14 @@ public class ProductsDisplayViewModel : ViewModelBase
WebView2LoginErrorMessage.Caption,
webViewEx);
}
else if (NonJsonResponseExceptionExtensions.TryFindInTree(ex, out var htmlEx) && htmlEx is not null)
{
await MessageBox.ShowAdminAlert(
null,
htmlEx.GetExplainerBody(),
NonJsonResponseExceptionExtensions.LibraryScanFailedCaption,
htmlEx);
}
else
{
await MessageBox.ShowAdminAlert(
@@ -0,0 +1,247 @@
using ApplicationServices;
using AudibleApi.Authorization;
using AudibleUtilities;
using LibationFileManager;
using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Collections.ObjectModel;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibationAvalonia.ViewModels.Settings;
public class AudiobookshelfSettingsVM : ViewModelBase
{
private readonly Configuration config;
private bool enabled;
private string serverUrl = "";
private string apiToken = "";
private string statusText = "";
private bool statusIsError;
private bool plaintextWarningVisible;
private List<AudiobookshelfApiService.Library> libraries = [];
private int selectedLibraryIndex = -1;
private int selectedFolderIndex = -1;
private bool isConnecting;
private ObservableCollection<string> libraryNames = new();
private ObservableCollection<string> folderNames = new();
public AudiobookshelfSettingsVM(Configuration config)
{
this.config = config;
enabled = config.AudiobookshelfEnabled;
serverUrl = config.AudiobookshelfServerUrl ?? "";
apiToken = AudiobookshelfTokenStorage.DecryptToken(config.AudiobookshelfApiToken) ?? "";
var osSecretStoreAvailable = IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out _);
bool encryptingButNoProtector = config.TokenStorageMethod == TokenStorageMethod.Encrypted && IdentityTokenStorage.Protector is null;
PlaintextWarningVisible = (osSecretStoreAvailable && config.TokenStorageMethod == TokenStorageMethod.Plaintext) || encryptingButNoProtector;
ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync);
_ = RestoreLibrariesAsync();
}
public bool Enabled
{
get => enabled;
set
{
this.RaiseAndSetIfChanged(ref enabled, value);
this.RaisePropertyChanged(nameof(CanConnect));
}
}
public string ServerUrl
{
get => serverUrl;
set => this.RaiseAndSetIfChanged(ref serverUrl, value);
}
public string ApiToken
{
get => apiToken;
set => this.RaiseAndSetIfChanged(ref apiToken, value);
}
public string StatusText
{
get => statusText;
set => this.RaiseAndSetIfChanged(ref statusText, value);
}
public bool StatusIsError
{
get => statusIsError;
set => this.RaiseAndSetIfChanged(ref statusIsError, value);
}
public bool PlaintextWarningVisible
{
get => plaintextWarningVisible;
private set => this.RaiseAndSetIfChanged(ref plaintextWarningVisible, value);
}
public List<AudiobookshelfApiService.Library> Libraries
{
get => libraries;
set
{
this.RaiseAndSetIfChanged(ref libraries, value);
UpdateLibraryNames();
}
}
public ObservableCollection<string> LibraryNames
{
get => libraryNames;
set => this.RaiseAndSetIfChanged(ref libraryNames, value);
}
public int SelectedLibraryIndex
{
get => selectedLibraryIndex;
set
{
this.RaiseAndSetIfChanged(ref selectedLibraryIndex, value);
UpdateFolders();
}
}
public ObservableCollection<string> FolderNames
{
get => folderNames;
set => this.RaiseAndSetIfChanged(ref folderNames, value);
}
public int SelectedFolderIndex
{
get => selectedFolderIndex;
set => this.RaiseAndSetIfChanged(ref selectedFolderIndex, value);
}
public bool IsConnecting
{
get => isConnecting;
set
{
this.RaiseAndSetIfChanged(ref isConnecting, value);
this.RaisePropertyChanged(nameof(CanConnect));
}
}
public bool CanConnect => Enabled && !IsConnecting;
public ICommand ConnectCommand { get; }
// Labels from Configuration descriptions
public string EnabledText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfEnabled));
public string ServerUrlText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfServerUrl));
public string ApiTokenText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfApiToken));
public string LibraryText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfLibraryId));
public string FolderText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfFolderId));
public string PlaintextWarningText { get; } = "Warning: The API token is stored as plaintext in Settings.json.";
public string ConnectButtonText { get; } = "Connect / Refresh";
private void UpdateLibraryNames()
{
LibraryNames.Clear();
foreach (var lib in Libraries)
LibraryNames.Add($"{lib.Name} ({lib.Id})");
}
private void UpdateFolders()
{
FolderNames.Clear();
SelectedFolderIndex = -1;
if (SelectedLibraryIndex < 0 || SelectedLibraryIndex >= Libraries.Count)
return;
var library = Libraries[SelectedLibraryIndex];
foreach (var folder in library.Folders)
FolderNames.Add($"{folder.FullPath} ({folder.Id})");
if (FolderNames.Count > 0)
SelectedFolderIndex = 0;
}
private async Task ConnectAsync()
{
IsConnecting = true;
StatusText = "Connecting...";
StatusIsError = false;
try
{
var result = await AudiobookshelfApiService.ConnectAsync(ServerUrl, ApiToken);
if (!string.IsNullOrEmpty(result.NormalizedServerUrl))
ServerUrl = result.NormalizedServerUrl;
StatusText = result.StatusMessage;
StatusIsError = !result.Success;
if (!result.Success)
return;
Libraries = result.Libraries.ToList();
SelectedLibraryIndex = 0;
}
finally
{
IsConnecting = false;
}
}
private async Task RestoreLibrariesAsync()
{
try
{
if (string.IsNullOrWhiteSpace(config.AudiobookshelfServerUrl) || string.IsNullOrWhiteSpace(config.AudiobookshelfApiToken))
return;
var libs = await AudiobookshelfApiService.GetLibrariesAsync(config.AudiobookshelfServerUrl, config.AudiobookshelfApiToken);
Libraries = libs;
var savedLibIndex = libs.FindIndex(l => l.Id == config.AudiobookshelfLibraryId);
if (savedLibIndex >= 0)
{
SelectedLibraryIndex = savedLibIndex;
var library = libs[savedLibIndex];
var savedFolderIndex = library.Folders.FindIndex(f => f.Id == config.AudiobookshelfFolderId);
if (savedFolderIndex >= 0)
SelectedFolderIndex = savedFolderIndex;
}
}
catch
{
// Silently fail; user can manually reconnect
}
}
public void SaveSettings(Configuration config)
{
config.AudiobookshelfEnabled = Enabled;
config.AudiobookshelfServerUrl = AudiobookshelfApiService.TryNormalizeServerUrlForSave(ServerUrl);
ServerUrl = config.AudiobookshelfServerUrl ?? "";
config.AudiobookshelfApiToken = AudiobookshelfTokenStorage.EncryptToken(ApiToken.Trim());
if (SelectedLibraryIndex >= 0 && Libraries.Count > SelectedLibraryIndex)
config.AudiobookshelfLibraryId = Libraries[SelectedLibraryIndex].Id;
else
config.AudiobookshelfLibraryId = null;
if (SelectedLibraryIndex >= 0 && SelectedFolderIndex >= 0)
{
var library = Libraries[SelectedLibraryIndex];
if (library.Folders.Count > SelectedFolderIndex)
config.AudiobookshelfFolderId = library.Folders[SelectedFolderIndex].Id;
else
config.AudiobookshelfFolderId = null;
}
else
config.AudiobookshelfFolderId = null;
}
}
@@ -1,3 +1,5 @@
using AudibleApi.Authorization;
using AudibleUtilities;
using Dinah.Core;
using FileManager;
using LibationFileManager;
@@ -6,6 +8,8 @@ using ReactiveUI;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using System.Windows.Input;
namespace LibationAvalonia.ViewModels.Settings;
@@ -13,6 +17,13 @@ public class ImportantSettingsVM : ViewModelBase
{
private EnumDisplay<Configuration.Theme> themeVariant;
private readonly Configuration config;
private bool encryptTokens;
private bool plaintextWarningVisible;
private bool convertButtonEnabled;
private bool osStoreUnavailableVisible;
private string osStoreUnavailableMessage = "";
private TokenStorageMethod initialTokenStorageMethod;
private bool osSecretStoreAvailable;
public ImportantSettingsVM(Configuration config)
{
@@ -29,8 +40,35 @@ public class ImportantSettingsVM : ViewModelBase
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
themeVariant = Themes.Single(v => v.Value == config.ThemeVariant);
initialTokenStorageMethod = config.TokenStorageMethod;
osSecretStoreAvailable = IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason);
if (!osSecretStoreAvailable)
{
OsStoreUnavailableVisible = true;
OsStoreUnavailableMessage = TokenStorageSettingsUi.OsStoreUnavailableMessage(unavailableReason);
encryptTokens = false;
CanEditEncryptTokens = false;
ExportButtonEnabled = false;
}
else
{
OsStoreUnavailableVisible = false;
encryptTokens = TokenStorageSettingsUi.CheckboxFromMethod(config.TokenStorageMethod);
CanEditEncryptTokens = true;
ExportButtonEnabled = true;
}
RefreshTokenStorageUiState();
ConvertExistingTokensCommand = ReactiveCommand.CreateFromTask(ConvertExistingTokensAsync);
}
public async Task<TokenStorageSaveDecision> PromptOnSaveAsync(object? owner)
=> await TokenStorageSettingsUi.PromptOnPreferenceSaveAsync(owner, initialTokenStorageMethod, SelectedTokenStorageMethod);
public async Task<bool> ConvertAfterSaveAsync(object? owner)
=> await TokenStorageSettingsUi.RunConversionAsync(owner, SelectedTokenStorageMethod);
public void SaveSettings(Configuration config)
{
config.Books = GetBooksDirectory();
@@ -40,6 +78,9 @@ public class ImportantSettingsVM : ViewModelBase
config.LastWriteTime = LastWriteTime.Value;
config.UseWebView = UseWebView;
config.LogLevel = LoggingLevel;
config.TokenStorageMethod = SelectedTokenStorageMethod;
initialTokenStorageMethod = SelectedTokenStorageMethod;
RefreshTokenStorageUiState();
}
public LongPath GetBooksDirectory()
@@ -63,6 +104,24 @@ public class ImportantSettingsVM : ViewModelBase
Go.To.Folder(Configuration.Instance.LibationFiles.Location.ShortPathName);
}
private async Task ConvertExistingTokensAsync()
{
var converted = await TokenStorageSettingsUi.ConfirmAndConvertAsync(null, SelectedTokenStorageMethod);
if (converted)
RefreshTokenStorageUiState();
}
private void RefreshTokenStorageUiState()
{
var method = SelectedTokenStorageMethod;
PlaintextWarningVisible = osSecretStoreAvailable && method == TokenStorageMethod.Plaintext;
var alignment = AccountTokenStorage.GetAccountsAlignment(method);
ConvertButtonEnabled = TokenStorageSettingsUi.IsConvertButtonEnabled(alignment);
}
private TokenStorageMethod SelectedTokenStorageMethod
=> TokenStorageSettingsUi.MethodFromCheckbox(EncryptTokens);
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
{
Configuration.KnownDirectories.LibationFiles,
@@ -95,6 +154,15 @@ public class ImportantSettingsVM : ViewModelBase
.Select(v => new EnumDisplay<Configuration.Theme>(v))
.ToArray();
public string EncryptTokensText { get; } = TokenStorageSettingsUi.CheckboxText;
public string PlaintextWarningText { get; } = TokenStorageSettingsUi.PlaintextWarningText;
public string ConvertButtonText { get; } = TokenStorageSettingsUi.ConvertButtonText;
public string ConvertButtonToolTip { get; } = TokenStorageSettingsUi.ConvertButtonToolTip;
public string ExportButtonText { get; } = TokenStorageSettingsUi.ExportButtonText;
public string ExportButtonToolTip { get; } = TokenStorageSettingsUi.ExportButtonToolTip;
public bool CanEditEncryptTokens { get; }
public bool ExportButtonEnabled { get; }
public string BooksDirectory { get; set; }
public bool SavePodcastsToParentFolder { get; set; }
public bool OverwriteExisting { get; set; }
@@ -105,6 +173,42 @@ public class ImportantSettingsVM : ViewModelBase
public bool UseWebView { get; set; }
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }
public bool EncryptTokens
{
get => encryptTokens;
set
{
this.RaiseAndSetIfChanged(ref encryptTokens, value);
RefreshTokenStorageUiState();
}
}
public bool PlaintextWarningVisible
{
get => plaintextWarningVisible;
private set => this.RaiseAndSetIfChanged(ref plaintextWarningVisible, value);
}
public bool ConvertButtonEnabled
{
get => convertButtonEnabled;
private set => this.RaiseAndSetIfChanged(ref convertButtonEnabled, value);
}
public bool OsStoreUnavailableVisible
{
get => osStoreUnavailableVisible;
private set => this.RaiseAndSetIfChanged(ref osStoreUnavailableVisible, value);
}
public string OsStoreUnavailableMessage
{
get => osStoreUnavailableMessage;
private set => this.RaiseAndSetIfChanged(ref osStoreUnavailableMessage, value);
}
public ICommand ConvertExistingTokensCommand { get; }
public EnumDisplay<Configuration.Theme> ThemeVariant
{
get => themeVariant;
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff. Show more