Compare commits

...

133 Commits

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

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

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

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

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

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

This change does the following:

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

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

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

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

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

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-09-04 00:05:35 +00:00
rmcrackan
0f6493f4af Merge pull request #1353 from rmcrackan/rmcrackan-patch-2
Update NamingTemplates.md
2025-08-27 09:30:09 -04:00
rmcrackan
454b490a06 Update NamingTemplates.md 2025-08-27 09:28:45 -04:00
rmcrackan
ffea2648aa Merge pull request #1352 from rmcrackan/rmcrackan-patch-1
Update NamingTemplates.md
2025-08-27 09:22:20 -04:00
rmcrackan
1ac967500c Update NamingTemplates.md
Better documentation for inverted tags
2025-08-27 08:57:20 -04:00
Michael Bucari-Tovo
39e9f675d2 Fix possible race error on startup with autoscan 2025-08-26 10:00:21 -06:00
rmcrackan
ed5afe5d0f update dependencies 2025-08-20 12:07:53 -04:00
rmcrackan
ab075d0bef Merge pull request #1343 from MajorTanya/patch-1
Place examples in their own line
2025-08-20 11:49:46 -04:00
MajorTanya
7fb1adb41b Place examples in their own line
Markdown collapses single line breaks, so this change makes it so the examples will have their own lines.
2025-08-20 17:26:48 +02:00
rmcrackan
9735a8391c incr ver 2025-08-20 08:39:10 -04:00
rmcrackan
dbdfdbc536 Merge pull request #1342 from Mbucari/master
Added new <has PROPERTY-><-has> conditional tag
2025-08-20 08:37:11 -04:00
Michael Bucari-Tovo
3b86fc405f Add <has-> template tag 2025-08-19 18:41:31 -06:00
MBucari
4ea7f04921 Preserve space between series order numbers 2025-08-17 13:40:37 -06:00
MBucari
5b59b442ab Add last downloaded sample rate, bitrate and codec name to search engine. 2025-08-17 13:07:24 -06:00
370 changed files with 7991 additions and 6384 deletions

View File

@@ -10,10 +10,10 @@ PLEASE FILL OUT THE FOLLOWING. Bug reports with limited information or lacking a
___
**Describe the bug**
## Describe the bug
A clear and concise description of what the bug is.
**To Reproduce**
## To Reproduce
Steps to reproduce the behavior:
1. Go to '...'
@@ -21,14 +21,23 @@ Steps to reproduce the behavior:
3. Scroll down to '....'
4. See error
**Expected behavior**
## Expected behavior
A clear and concise description of what you expected to happen.
**Screenshots**
## Screenshots
If applicable, add screenshots to help explain your problem.
**Platform**
## Platform
[e.g. Windows 10, Windows 11, Mac, Linux (State distribution)]
**Log Files**
Attach your Libation log file here. Logs are typically in your `[user]\Libation` folder. (For example, on windows: `C:\my_username\Libation`) Also within Libation, on the first tab in Settings you can click the button 'Open log folder'. If your user folder contains the file "LibationCrash.log", attach that also.
## Log Files
Attach your Libation log file here. If your user folder contains the file "LibationCrash.log", attach that also.
**Default Log File Locations**
|Platform|Folder|
|-|-|
|Windows|`%userprofile%\Libation`|
|macOS|`~/Library/Application Support/Libation`|
|Linux|`~/.local/share/Libation`|
Alternative, you may open the log file folder from within Libation. Open Libation's settings, and on the first tab in Settings you can click the button 'Open log folder'.

View File

@@ -6,63 +6,46 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
runs_on:
publish-r2r:
type: boolean
retention-days:
type: number
architecture:
type: string
description: "The GitHub hosted runner to use"
description: "CPU architecture targeted by the build."
required: true
OS:
type: string
description: >
The operating system targeted by the build.
There must be a corresponding Bundle_$OS.sh script file in ./Scripts
required: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
RELEASE_NAME: "chardonnay"
jobs:
build:
name: "${{ inputs.OS }}-${{ inputs.architecture }}"
runs-on: ${{ inputs.runs_on }}
runs-on: ubuntu-latest
env:
RUNTIME_ID: "linux-${{ inputs.architecture }}"
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v4
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
inputVersion="${{ inputs.version_override }}"
if [[ "${#inputVersion}" -gt 0 ]]
then
version="${inputVersion}"
else
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
fi
echo "version=${version}" >> "${GITHUB_OUTPUT}"
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
@@ -70,63 +53,31 @@ jobs:
id: publish
working-directory: ./Source
run: |
if [[ "${{ inputs.OS }}" == "MacOS" ]]
then
display_os="macOS"
RUNTIME_ID="osx-${{ inputs.architecture }}"
else
display_os="Linux"
RUNTIME_ID="linux-${{ inputs.architecture }}"
fi
OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}"
echo "display_os=${display_os}" >> $GITHUB_OUTPUT
echo "Runtime Identifier: $RUNTIME_ID"
echo "Output Directory: $OUTPUT"
dotnet publish \
LibationAvalonia/LibationAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
LibationCli/LibationCli.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml
dotnet publish \
HangoverAvalonia/HangoverAvalonia.csproj \
--runtime $RUNTIME_ID \
--configuration ${{ env.DOTNET_CONFIGURATION }} \
--output $OUTPUT \
-p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}
run: |
BUNDLE_DIR=$(pwd)
echo "Bundle dir: ${BUNDLE_DIR}"
cd ..
SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh
SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh
chmod +rx ${SCRIPT}
${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}"
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Publish bundle
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.retention-days }}

104
.github/workflows/build-mac.yml vendored Normal file
View File

@@ -0,0 +1,104 @@
# build-mac.yml
# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation.
---
name: build
on:
workflow_call:
inputs:
libation-version:
type: string
required: true
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
sign-app:
type: boolean
description: "Wheather to sign an notorize the app bundle and dmg."
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
jobs:
build:
name: "macOS-${{ inputs.architecture }}"
runs-on: macos-latest
env:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- uses: apple-actions/import-codesign-certs@v5
if: ${{ inputs.sign-app }}
with:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
id: publish
working-directory: ./Source
run: |
PUBLISH_ARGS=(
'--runtime' '${{ env.RUNTIME_ID }}'
'--configuration' 'Release'
'--output' '../bin'
'-p:PublishProtocol=FileSystem'
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}"
'-p:SelfContained=true')
dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}"
dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}"
dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}"
- name: Build bundle
id: bundle
run: |
SCRIPT=./Scripts/Bundle_MacOS.sh
chmod +rx ${SCRIPT}
${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}"
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- name: Notarize bundle
if: ${{ inputs.sign-app }}
run: |
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
WAIT="--wait"
fi
echo "::debug::Submitting the disk image for notarization"
RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1)
SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }')
echo "$RESPONSE"
echo "::notice::Noraty Submission Id: $SUBMISSION_ID"
if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then
echo "::debug::Stapling the notarization ticket to the disk image"
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
fi
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}
if-no-files-found: error
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,113 +6,77 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
architecture:
type: string
description: "CPU architecture targeted by the build."
required: true
env:
DOTNET_CONFIGURATION: "Release"
DOTNET_VERSION: "9.0.x"
dotnet-version:
type: string
required: true
run-unit-tests:
type: boolean
publish-r2r:
type: boolean
retention-days:
type: number
jobs:
build:
name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
name: "Windows-${{ matrix.release_name }}-x64"
runs-on: windows-latest
env:
OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}"
RUNTIME_ID: "win-${{ inputs.architecture }}"
strategy:
matrix:
os: [Windows]
ui: [Avalonia]
release_name: [chardonnay]
include:
- os: Windows
ui: WinForms
- ui: WinForms
release_name: classic
prefix: Classic-
steps:
- uses: actions/checkout@v5
- name: Setup .NET
uses: actions/setup-dotnet@v4
- uses: actions/checkout@v6
- uses: actions/setup-dotnet@v5
with:
dotnet-version: ${{ env.DOTNET_VERSION }}
env:
NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }}
- name: Get version
id: get_version
run: |
if ("${{ inputs.version_override }}".length -gt 0) {
$version = "${{ inputs.version_override }}"
} else {
$version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim()
}
"version=$version" >> $env:GITHUB_OUTPUT
dotnet-version: ${{ inputs.dotnet-version }}
dotnet-quality: "ga"
- name: Unit test
if: ${{ inputs.run_unit_tests }}
if: ${{ inputs.run-unit-tests }}
working-directory: ./Source
run: dotnet test
- name: Publish
working-directory: ./Source
run: |
dotnet publish `
Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
LibationCli/LibationCli.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:DefineConstants="${{ matrix.release_name }}" `
-p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
dotnet publish `
Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj `
--runtime ${{ env.RUNTIME_ID }} `
--configuration ${{ env.DOTNET_CONFIGURATION }} `
--output bin/Publish/${{ env.OUTPUT_NAME }} `
-p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml
$PUBLISH_ARGS=@(
"--runtime", "win-x64",
"--configuration", "Release",
"--output", "../bin",
"-p:PublishProtocol=FileSystem",
"-p:PublishReadyToRun=${{ inputs.publish-r2r }}",
"-p:SelfContained=true")
dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS
dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS
dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS
dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS
- name: Zip artifact
id: zip
working-directory: ./Source/bin/Publish
working-directory: ./bin
run: |
$bin_dir = "${{ env.OUTPUT_NAME }}\"
$delfiles = @(
"WindowsConfigApp.exe",
"WindowsConfigApp.runtimeconfig.json",
"WindowsConfigApp.deps.json"
)
foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } }
$artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}"
"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 }}-x64.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip"
Compress-Archive -Path * -DestinationPath "$artifact"
- name: Publish artifact
uses: actions/upload-artifact@v4
- uses: actions/upload-artifact@v5
with:
name: ${{ steps.zip.outputs.artifact }}.zip
path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip
name: ${{ steps.zip.outputs.artifact }}
path: ./bin/${{ steps.zip.outputs.artifact }}
if-no-files-found: error
retention-days: 7
retention-days: ${{ inputs.retention-days }}

View File

@@ -6,26 +6,51 @@ name: build
on:
workflow_call:
inputs:
version_override:
libation-version:
type: string
description: "Version number override"
required: false
run_unit_tests:
description: "Libation version number"
required: true
dotnet-version:
type: string
default: "10.x"
description: ".NET version to target"
run-unit-tests:
type: boolean
description: "Skip running unit tests"
required: false
default: true
description: "Whether to run unit tests prior to publishing."
publish-r2r:
type: boolean
description: "Whether to publish assemblies as ReadyToRun."
release:
type: boolean
description: "Whether this workflow is being called as a release"
retention-days:
type: number
description: "Number of days the artifacts are to be retained."
jobs:
jobs:
windows:
strategy:
matrix:
architecture: [x64]
uses: ./.github/workflows/build-windows.yml
with:
version_override: ${{ inputs.version_override }}
run_unit_tests: ${{ inputs.run_unit_tests }}
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
macOS:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-mac.yml
with:
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }}
secrets: inherit
linux:
strategy:
@@ -34,20 +59,11 @@ jobs:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: ubuntu-latest
libation-version: ${{ inputs.libation-version }}
dotnet-version: ${{ inputs.dotnet-version }}
run-unit-tests: ${{ inputs.run-unit-tests }}
publish-r2r: ${{ inputs.publish-r2r }}
retention-days: ${{ inputs.retention-days }}
architecture: ${{ matrix.architecture }}
OS: ${{ matrix.OS }}
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}
macos:
strategy:
matrix:
architecture: [x64, arm64]
uses: ./.github/workflows/build-linux.yml
with:
version_override: ${{ inputs.version_override }}
runs_on: macos-latest
OS: MacOS
architecture: ${{ matrix.architecture }}
run_unit_tests: ${{ inputs.run_unit_tests }}

View File

@@ -26,7 +26,7 @@ jobs:
steps:
- name: Checkout
uses: actions/checkout@v5
uses: actions/checkout@v6
- name: Set up QEMU
uses: docker/setup-qemu-action@v3

View File

@@ -8,7 +8,7 @@ on:
- "v*"
jobs:
prerelease:
runs-on: ubuntu-latest
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
@@ -31,16 +31,18 @@ jobs:
build:
needs: [prerelease]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
version_override: ${{ needs.prerelease.outputs.version }}
run_unit_tests: false
libation-version: ${{ needs.prerelease.outputs.version }}
publish-r2r: true
release: true
release:
needs: [prerelease, build]
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v5
uses: actions/download-artifact@v6
with:
path: artifacts
pattern: "*(Classic-)Libation.*"

View File

@@ -17,6 +17,6 @@ jobs:
container:
image: ghcr.io/flathub/flatpak-builder-lint:latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- name: Check the MetaInfo file
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml

View File

@@ -15,7 +15,7 @@ jobs:
validate-desktop-file:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v5
- uses: actions/checkout@v6
- run: sudo apt --yes install desktop-file-utils
- name: Check the desktop file
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop

View File

@@ -10,12 +10,31 @@ on:
branches: [master]
jobs:
get_version:
runs-on: ubuntu-slim
outputs:
version: ${{ steps.get_version.outputs.version }}
steps:
- name: Get version
id: get_version
run: |
wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj"
version="$(grep -Eio -m 1 '<Version>.*</Version>' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')"
echo "version=${version}" >> "${GITHUB_OUTPUT}"
build:
needs: [get_version]
uses: ./.github/workflows/build.yml
secrets: inherit
with:
libation-version: ${{ needs.get_version.outputs.version }}
retention-days: 14
run-unit-tests: true
docker:
needs: [get_version]
uses: ./.github/workflows/docker.yml
with:
version: ${GITHUB_SHA}
version: ${{ needs.get_version.outputs.version }}
release: false
secrets:
docker_username: ${{ secrets.DOCKERHUB_USERNAME }}

8
.gitignore vendored
View File

@@ -370,4 +370,10 @@ FodyWeavers.xsd
/__TODO.txt
/DataLayer/LibationContext.db
*/bin-Avalonia
*/bin-Avalonia
# macOS Directory Info
.DS_Store
# JetBrains Rider Settings
**/.idea/

View File

@@ -3,8 +3,8 @@
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",
"LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb",
"LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm",
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz"
"MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg"
}

View File

@@ -1,16 +1,19 @@
# Dockerfile
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:9.0 AS build
FROM --platform=${BUILDPLATFORM} mcr.microsoft.com/dotnet/sdk:10.0 AS build
ARG TARGETARCH
COPY Source /Source
RUN dotnet publish \
/Source/LibationCli/LibationCli.csproj \
--os linux \
--arch ${TARGETARCH} \
--configuration Release \
--output /Source/bin/Publish/Linux-chardonnay \
-p:PublishProfile=/Source/LibationCli/Properties/PublishProfiles/LinuxProfile.pubxml
-p:PublishProtocol=FileSystem \
-p:PublishReadyToRun=true \
-p:SelfContained=true
FROM mcr.microsoft.com/dotnet/runtime:9.0
FROM mcr.microsoft.com/dotnet/runtime:10.0
ARG USER_UID=1001
ARG USER_GID=1001

View File

@@ -10,12 +10,10 @@
- [Files and folders](#files-and-folders)
- [Settings](#settings)
- [Custom File Naming](NamingTemplates.md)
- [Command Line Interface](#command-line-interface)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
- [Command Line Interface](#command-line-interface)
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
@@ -39,55 +37,6 @@ In addition to the options that are enabled if you allow Libation to "fix up" th
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
```
help
libationcli --help
verb-specific help
libationcli scan --help
scan all libraries
libationcli scan
scan only libraries for specific accounts
libationcli scan nickname1 nickname2
convert all m4b files to mp3
libationcli convert
liberate all books and pdfs
libationcli liberate
liberate pdfs only
libationcli liberate --pdf
libationcli liberate -p
export library to file
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
@@ -109,4 +58,107 @@ The below video demonstrates using the theme editor to make changes to the Dark
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
#### Help
```console
libationcli --help
```
#### Verb-Specific Help
```console
libationcli scan --help
```
#### Scan All Libraries
```console
libationcli scan
```
#### Scan Only Libraries for Specific Accounts
```console
libationcli scan nickname1 nickname2
```
#### Convert All m4b Files to mp3
```console
libationcli convert
```
#### Liberate All Books and Pdfs
```console
libationcli liberate
```
#### Liberate Pdfs Only
```console
libationcli liberate --pdf
libationcli liberate -p
```
#### Force Book(s) to Re-Liberate
```console
libationcli liberate --force
libationcli liberate -f
```
#### Liberate using a license file from the `get-license` command
```console
libationcli liberate --license /path/to/license.lic
libationcli liberate --license - < /path/to/license.lic
```
#### List Libation Settings
```console
libationcli get-setting
libationcli get-setting -b
libationcli get-setting FileDownloadQuality
```
#### Override Libation Settings for the Command
```console
libationcli liberate B017V4IM1G -override FileDownloadQuality=Normal
libationcli liberate B017V4IM1G -o FileDownloadQuality=normal -o UseWidevine=true Request_xHE_AAC=true -f
```
#### Copy the Local SQLite Database to Postgres
```console
libationcli copydb --connectionString "my postgres connection string"
libationcli copydb -c "my postgres connection string"
```
#### Export Library to File
```console
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
```
#### Set Download Status
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
```console
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
#### Get a Content License Without Downloading
```console
libationcli get-license B017V4IM1G
```
#### Example Powershell Script to Download Four Differenf Versions f the Same Book
```powershell
$asin="B017V4IM1G"
$xHE_AAC=@('true', 'false')
$Qualities=@('Normal', 'High')
foreach($q in $Qualities){
foreach($x in $xHE_AAC){
$license = ./libationcli get-license $asin --override FileDownloadQuality=$q --override Request_xHE_AAC=$x
echo $($license | ConvertFrom-Json).ContentMetadata.content_reference
echo $license | ./libationcli liberate --force --license -
}
}
```

View File

@@ -58,6 +58,11 @@ This is a proprietary codec created by the [Fraunhofer Institute for Integrated
xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11)
Note that the xHE-AAC files authored by Audible have some USAC conformance errors including:
- Number of samples per frame not matching the UsacConfig coreCoderFrameLength value.
- Disagreement between stts and UsacFrame usacIndependencyFlag value.
- Stts indicating a frame is an immediate play-out frame, but USAC AudioPreRoll is absent.
## Dolby Atmos
Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4.

View File

@@ -81,17 +81,39 @@ Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) w
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|\<has PROPERTY-\>...\<-has\>|Only include if the PROPERTY has a value (i.e. not null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
For example, <if podcast-\>\<series\>\<-if podcast\> will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a '!' symbol before the opening tag name.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|Inverted Tag|Description|Type|
|-|-|-|
|\<!if series-\>...\<-if series\>|Only include if *not* part of a book series or podcast|Conditional|
|\<!if podcast-\>...\<-if podcast\>|Only include if *not* part of a podcast|Conditional|
|\<!if bookseries-\>...\<-if bookseries\>|Only include if *not* part of a book series|Conditional|
|\<!if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is *not* a podcast series parent|Conditional|
|\<!has PROPERTY-\>...\<-has\>|Only include if the PROPERTY *does not* have a value (i.e. is null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
\<if podcast-\>Podcasts<-if podcast\>\<!if podcast-\>Books\<-if podcast\>\\\<title\>
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
This example will add a number if the `<series#\>` tag has a value:
`<has series#><series#><-has>`
This example will put non-series books in a "Standalones" folder:
`<!if series->Standalones/<-if series>`
And this example will customize the title based on whether the book has a subtitle:
`<audible title><has audible subtitle->-<audible subtitle><-has>`
# Tag Formatters
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.

View File

@@ -28,14 +28,6 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
ARCH=$(echo $ARCH | sed 's/x64/amd64/')
DEB_DIR=./deb
@@ -113,6 +105,7 @@ Essential: no
Priority: optional
Maintainer: github.com/rmcrackan
Description: liberate your audiobooks
Recommends: libgtk-3-0, libwebkit2gtk-4.1-0
" >> $FOLDER_DEBIAN/control
echo "Changing permissions for pre- and post-install files..."

View File

@@ -3,6 +3,7 @@
BIN_DIR=$1; shift
VERSION=$1; shift
ARCH=$1; shift
SIGN_WITH_KEY=$1; shift
if [ -z "$BIN_DIR" ]
then
@@ -28,12 +29,9 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" $ARCH
if [ "$SIGN_WITH_KEY" != "true" ]
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information."
fi
BUNDLE=./Libation.app
@@ -74,6 +72,15 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
echo "Moving Info.plist file..."
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
echo "Moving Libation_DS_Store file..."
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
echo "Moving background.png file..."
mv $BUNDLE_MACOS/background.png ./background.png
echo "Moving background.png file..."
mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements
PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/')
echo "Set LSArchitecturePriority to $PLIST_ARCH"
sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
@@ -81,27 +88,45 @@ sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist
echo "Set CFBundleVersion to $VERSION"
sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist
delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json')
for n in "${delfiles[@]}"
do
echo "Deleting $n"
rm $BUNDLE_MACOS/$n
done
APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz
DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg"
echo "Signing executables in: $BUNDLE"
codesign --force --deep -s - $BUNDLE
all_identities=$(security find-identity -v -p codesigning)
identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p')
echo "Creating app bundle: $APP_FILE"
tar -czvf $APP_FILE $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing executables in: $BUNDLE"
codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE"
codesign --verify --verbose "$BUNDLE"
else
echo "Signing with empty key: $BUNDLE"
codesign --force --deep -s - $BUNDLE
fi
mkdir bundle
echo "moving to ./bundle/$APP_FILE"
mv $APP_FILE ./bundle/$APP_FILE
echo "Creating app disk image: $DMG_FILE"
mkdir Libation
mv $BUNDLE ./Libation/$BUNDLE
mv Libation_DS_Store Libation/.DS_Store
mkdir Libation/.background
mv background.png Libation/.background/
ln -s /Applications "./Libation/ "
mkdir ./bundle
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
# Create a .DS_Store by:
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
# - Open the folder and edit it to your liking.
# - Copy the .DS_Store from the directory and save it to Libation_DS_Store
rm -r $BUNDLE
if [ "$SIGN_WITH_KEY" == "true" ]; then
echo "Signing $DMG_FILE"
codesign --deep --sign "${identity}" "./bundle/$DMG_FILE"
fi
echo "Done!"

View File

@@ -28,14 +28,6 @@ then
exit
fi
contains() { case "$1" in *"$2"*) true ;; *) false ;; esac }
if ! contains "$BIN_DIR" "$ARCH"
then
echo "This script must be called with a Libation binaries for ${ARCH}."
exit
fi
BASEDIR=$(pwd)
delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json')
@@ -62,7 +54,7 @@ License: GPLv3+
URL: https://github.com/rmcrackan/Libation
Source0: https://github.com/rmcrackan/Libation
Requires: bash
Requires: bash gtk3 webkit2gtk4.1
%define __os_install_post %{nil}

View File

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

View File

@@ -26,7 +26,17 @@ namespace AaxDecrypter
protected string OutputDirectory { get; }
public IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position;
protected virtual long InputFilePosition
{
get
{
//Use try/catch instread of checking CanRead to avoid
//a race with the background download completing
//between the check and the Position call.
try { return InputFileStream.Position; }
catch { return InputFileStream.Length; }
}
}
private bool downloadFinished;
private NetworkFileStreamPersister? m_nfsPersister;

View File

@@ -15,6 +15,7 @@ namespace AaxDecrypter
KeyPart2 = keyPart2;
}
[Newtonsoft.Json.JsonConstructor]
public KeyData(string keyPart1, string? keyPart2 = null)
{
ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1));

View File

@@ -209,6 +209,12 @@ namespace AaxDecrypter
}
}
}
catch (Exception ex)
{
//Don't throw from DownloadTask.
//This task gets awaited in Dispose() and we don't want to have an unhandled exception there.
Serilog.Log.Error(ex, "An error was encountered during the download process.");
}
finally
{
_writeFile.Dispose();
@@ -306,7 +312,7 @@ namespace AaxDecrypter
if (WritePosition > endPosition)
throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10}).");
}
catch (TaskCanceledException)
catch (OperationCanceledException)
{
Serilog.Log.Information("Download was cancelled");
}
@@ -402,7 +408,7 @@ namespace AaxDecrypter
*/
protected override void Dispose(bool disposing)
{
if (disposing && !disposed)
if (disposing && !Interlocked.CompareExchange(ref disposed, true, false))
{
_cancellationSource.Cancel();
DownloadTask?.GetAwaiter().GetResult();
@@ -413,7 +419,6 @@ namespace AaxDecrypter
OnUpdate(waitForWrite: true);
}
disposed = true;
base.Dispose(disposing);
}

View File

@@ -1,6 +1,6 @@
using FileManager;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase

View File

@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<Version>12.5.2.1</Version>
<TargetFramework>net10.0</TargetFramework>
<Version>12.7.4.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />
<!-- Do not remove unused Serilog.Sinks -->
<!-- Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. -->
<PackageReference Include="Serilog.Sinks.Console" Version="6.0.0" />
<PackageReference Include="Serilog.Sinks.Console" Version="6.1.1" />
<PackageReference Include="Serilog.Sinks.File" Version="7.0.0" />
</ItemGroup>
<ItemGroup>

View File

@@ -79,8 +79,18 @@ namespace AppScaffolding
}
/// <summary>most migrations go in here</summary>
public static void RunPostConfigMigrations(Configuration config)
public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false)
{
if (ephemeralSettings)
{
var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath));
config.LoadEphemeralSettings(settings);
}
else
{
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
}
DeleteOpenSqliteFiles(config);
AudibleApiStorage.EnsureAccountsSettingsFileExists();
//
@@ -93,6 +103,19 @@ namespace AppScaffolding
Migrations.migrate_to_v12_0_1(config);
}
/// <summary>
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
/// </summary>
private static void DeleteOpenSqliteFiles(Configuration config)
{
var walFile = SqliteStorage.DatabasePath + "-wal";
var shmFile = SqliteStorage.DatabasePath + "-shm";
if (File.Exists(walFile))
FileManager.FileUtility.SaferDelete(walFile);
if (File.Exists(shmFile))
FileManager.FileUtility.SaferDelete(shmFile);
}
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
{
@@ -150,7 +173,7 @@ namespace AppScaffolding
new JObject
{
// for this sink to work, a path must be provided. we override this below
{ "path", Path.Combine(config.LibationFiles, "Log.log") },
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
{ "rollingInterval", "Month" },
// Serilog template formatting examples
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
@@ -234,6 +257,7 @@ namespace AppScaffolding
//Log.Logger.Here().Debug("Begin Libation. Debug with line numbers");
}
#nullable enable
private static void logStartupState(Configuration config)
{
#if DEBUG
@@ -247,9 +271,11 @@ namespace AppScaffolding
// begin logging session with a form feed
Log.Logger.Information("\r\n\f");
static int fileCount(FileManager.LongPath longPath)
static int fileCount(FileManager.LongPath? longPath)
{
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
if (longPath is null)
return -1;
try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); }
catch { return -1; }
}
@@ -289,8 +315,8 @@ namespace AppScaffolding
if (InteropFactory.InteropFunctionsType is null)
Serilog.Log.Logger.Warning("WARNING: OSInteropProxy.InteropFunctionsType is null");
}
private static void wireUpSystemEvents(Configuration configuration)
#nullable restore
private static void wireUpSystemEvents(Configuration configuration)
{
LibraryCommands.LibrarySizeChanged += (object _, List<DataLayer.LibraryBook> libraryBooks)
=> SearchEngineCommands.FullReIndex(libraryBooks);
@@ -433,8 +459,8 @@ namespace AppScaffolding
const string FILENAME_V1 = "FileLocations.json";
const string FILENAME_V2 = "FileLocationsV2.json";
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2);
var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1);
var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2);
if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1))
{

View File

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

View File

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

View File

@@ -69,10 +69,10 @@ namespace ApplicationServices
return Count;
}
public void Execute()
public async Task ExecuteAsync()
{
foreach (var a in actionSets)
a.LibraryBooks.UpdateBookStatus(a.newStatus);
await a.LibraryBooks.UpdateBookStatusAsync(a.newStatus);
}
}
}

View File

@@ -1,21 +1,40 @@
using System;
using System.Collections.Generic;
using DataLayer;
using DataLayer;
using LibationFileManager;
using Microsoft.EntityFrameworkCore;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices
{
public static class DbContexts
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
=> LibationContext.Create(SqliteStorage.ConnectionString);
public static class DbContexts
{
/// <summary>Use for fully functional context, incl. SaveChanges(). For query-only, use the other method</summary>
public static LibationContext GetContext()
{
var context = !string.IsNullOrEmpty(Configuration.Instance.PostgresqlConnectionString)
? LibationContextFactory.CreatePostgres(Configuration.Instance.PostgresqlConnectionString)
: LibationContextFactory.CreateSqlite(SqliteStorage.ConnectionString);
context.Database.Migrate();
return context;
}
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{
/// <summary>Use for full library querying. No lazy loading</summary>
public static List<LibraryBook> GetLibrary_Flat_NoTracking(bool includeParents = false)
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
}
public static List<LibraryBook> GetDeletedLibraryBooks()
{
using var context = GetContext();
return context.GetDeletedLibraryBooks();
}
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
{
using var context = GetContext();
return context.GetLibrary_Flat_NoTracking(includeParents);
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
}
}
}

View File

@@ -1,9 +1,4 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using AudibleApi;
using AudibleApi;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
@@ -11,8 +6,14 @@ using Dinah.Core.Logging;
using DtoImporterService;
using FileManager;
using LibationFileManager;
using Microsoft.Extensions.DependencyModel;
using Newtonsoft.Json.Linq;
using Serilog;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using static DtoImporterService.PerfLogger;
#nullable enable
@@ -70,7 +71,7 @@ namespace ApplicationServices
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -141,16 +142,16 @@ namespace ApplicationServices
return default;
Log.Logger.Information("Begin long-running import");
logTime($"pre {nameof(importIntoDbAsync)}");
newCount = await importIntoDbAsync(importItems);
logTime($"post {nameof(importIntoDbAsync)}");
logTime($"pre {nameof(ImportIntoDbAsync)}");
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
logTime($"post {nameof(ImportIntoDbAsync)}");
Log.Logger.Information($"Import complete. New count {newCount}");
return (totalCount, newCount);
}
catch (AudibleApi.Authentication.LoginFailedException lfEx)
{
lfEx.SaveFiles(Configuration.Instance.LibationFiles);
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
// nuget Serilog.Exceptions would automatically log custom properties
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
@@ -180,7 +181,8 @@ namespace ApplicationServices
}
}
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
{
ArgumentValidator.EnsureNotNull(item, "item");
ArgumentValidator.EnsureNotNull(accountId, "accountId");
@@ -203,35 +205,23 @@ namespace ApplicationServices
return 0;
}
using var context = DbContexts.GetContext();
return DoDbSizeChangeOperation(ctx =>
{
var bookImporter = new BookImporter(ctx);
bookImporter.Import(importItems);
var book = ctx.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId);
var bookImporter = new BookImporter(context);
await Task.Run(() => bookImporter.Import(importItems));
var book = await Task.Run(() => context.LibraryBooks.FirstOrDefault(lb => lb.Book.AudibleProductId == importItem.DtoItem.ProductId));
if (book is null)
{
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
context.LibraryBooks.Add(book);
}
else
{
book.AbsentFromLastScan = false;
}
try
{
int qtyChanged = await Task.Run(() => SaveContext(context));
if (qtyChanged > 0)
await Task.Run(() => finalizeLibrarySizeChange(context));
return qtyChanged;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error adding single library book to DB. {@DebugInfo}", new { item, accountId, localeName });
return 0;
}
}
if (book is null)
{
book = new LibraryBook(bookImporter.Cache[importItem.DtoItem.ProductId], importItem.DtoItem.DateAdded, importItem.AccountId);
ctx.LibraryBooks.Add(book);
}
else
{
book.AbsentFromLastScan = false;
}
});
}
private static LogArchiver? openLogArchive(string? archivePath)
{
@@ -268,7 +258,7 @@ namespace ApplicationServices
await using LogArchiver? archiver
= Log.Logger.IsDebugEnabled()
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip"))
? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip"))
: default;
archiver?.DeleteAllButNewestN(20);
@@ -347,23 +337,21 @@ namespace ApplicationServices
}
}
private static async Task<int> importIntoDbAsync(List<ImportItem> importItems)
private static async Task<int> ImportIntoDbAsync(List<ImportItem> importItems) => await Task.Run(() => importIntoDb(importItems));
private static int importIntoDb(List<ImportItem> importItems)
{
logTime("importIntoDbAsync -- pre db");
using var context = DbContexts.GetContext();
var libraryBookImporter = new LibraryBookImporter(context);
var newCount = await Task.Run(() => libraryBookImporter.Import(importItems));
logTime("importIntoDbAsync -- post Import()");
int qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
logTime("importIntoDbAsync -- pre db");
// this is any changes at all to the database, not just new books
if (qtyChanges > 0)
await Task.Run(() => finalizeLibrarySizeChange(context));
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
int newCount = 0;
return newCount;
}
DoDbSizeChangeOperation(ctx =>
{
var libraryBookImporter = new LibraryBookImporter(ctx);
newCount = libraryBookImporter.Import(importItems);
logTime("importIntoDbAsync -- post Import()");
});
return newCount;
}
public static int SaveContext(LibationContext context)
{
@@ -389,57 +377,58 @@ namespace ApplicationServices
#region remove/restore books
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
public static int RemoveBook(this LibraryBook idToRemove) => removeBooks(new[] { idToRemove });
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
{
try
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
{
{
lb.IsDeleted = true;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
return qtyChanges;
}
public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove));
private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
});
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error removing books");
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
public static int RestoreBooks(this IEnumerable<LibraryBook> libraryBooks)
{
try
public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in libraryBooks)
{
lb.IsDeleted = false;
context.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
return qtyChanges;
return DoDbSizeChangeOperation(ctx =>
{
ctx.LibraryBooks.RemoveRange(libraryBooks);
ctx.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
});
}
catch (Exception ex)
{
@@ -448,36 +437,40 @@ namespace ApplicationServices
}
}
public static int PermanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
static int DoDbSizeChangeOperation(Action<LibationContext> action)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
try
{
int qtyChanges;
List<LibraryBook>? library;
using var context = DbContexts.GetContext();
using (var context = DbContexts.GetContext())
{
action?.Invoke(context);
context.LibraryBooks.RemoveRange(libraryBooks);
context.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
qtyChanges = SaveContext(context);
logTime("importIntoDbAsync -- post SaveChanges");
library = qtyChanges == 0 ? null : context.GetLibrary_Flat_NoTracking(includeParents: true);
}
var qtyChanges = context.SaveChanges();
if (qtyChanges > 0)
finalizeLibrarySizeChange(context);
if (library is not null)
finalizeLibrarySizeChange(library);
logTime("importIntoDbAsync -- post finalizeLibrarySizeChange");
return qtyChanges;
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error restoring books");
throw;
}
}
}
catch (Exception ex)
{
Log.Logger.Error(ex, "Error performing DB Size change operation");
throw;
}
}
#endregion
// call this whenever books are added or removed from library
private static void finalizeLibrarySizeChange(LibationContext context)
private static void finalizeLibrarySizeChange(List<LibraryBook> library)
{
var library = context.GetLibrary_Flat_NoTracking(includeParents: true);
LibrarySizeChanged?.Invoke(null, library);
}
@@ -490,21 +483,21 @@ namespace ApplicationServices
public static event EventHandler<IEnumerable<LibraryBook>>? BookUserDefinedItemCommitted;
#region Update book details
public static int UpdateUserDefinedItem(
public static async Task<int> UpdateUserDefinedItemAsync(
this LibraryBook lb,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating? rating = null)
=> new[] { lb }.UpdateUserDefinedItem(tags, bookStatus, pdfStatus, rating);
=> await UpdateUserDefinedItemAsync([lb], tags, bookStatus, pdfStatus, rating);
public static int UpdateUserDefinedItem(
public static async Task<int> UpdateUserDefinedItemAsync(
this IEnumerable<LibraryBook> lb,
string? tags = null,
LiberatedStatus? bookStatus = null,
LiberatedStatus? pdfStatus = null,
Rating? rating = null)
=> updateUserDefinedItem(
=> await UpdateUserDefinedItemAsync(
lb,
udi => {
// blank tags are expected. null tags are not
@@ -521,52 +514,54 @@ namespace ApplicationServices
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> await lb.UpdateUserDefinedItemAsync(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static int UpdateBookStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);
public static async Task<int> UpdateBookStatusAsync(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static async Task<int> UpdateBookStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus bookStatus)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.BookStatus = bookStatus);
public static int UpdatePdfStatus(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdatePdfStatus(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.SetPdfStatus(pdfStatus));
public static async Task<int> UpdatePdfStatusAsync(this LibraryBook libraryBook, LiberatedStatus pdfStatus)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static async Task<int> UpdatePdfStatusAsync(this IEnumerable<LibraryBook> libraryBooks, LiberatedStatus pdfStatus)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.SetPdfStatus(pdfStatus));
public static int UpdateTags(this LibraryBook libraryBook, string tags)
=> libraryBook.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static int UpdateTags(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> libraryBooks.UpdateUserDefinedItem(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this LibraryBook libraryBook, string tags)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static int UpdateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> libraryBook.updateUserDefinedItem(action);
public static int UpdateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> libraryBooks.updateUserDefinedItem(action);
public static async Task<int> UpdateUserDefinedItemAsync(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> await UpdateUserDefinedItemAsync([libraryBook], action);
private static int updateUserDefinedItem(this LibraryBook libraryBook, Action<UserDefinedItem> action) => new[] { libraryBook }.updateUserDefinedItem(action);
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
=> Task.Run(() => libraryBooks.updateUserDefinedItem(action));
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
using var context = DbContexts.GetContext();
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
int qtyChanges;
using (var context = DbContexts.GetContext())
{
action?.Invoke(book.Book.UserDefinedItem);
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
{
action?.Invoke(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
var udiEntity = context.Entry(book.Book.UserDefinedItem);
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
if (udiEntity.Reference(udi => udi.Rating).TargetEntry is Microsoft.EntityFrameworkCore.ChangeTracking.EntityEntry<Rating> ratingEntry)
ratingEntry.State = Microsoft.EntityFrameworkCore.EntityState.Modified;
}
var qtyChanges = context.SaveChanges();
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
@@ -582,7 +577,7 @@ namespace ApplicationServices
// must be here instead of in db layer due to AaxcExists
public static LiberatedStatus Liberated_Status(Book book)
=> book.Audio_Exists() ? book.UserDefinedItem.BookStatus
=> book.AudioExists ? book.UserDefinedItem.BookStatus
: AudibleFileStorage.AaxcExists(book.AudibleProductId) ? LiberatedStatus.PartialDownload
: LiberatedStatus.NotLiberated;
@@ -591,7 +586,7 @@ namespace ApplicationServices
// below are queries, not commands. maybe I should make a LibraryQueries. except there's already one of those...
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable)
public record LibraryStats(int booksFullyBackedUp, int booksDownloadedOnly, int booksNoProgress, int booksError, int booksUnavailable, int pdfsDownloaded, int pdfsNotDownloaded, int pdfsUnavailable, IEnumerable<LibraryBook> LibraryBooks)
{
public int PendingBooks => booksNoProgress + booksDownloadedOnly;
public bool HasPendingBooks => PendingBooks > 0;
@@ -650,7 +645,7 @@ namespace ApplicationServices
var pdfResults = libraryBooks
.AsParallel()
.Where(lb => lb.Book.HasPdf())
.Where(lb => lb.Book.HasPdf)
.Select(lb => new { absent = lb.AbsentFromLastScan, status = Pdf_Status(lb.Book) })
.ToList();
@@ -660,7 +655,7 @@ namespace ApplicationServices
Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable });
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable);
return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks);
}
}
}

View File

@@ -1,11 +1,12 @@
using System;
using System.Collections.Generic;
using System.Linq;
using ClosedXML.Excel;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ApplicationServices
{
@@ -149,12 +150,12 @@ namespace ApplicationServices
Locale = a.Book.Locale,
Title = a.Book.Title,
Subtitle = a.Book.Subtitle,
AuthorNames = a.Book.AuthorNames(),
NarratorNames = a.Book.NarratorNames(),
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf(),
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
@@ -208,19 +209,11 @@ namespace ApplicationServices
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Library");
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Library");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new[] {
nameof(ExportDto.Account),
nameof(ExportDto.DateAdded),
@@ -261,81 +254,71 @@ namespace ApplicationServices
nameof(ExportDto.ChannelCount),
nameof(ExportDto.BitRate)
};
var col = 0;
int rowIndex = 1, col = 1;
var headerRow = sheet.Row(rowIndex++);
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
var name = ExportDto.GetName(c);
cell.SetCellValue(name);
cell.CellStyle = detailSubtotalCellStyle;
var headerCell = headerRow.Cell(col++);
headerCell.Value = ExportDto.GetName(c);
headerCell.Style.Font.Bold = true;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
rowIndex++;
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var dto in dtos)
{
col = 0;
row = sheet.CreateRow(rowIndex++);
col = 1;
var row = sheet.Row(rowIndex++);
row.CreateCell(col++).SetCellValue(dto.Account);
row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle;
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
row.CreateCell(col++).SetCellValue(dto.Locale);
row.CreateCell(col++).SetCellValue(dto.Title);
row.CreateCell(col++).SetCellValue(dto.Subtitle);
row.CreateCell(col++).SetCellValue(dto.AuthorNames);
row.CreateCell(col++).SetCellValue(dto.NarratorNames);
row.CreateCell(col++).SetCellValue(dto.LengthInMinutes);
row.CreateCell(col++).SetCellValue(dto.Description);
row.CreateCell(col++).SetCellValue(dto.Publisher);
row.CreateCell(col++).SetCellValue(dto.HasPdf);
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall);
row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance);
row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory);
row.CreateCell(col++).SetCellValue(dto.PictureId);
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle;
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
row.CreateCell(col++).SetCellValue(dto.MyRatingOverall);
row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance);
row.CreateCell(col++).SetCellValue(dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.Language);
row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle;
row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion);
row.CreateCell(col++).SetCellValue(dto.IsFinished);
row.CreateCell(col++).SetCellValue(dto.IsSpatial);
row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion);
row.CreateCell(col++).SetCellValue(dto.CodecString);
row.CreateCell(col++).SetCellValue(dto.SampleRate);
row.CreateCell(col++).SetCellValue(dto.ChannelCount);
row.CreateCell(col++).SetCellValue(dto.BitRate);
row.Cell(col++).Value = dto.Account;
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
row.Cell(col++).Value = dto.AudibleProductId;
row.Cell(col++).Value = dto.Locale;
row.Cell(col++).Value = dto.Title;
row.Cell(col++).Value = dto.Subtitle;
row.Cell(col++).Value = dto.AuthorNames;
row.Cell(col++).Value = dto.NarratorNames;
row.Cell(col++).Value = dto.LengthInMinutes;
row.Cell(col++).Value = dto.Description;
row.Cell(col++).Value = dto.Publisher;
row.Cell(col++).Value = dto.HasPdf;
row.Cell(col++).Value = dto.SeriesNames;
row.Cell(col++).Value = dto.SeriesOrder;
row.Cell(col++).Value = dto.CommunityRatingOverall;
row.Cell(col++).Value = dto.CommunityRatingPerformance;
row.Cell(col++).Value = dto.CommunityRatingStory;
row.Cell(col++).Value = dto.PictureId;
row.Cell(col++).Value = dto.IsAbridged;
row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
row.Cell(col++).Value = dto.CategoriesNames;
row.Cell(col++).Value = dto.MyRatingOverall;
row.Cell(col++).Value = dto.MyRatingPerformance;
row.Cell(col++).Value = dto.MyRatingStory;
row.Cell(col++).Value = dto.MyLibationTags;
row.Cell(col++).Value = dto.BookStatus;
row.Cell(col++).Value = dto.PdfStatus;
row.Cell(col++).Value = dto.ContentType;
row.Cell(col++).Value = dto.Language;
row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
row.Cell(col++).Value = dto.LastDownloadedVersion;
row.Cell(col++).Value = dto.IsFinished;
row.Cell(col++).Value = dto.IsSpatial;
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.CodecString;
row.Cell(col++).Value = dto.SampleRate;
row.Cell(col++).Value = dto.ChannelCount;
row.Cell(col++).Value = dto.BitRate;
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
workbook.SaveAs(saveFilePath);
}
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate)
=> nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value)
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt)
=> nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value)
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat)
=> nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value)
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
{
cell.Value = value;
cell.Style.DateFormat.Format = dateFormat;
}
}
}

View File

@@ -1,10 +1,11 @@
using AudibleApi.Common;
using ClosedXML.Excel;
using CsvHelper;
using DataLayer;
using Newtonsoft.Json.Linq;
using NPOI.XSSF.UserModel;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
namespace ApplicationServices
@@ -16,19 +17,10 @@ namespace ApplicationServices
if (!records.Any())
return;
using var workbook = new XSSFWorkbook();
var sheet = workbook.CreateSheet("Records");
var detailSubtotalFont = workbook.CreateFont();
detailSubtotalFont.IsBold = true;
var detailSubtotalCellStyle = workbook.CreateCellStyle();
detailSubtotalCellStyle.SetFont(detailSubtotalFont);
using var workbook = new XLWorkbook();
var worksheet = workbook.AddWorksheet("Records");
// headers
var rowIndex = 0;
var row = sheet.CreateRow(rowIndex);
var columns = new List<string>
{
nameof(Type.Name),
@@ -49,56 +41,52 @@ namespace ApplicationServices
if (records.OfType<Clip>().Any())
columns.Add(nameof(Clip.Title));
var col = 0;
int rowIndex = 1, col = 1;
var headerRow = worksheet.Row(rowIndex++);
foreach (var c in columns)
{
var cell = row.CreateCell(col++);
cell.SetCellValue(c);
cell.CellStyle = detailSubtotalCellStyle;
var headerCell = headerRow.Cell(col++);
headerCell.Value = c;
headerCell.Style.Font.Bold = true;
}
var dateFormat = workbook.CreateDataFormat();
var dateStyle = workbook.CreateCellStyle();
dateStyle.DataFormat = dateFormat.GetFormat("MM/dd/yyyy HH:mm:ss");
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var record in records)
{
col = 0;
col = 1;
var row = worksheet.Row(rowIndex++);
row = sheet.CreateRow(++rowIndex);
row.CreateCell(col++).SetCellValue(record.GetType().Name);
var dateCreatedCell = row.CreateCell(col++);
dateCreatedCell.CellStyle = dateStyle;
dateCreatedCell.SetCellValue(record.Created.DateTime);
row.CreateCell(col++).SetCellValue(record.Start.TotalMilliseconds);
row.Cell(col++).Value = record.GetType().Name;
row.Cell(col++).SetDate(record.Created.DateTime, dateFormat);
row.Cell(col++).Value = record.Start.TotalMilliseconds;
if (record is IAnnotation annotation)
{
row.CreateCell(col++).SetCellValue(annotation.AnnotationId);
var lastModifiedCell = row.CreateCell(col++);
lastModifiedCell.CellStyle = dateStyle;
lastModifiedCell.SetCellValue(annotation.LastModified.DateTime);
row.Cell(col++).Value = annotation.AnnotationId;
row.Cell(col++).SetDate(annotation.LastModified.DateTime, dateFormat);
if (annotation is IRangeAnnotation rangeAnnotation)
{
row.CreateCell(col++).SetCellValue(rangeAnnotation.End.TotalMilliseconds);
row.CreateCell(col++).SetCellValue(rangeAnnotation.Text);
row.Cell(col++).Value = rangeAnnotation.End.TotalMilliseconds;
row.Cell(col++).Value = rangeAnnotation.Text;
if (rangeAnnotation is Clip clip)
row.CreateCell(col++).SetCellValue(clip.Title);
row.Cell(col++).Value = clip.Title;
}
}
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
workbook.SaveAs(saveFilePath);
}
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
{
cell.Value = value;
cell.Style.DateFormat.Format = dateFormat;
}
public static void ToJson(string saveFilePath, LibraryBook libraryBook, IEnumerable<IRecord> records)
{
if (!records.Any())

View File

@@ -18,68 +18,64 @@ namespace AudibleUtilities
public string AccountId { get; }
// user-friendly, non-canonical name. mutable
private string _accountName;
public string AccountName
{
get => _accountName;
get => field;
set
{
if (string.IsNullOrWhiteSpace(value))
return;
var v = value.Trim();
if (v == _accountName)
if (v == field)
return;
_accountName = v;
field = v;
update();
}
}
// whether to include this account when scanning libraries.
// technically this is an app setting; not an attribute of account. but since it's managed with accounts, it makes sense to put this exception-to-the-rule here
private bool _libraryScan = true;
public bool LibraryScan
{
get => _libraryScan;
get => field;
set
{
if (value == _libraryScan)
if (value == field)
return;
_libraryScan = value;
field = value;
update();
}
}
private string _decryptKey = "";
/// <summary>aka: activation bytes</summary>
public string DecryptKey
{
get => _decryptKey;
get => field ?? "";
set
{
var v = (value ?? "").Trim();
if (v == _decryptKey)
if (v == field)
return;
_decryptKey = v;
field = v;
update();
}
}
private Identity _identity;
public Identity IdentityTokens
{
get => _identity;
get => field;
set
{
if (_identity is null && value is null)
if (field is null && value is null)
return;
if (_identity is not null)
_identity.Updated -= update;
if (field is not null)
field.Updated -= update;
if (value is not null)
value.Updated += update;
_identity = value;
field = value;
update();
}
}

View File

@@ -6,6 +6,7 @@ using AudibleApi.Authorization;
using Dinah.Core;
using Newtonsoft.Json;
#nullable enable
namespace AudibleUtilities
{
// 'AccountsSettings' is intentionally NOT IEnumerable<> so that properties can be added/extended
@@ -14,8 +15,8 @@ namespace AudibleUtilities
// JSON : Array (properties on the collection will not be serialized)
public class AccountsSettings : IUpdatable
{
public event EventHandler Updated;
private void update(object sender = null, EventArgs e = null)
public event EventHandler? Updated;
private void update(object? sender = null, EventArgs? e = null)
{
foreach (var account in Accounts)
validate(account);
@@ -48,9 +49,9 @@ namespace AudibleUtilities
}
}
private string _cdm;
private string? _cdm;
[JsonProperty]
public string Cdm
public string? Cdm
{
get => _cdm;
set
@@ -68,7 +69,7 @@ namespace AudibleUtilities
#endregion
#region de/serialize
public static AccountsSettings FromJson(string json)
public static AccountsSettings? FromJson(string json)
=> JsonConvert.DeserializeObject<AccountsSettings>(json, Identity.GetJsonSerializerSettings());
public string ToJson(Formatting formatting = Formatting.Indented)
@@ -107,7 +108,7 @@ namespace AudibleUtilities
account.Updated += update;
}
public Account GetAccount(string accountId, string locale)
public Account? GetAccount(string accountId, string? locale)
{
if (locale is null)
return null;

View File

@@ -25,7 +25,7 @@ namespace AudibleUtilities
public static class AudibleApiStorage
{
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json");
public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json");
public static event EventHandler<AccountSettingsLoadErrorEventArgs> LoadError;

View File

@@ -28,10 +28,11 @@ namespace AudibleUtilities
if (items.Any(i => string.IsNullOrWhiteSpace(i.ProductId)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with null or blank {nameof(Item.ProductId)}", nameof(items)));
if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
//// unfortunately, an actual user has a title with a beginning-of-time 'purchase_date'
//if (items.Any(i => i.DateAdded < new DateTime(1980, 1, 1)))
// exceptions.Add(new ArgumentException($"Collection contains item(s) with invalid {nameof(Item.DateAdded)}", nameof(items)));
return exceptions;
return exceptions;
}
}
public class BookValidator : IValidator
@@ -91,7 +92,7 @@ namespace AudibleUtilities
if (distinct.Any(s => s.SeriesId is null))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesId)}", nameof(items)));
//// unfortunately, a user has a series with no name
//// unfortunately, an actual user has a series with no name
//if (distinct.Any(s => s.SeriesName is null))
// exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Series)} with null {nameof(Series.SeriesName)}", nameof(items)));

View File

@@ -1,12 +1,12 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.4.1" />
<PackageReference Include="Google.Protobuf" Version="3.32.0" />
<PackageReference Include="AudibleApi" Version="10.1.0.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,38 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,494 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20251027224441_InitialPostgres")]
partial class InitialPostgres
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("integer");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("integer");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
b.Property<string>("AudibleProductId")
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsAbridged")
.HasColumnType("boolean");
b.Property<bool>("IsSpatial")
.HasColumnType("boolean");
b.Property<string>("Language")
.HasColumnType("text");
b.Property<int>("LengthInMinutes")
.HasColumnType("integer");
b.Property<string>("Locale")
.HasColumnType("text");
b.Property<string>("PictureId")
.HasColumnType("text");
b.Property<string>("PictureLarge")
.HasColumnType("text");
b.Property<string>("Subtitle")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("CategoryLadderId")
.HasColumnType("integer");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("ContributorId")
.HasColumnType("integer");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<byte>("Order")
.HasColumnType("smallint");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<string>("AudibleCategoryId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
b.Property<string>("AudibleContributorId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("boolean");
b.Property<string>("Account")
.HasColumnType("text");
b.Property<DateTime>("DateAdded")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
b.Property<string>("AudibleSeriesId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer");
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<string>("Order")
.HasColumnType("text");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<float>("OverallRating")
.HasColumnType("real");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
b1.Property<float>("StoryRating")
.HasColumnType("real");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<string>("Url")
.HasColumnType("text");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<int>("BookStatus")
.HasColumnType("integer");
b1.Property<bool>("IsFinished")
.HasColumnType("boolean");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("timestamp without time zone");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("text");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("bigint");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("text");
b1.Property<int?>("PdfStatus")
.HasColumnType("integer");
b1.Property<string>("Tags")
.HasColumnType("text");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("integer");
b2.Property<float>("OverallRating")
.HasColumnType("real");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
b2.Property<float>("StoryRating")
.HasColumnType("real");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,372 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class InitialPostgres : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "Books",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleProductId = table.Column<string>(type: "text", nullable: true),
Title = table.Column<string>(type: "text", nullable: true),
Subtitle = table.Column<string>(type: "text", nullable: true),
Description = table.Column<string>(type: "text", nullable: true),
LengthInMinutes = table.Column<int>(type: "integer", nullable: false),
ContentType = table.Column<int>(type: "integer", nullable: false),
Locale = table.Column<string>(type: "text", nullable: true),
PictureId = table.Column<string>(type: "text", nullable: true),
PictureLarge = table.Column<string>(type: "text", nullable: true),
IsAbridged = table.Column<bool>(type: "boolean", nullable: false),
IsSpatial = table.Column<bool>(type: "boolean", nullable: false),
DatePublished = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
Language = table.Column<string>(type: "text", nullable: true),
Rating_OverallRating = table.Column<float>(type: "real", nullable: true),
Rating_PerformanceRating = table.Column<float>(type: "real", nullable: true),
Rating_StoryRating = table.Column<float>(type: "real", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Books", x => x.BookId);
});
migrationBuilder.CreateTable(
name: "Categories",
columns: table => new
{
CategoryId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleCategoryId = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Categories", x => x.CategoryId);
});
migrationBuilder.CreateTable(
name: "CategoryLadders",
columns: table => new
{
CategoryLadderId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryLadders", x => x.CategoryLadderId);
});
migrationBuilder.CreateTable(
name: "Contributors",
columns: table => new
{
ContributorId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
Name = table.Column<string>(type: "text", nullable: true),
AudibleContributorId = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Contributors", x => x.ContributorId);
});
migrationBuilder.CreateTable(
name: "Series",
columns: table => new
{
SeriesId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
AudibleSeriesId = table.Column<string>(type: "text", nullable: true),
Name = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Series", x => x.SeriesId);
});
migrationBuilder.CreateTable(
name: "LibraryBooks",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
DateAdded = table.Column<DateTime>(type: "timestamp without time zone", nullable: false),
Account = table.Column<string>(type: "text", nullable: true),
IsDeleted = table.Column<bool>(type: "boolean", nullable: false),
AbsentFromLastScan = table.Column<bool>(type: "boolean", nullable: false),
IncludedUntil = table.Column<DateTime>(type: "timestamp without time zone", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_LibraryBooks", x => x.BookId);
table.ForeignKey(
name: "FK_LibraryBooks_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "Supplement",
columns: table => new
{
SupplementId = table.Column<int>(type: "integer", nullable: false)
.Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn),
BookId = table.Column<int>(type: "integer", nullable: false),
Url = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_Supplement", x => x.SupplementId);
table.ForeignKey(
name: "FK_Supplement_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "UserDefinedItem",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
LastDownloaded = table.Column<DateTime>(type: "timestamp without time zone", nullable: true),
LastDownloadedVersion = table.Column<string>(type: "text", nullable: true),
LastDownloadedFormat = table.Column<long>(type: "bigint", nullable: true),
LastDownloadedFileVersion = table.Column<string>(type: "text", nullable: true),
Tags = table.Column<string>(type: "text", nullable: true),
Rating_OverallRating = table.Column<float>(type: "real", nullable: true),
Rating_PerformanceRating = table.Column<float>(type: "real", nullable: true),
Rating_StoryRating = table.Column<float>(type: "real", nullable: true),
BookStatus = table.Column<int>(type: "integer", nullable: false),
PdfStatus = table.Column<int>(type: "integer", nullable: true),
IsFinished = table.Column<bool>(type: "boolean", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_UserDefinedItem", x => x.BookId);
table.ForeignKey(
name: "FK_UserDefinedItem_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BookCategory",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
CategoryLadderId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookCategory", x => new { x.BookId, x.CategoryLadderId });
table.ForeignKey(
name: "FK_BookCategory_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookCategory_CategoryLadders_CategoryLadderId",
column: x => x.CategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "CategoryCategoryLadder",
columns: table => new
{
_categoriesCategoryId = table.Column<int>(type: "integer", nullable: false),
_categoryLaddersCategoryLadderId = table.Column<int>(type: "integer", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_CategoryCategoryLadder", x => new { x._categoriesCategoryId, x._categoryLaddersCategoryLadderId });
table.ForeignKey(
name: "FK_CategoryCategoryLadder_Categories__categoriesCategoryId",
column: x => x._categoriesCategoryId,
principalTable: "Categories",
principalColumn: "CategoryId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_CategoryCategoryLadder_CategoryLadders__categoryLaddersCate~",
column: x => x._categoryLaddersCategoryLadderId,
principalTable: "CategoryLadders",
principalColumn: "CategoryLadderId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "BookContributor",
columns: table => new
{
BookId = table.Column<int>(type: "integer", nullable: false),
ContributorId = table.Column<int>(type: "integer", nullable: false),
Role = table.Column<int>(type: "integer", nullable: false),
Order = table.Column<byte>(type: "smallint", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_BookContributor", x => new { x.BookId, x.ContributorId, x.Role });
table.ForeignKey(
name: "FK_BookContributor_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_BookContributor_Contributors_ContributorId",
column: x => x.ContributorId,
principalTable: "Contributors",
principalColumn: "ContributorId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.CreateTable(
name: "SeriesBook",
columns: table => new
{
SeriesId = table.Column<int>(type: "integer", nullable: false),
BookId = table.Column<int>(type: "integer", nullable: false),
Order = table.Column<string>(type: "text", nullable: true)
},
constraints: table =>
{
table.PrimaryKey("PK_SeriesBook", x => new { x.SeriesId, x.BookId });
table.ForeignKey(
name: "FK_SeriesBook_Books_BookId",
column: x => x.BookId,
principalTable: "Books",
principalColumn: "BookId",
onDelete: ReferentialAction.Cascade);
table.ForeignKey(
name: "FK_SeriesBook_Series_SeriesId",
column: x => x.SeriesId,
principalTable: "Series",
principalColumn: "SeriesId",
onDelete: ReferentialAction.Cascade);
});
migrationBuilder.InsertData(
table: "Contributors",
columns: new[] { "ContributorId", "AudibleContributorId", "Name" },
values: new object[] { -1, null, "" });
migrationBuilder.CreateIndex(
name: "IX_BookCategory_BookId",
table: "BookCategory",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookCategory_CategoryLadderId",
table: "BookCategory",
column: "CategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_BookContributor_BookId",
table: "BookContributor",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_BookContributor_ContributorId",
table: "BookContributor",
column: "ContributorId");
migrationBuilder.CreateIndex(
name: "IX_Books_AudibleProductId",
table: "Books",
column: "AudibleProductId");
migrationBuilder.CreateIndex(
name: "IX_Categories_AudibleCategoryId",
table: "Categories",
column: "AudibleCategoryId");
migrationBuilder.CreateIndex(
name: "IX_CategoryCategoryLadder__categoryLaddersCategoryLadderId",
table: "CategoryCategoryLadder",
column: "_categoryLaddersCategoryLadderId");
migrationBuilder.CreateIndex(
name: "IX_Contributors_Name",
table: "Contributors",
column: "Name");
migrationBuilder.CreateIndex(
name: "IX_Series_AudibleSeriesId",
table: "Series",
column: "AudibleSeriesId");
migrationBuilder.CreateIndex(
name: "IX_SeriesBook_BookId",
table: "SeriesBook",
column: "BookId");
migrationBuilder.CreateIndex(
name: "IX_SeriesBook_SeriesId",
table: "SeriesBook",
column: "SeriesId");
migrationBuilder.CreateIndex(
name: "IX_Supplement_BookId",
table: "Supplement",
column: "BookId");
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropTable(
name: "BookCategory");
migrationBuilder.DropTable(
name: "BookContributor");
migrationBuilder.DropTable(
name: "CategoryCategoryLadder");
migrationBuilder.DropTable(
name: "LibraryBooks");
migrationBuilder.DropTable(
name: "SeriesBook");
migrationBuilder.DropTable(
name: "Supplement");
migrationBuilder.DropTable(
name: "UserDefinedItem");
migrationBuilder.DropTable(
name: "Contributors");
migrationBuilder.DropTable(
name: "Categories");
migrationBuilder.DropTable(
name: "CategoryLadders");
migrationBuilder.DropTable(
name: "Series");
migrationBuilder.DropTable(
name: "Books");
}
}
}

View File

@@ -0,0 +1,491 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
[DbContext(typeof(LibationContext))]
partial class LibationContextModelSnapshot : ModelSnapshot
{
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("integer");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("integer");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("BookId"));
b.Property<string>("AudibleProductId")
.HasColumnType("text");
b.Property<int>("ContentType")
.HasColumnType("integer");
b.Property<DateTime?>("DatePublished")
.HasColumnType("timestamp without time zone");
b.Property<string>("Description")
.HasColumnType("text");
b.Property<bool>("IsAbridged")
.HasColumnType("boolean");
b.Property<bool>("IsSpatial")
.HasColumnType("boolean");
b.Property<string>("Language")
.HasColumnType("text");
b.Property<int>("LengthInMinutes")
.HasColumnType("integer");
b.Property<string>("Locale")
.HasColumnType("text");
b.Property<string>("PictureId")
.HasColumnType("text");
b.Property<string>("PictureLarge")
.HasColumnType("text");
b.Property<string>("Subtitle")
.HasColumnType("text");
b.Property<string>("Title")
.HasColumnType("text");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("CategoryLadderId")
.HasColumnType("integer");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<int>("ContributorId")
.HasColumnType("integer");
b.Property<int>("Role")
.HasColumnType("integer");
b.Property<byte>("Order")
.HasColumnType("smallint");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryId"));
b.Property<string>("AudibleCategoryId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("CategoryLadderId"));
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("ContributorId"));
b.Property<string>("AudibleContributorId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("boolean");
b.Property<string>("Account")
.HasColumnType("text");
b.Property<DateTime>("DateAdded")
.HasColumnType("timestamp without time zone");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("SeriesId"));
b.Property<string>("AudibleSeriesId")
.HasColumnType("text");
b.Property<string>("Name")
.HasColumnType("text");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("integer");
b.Property<int>("BookId")
.HasColumnType("integer");
b.Property<string>("Order")
.HasColumnType("text");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<float>("OverallRating")
.HasColumnType("real");
b1.Property<float>("PerformanceRating")
.HasColumnType("real");
b1.Property<float>("StoryRating")
.HasColumnType("real");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("integer");
NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b1.Property<int>("SupplementId"));
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<string>("Url")
.HasColumnType("text");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("integer");
b1.Property<int>("BookStatus")
.HasColumnType("integer");
b1.Property<bool>("IsFinished")
.HasColumnType("boolean");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("timestamp without time zone");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("text");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("bigint");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("text");
b1.Property<int?>("PdfStatus")
.HasColumnType("integer");
b1.Property<string>("Tags")
.HasColumnType("text");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("integer");
b2.Property<float>("OverallRating")
.HasColumnType("real");
b2.Property<float>("PerformanceRating")
.HasColumnType("real");
b2.Property<float>("StoryRating")
.HasColumnType("real");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore.Design;
namespace DataLayer.Postgres
{
public class PostgresContextFactory : IDesignTimeDbContextFactory<LibationContext>
{
public LibationContext CreateDbContext(string[] args)
{
return LibationContextFactory.CreatePostgres(string.Empty);
}
}
}

View File

@@ -0,0 +1,34 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
<GenerateRuntimeConfigurationFiles>true</GenerateRuntimeConfigurationFiles>
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<ProjectReference Include="..\DataLayer\DataLayer.csproj" />
</ItemGroup>
</Project>

View File

@@ -0,0 +1,477 @@
// <auto-generated />
using System;
using DataLayer;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
#nullable disable
namespace DataLayer.Migrations
{
[DbContext(typeof(LibationContext))]
[Migration("20251020175053_AddIncludedUntil")]
partial class AddIncludedUntil
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.Property<int>("_categoriesCategoryId")
.HasColumnType("INTEGER");
b.Property<int>("_categoryLaddersCategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("_categoriesCategoryId", "_categoryLaddersCategoryLadderId");
b.HasIndex("_categoryLaddersCategoryLadderId");
b.ToTable("CategoryCategoryLadder");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Property<int>("BookId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleProductId")
.HasColumnType("TEXT");
b.Property<int>("ContentType")
.HasColumnType("INTEGER");
b.Property<DateTime?>("DatePublished")
.HasColumnType("TEXT");
b.Property<string>("Description")
.HasColumnType("TEXT");
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpatial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
b.Property<int>("LengthInMinutes")
.HasColumnType("INTEGER");
b.Property<string>("Locale")
.HasColumnType("TEXT");
b.Property<string>("PictureId")
.HasColumnType("TEXT");
b.Property<string>("PictureLarge")
.HasColumnType("TEXT");
b.Property<string>("Subtitle")
.HasColumnType("TEXT");
b.Property<string>("Title")
.HasColumnType("TEXT");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
b.ToTable("Books");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("CategoryLadderId")
.HasColumnType("INTEGER");
b.HasKey("BookId", "CategoryLadderId");
b.HasIndex("BookId");
b.HasIndex("CategoryLadderId");
b.ToTable("BookCategory");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<int>("ContributorId")
.HasColumnType("INTEGER");
b.Property<int>("Role")
.HasColumnType("INTEGER");
b.Property<byte>("Order")
.HasColumnType("INTEGER");
b.HasKey("BookId", "ContributorId", "Role");
b.HasIndex("BookId");
b.HasIndex("ContributorId");
b.ToTable("BookContributor");
});
modelBuilder.Entity("DataLayer.Category", b =>
{
b.Property<int>("CategoryId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleCategoryId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("CategoryId");
b.HasIndex("AudibleCategoryId");
b.ToTable("Categories");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Property<int>("CategoryLadderId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.HasKey("CategoryLadderId");
b.ToTable("CategoryLadders");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Property<int>("ContributorId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleContributorId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("ContributorId");
b.HasIndex("Name");
b.ToTable("Contributors");
b.HasData(
new
{
ContributorId = -1,
Name = ""
});
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<bool>("AbsentFromLastScan")
.HasColumnType("INTEGER");
b.Property<string>("Account")
.HasColumnType("TEXT");
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.ToTable("LibraryBooks");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Property<int>("SeriesId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AudibleSeriesId")
.HasColumnType("TEXT");
b.Property<string>("Name")
.HasColumnType("TEXT");
b.HasKey("SeriesId");
b.HasIndex("AudibleSeriesId");
b.ToTable("Series");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.Property<int>("SeriesId")
.HasColumnType("INTEGER");
b.Property<int>("BookId")
.HasColumnType("INTEGER");
b.Property<string>("Order")
.HasColumnType("TEXT");
b.HasKey("SeriesId", "BookId");
b.HasIndex("BookId");
b.HasIndex("SeriesId");
b.ToTable("SeriesBook");
});
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
b.HasOne("DataLayer.Category", null)
.WithMany()
.HasForeignKey("_categoriesCategoryId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", null)
.WithMany()
.HasForeignKey("_categoryLaddersCategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.OwnsOne("DataLayer.Rating", "Rating", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<float>("OverallRating")
.HasColumnType("REAL");
b1.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b1.Property<float>("StoryRating")
.HasColumnType("REAL");
b1.HasKey("BookId");
b1.ToTable("Books");
b1.WithOwner()
.HasForeignKey("BookId");
});
b.OwnsMany("DataLayer.Supplement", "Supplements", b1 =>
{
b1.Property<int>("SupplementId")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<string>("Url")
.HasColumnType("TEXT");
b1.HasKey("SupplementId");
b1.HasIndex("BookId");
b1.ToTable("Supplement");
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.Navigation("Book");
});
b.OwnsOne("DataLayer.UserDefinedItem", "UserDefinedItem", b1 =>
{
b1.Property<int>("BookId")
.HasColumnType("INTEGER");
b1.Property<int>("BookStatus")
.HasColumnType("INTEGER");
b1.Property<bool>("IsFinished")
.HasColumnType("INTEGER");
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");
b1.Property<int?>("PdfStatus")
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.HasColumnType("TEXT");
b1.HasKey("BookId");
b1.ToTable("UserDefinedItem", (string)null);
b1.WithOwner("Book")
.HasForeignKey("BookId");
b1.OwnsOne("DataLayer.Rating", "Rating", b2 =>
{
b2.Property<int>("UserDefinedItemBookId")
.HasColumnType("INTEGER");
b2.Property<float>("OverallRating")
.HasColumnType("REAL");
b2.Property<float>("PerformanceRating")
.HasColumnType("REAL");
b2.Property<float>("StoryRating")
.HasColumnType("REAL");
b2.HasKey("UserDefinedItemBookId");
b2.ToTable("UserDefinedItem");
b2.WithOwner()
.HasForeignKey("UserDefinedItemBookId");
});
b1.Navigation("Book");
b1.Navigation("Rating");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,29 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddIncludedUntil : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AddColumn<DateTime>(
name: "IncludedUntil",
table: "LibraryBooks",
type: "TEXT",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IncludedUntil",
table: "LibraryBooks");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -194,6 +194,9 @@ namespace DataLayer.Migrations
b.Property<DateTime>("DateAdded")
.HasColumnType("TEXT");
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");

View File

@@ -0,0 +1,12 @@
using Microsoft.EntityFrameworkCore.Design;
namespace DataLayer.Sqlite
{
public class SqliteContextFactory : IDesignTimeDbContextFactory<LibationContext>
{
public LibationContext CreateDbContext(string[] args)
{
return LibationContextFactory.CreateSqlite(string.Empty);
}
}
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup>
@@ -10,18 +10,25 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Dinah.Core" Version="9.0.2.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="9.0.0.1" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.8">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.8" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.8">
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>
<None Update="migrate.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">
<DebugType>embedded</DebugType>
@@ -30,11 +37,5 @@
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Release|AnyCPU'">
<DebugType>embedded</DebugType>
</PropertyGroup>
<ItemGroup>
<None Update="migrate.json">
<CopyToOutputDirectory>PreserveNewest</CopyToOutputDirectory>
</None>
</ItemGroup>
</Project>

View File

@@ -73,7 +73,8 @@ namespace DataLayer
ContentType contentType,
IEnumerable<Contributor> authors,
IEnumerable<Contributor> narrators,
string localeName)
string localeName
)
{
// validate
ArgumentValidator.EnsureNotNull(audibleProductId, nameof(audibleProductId));

View File

@@ -45,5 +45,7 @@ namespace DataLayer
public override string ToString() => Name;
public void SetAudibleContributorId(string audibleContributorId)
=> AudibleContributorId = audibleContributorId;
}
public bool IsEmpty => ContributorId == -1;
}
}

View File

@@ -13,7 +13,8 @@ namespace DataLayer
public bool IsDeleted { get; set; }
public bool AbsentFromLastScan { get; set; }
public DateTime? IncludedUntil { get; private set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{
@@ -26,7 +27,7 @@ namespace DataLayer
}
public void SetAccount(string account) => Account = account;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}
public void SetIncludedUntil(DateTime? includedUntil) => IncludedUntil = includedUntil;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

View File

@@ -13,69 +13,74 @@ namespace DataLayer
.Where(a => a.Role == role)
.OrderBy(a => a.Order);
public static string TitleSortable(this Book book) => Formatters.GetSortName(book.Title + book.Subtitle);
public static string AuthorNames(this Book book) => string.Join(", ", book.Authors.Select(a => a.Name));
public static string NarratorNames(this Book book) => string.Join(", ", book.Narrators.Select(n => n.Name));
extension(Book book)
{
public string SeriesSortable() => Formatters.GetSortName(book.SeriesNames(true));
public string TitleSortable() => Formatters.GetSortName(book.Title + book.Subtitle);
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public static bool Audio_Exists(this Book book) => book.UserDefinedItem.BookStatus != LiberatedStatus.NotLiberated;
/// <summary>True if exists and IsLiberated. Else false</summary>
public static bool PDF_Exists(this Book book) => book.UserDefinedItem.PdfStatus == LiberatedStatus.Liberated;
public string AuthorNames => string.Join(", ", book.Authors.Select(a => a.Name));
public string NarratorNames => string.Join(", ", book.Narrators.Select(n => n.Name));
/// <summary>True if IsLiberated or Error. False if NotLiberated</summary>
public bool AudioExists => book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated or LiberatedStatus.Error;
/// <summary>True if exists and IsLiberated. Else false</summary>
public bool PdfExists => book.UserDefinedItem.PdfStatus == LiberatedStatus.NotLiberated;
/// <summary> Whether the book has any supplements </summary>
public bool HasPdf => book.Supplements.Any();
public static string SeriesSortable(this Book book) => Formatters.GetSortName(book.SeriesNames(true));
public static bool HasPdf(this Book book) => book.Supplements.Any();
public static string SeriesNames(this Book book, bool includeIndex = false)
{
if (book.SeriesLink is null)
return "";
public string SeriesNames(bool includeIndex = false)
{
if (book.SeriesLink is null)
return "";
// first: alphabetical by name
var withNames = book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(getSeriesNameString)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = book.SeriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
// first: alphabetical by name
var withNames = book.SeriesLink
.Where(s => !string.IsNullOrWhiteSpace(s.Series.Name))
.Select(getSeriesNameString)
.OrderBy(a => a)
.ToList();
// then un-named are alpha by series id
var nullNames = book.SeriesLink
.Where(s => string.IsNullOrWhiteSpace(s.Series.Name))
.Select(s => s.Series.AudibleSeriesId)
.OrderBy(a => a)
.ToList();
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
var all = withNames.Union(nullNames).ToList();
return string.Join(", ", all);
string getSeriesNameString(SeriesBook sb)
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name;
string getSeriesNameString(SeriesBook sb)
=> includeIndex && !string.IsNullOrWhiteSpace(sb.Order) && sb.Order != "-1"
? $"{sb.Series.Name} (#{sb.Order})"
: sb.Series.Name;
}
public string[] LowestCategoryNames()
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
.Where(c => c is not null)
.Distinct()
.ToArray();
public string[] AllCategoryNames()
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public string[] AllCategoryIds()
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
}
public static string[] LowestCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.Select(cl => cl.CategoryLadder.Categories.LastOrDefault()?.Name)
.Where(c => c is not null)
.Distinct()
.ToArray();
public static string[] AllCategoryNames(this Book book)
=> book.CategoriesLink?.Any() is not true ? Array.Empty<string>()
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.Name)
.ToArray();
public static string[] AllCategoryIds(this Book book)
=> book.CategoriesLink?.Any() is not true ? null
: book
.CategoriesLink
.SelectMany(cl => cl.CategoryLadder.Categories)
.Select(c => c.AudibleCategoryId)
.ToArray();
public static string AggregateTitles(this IEnumerable<LibraryBook> libraryBooks, int max = 5)
{
@@ -93,7 +98,7 @@ namespace DataLayer
return titlesAgg;
}
public static float FirstScore(this Rating rating)
public static float FirstScore(this Rating rating)
=> rating.OverallRating > 0 ? rating.OverallRating
: rating.PerformanceRating > 0 ? rating.PerformanceRating
: rating.StoryRating;

View File

@@ -0,0 +1,103 @@
using System;
using System.Diagnostics;
using System.Threading;
#nullable enable
namespace DataLayer;
/// <summary> Notifies clients that the object is being disposed. </summary>
public interface INotifyDisposed : IDisposable
{
/// <summary> Event raised when the object is disposed. </summary>
event EventHandler? ObjectDisposed;
}
/// <summary> Creates a single instance of <typeparamref name="TDisposable"/> at a time, blocking subsequent creations until the previous creations are disposed. </summary>
public static class InstanceQueue<TDisposable> where TDisposable : INotifyDisposed
{
/// <summary> Synchronization object for access to <see cref="LastInLine"/>"/> </summary>
private static Lock Locker { get; } = new();
/// <summary> Ticket holder for the last instance creator in line. </summary>
private static Ticket? LastInLine { get; set; }
/// <summary> Waits for all previously created instances of <typeparamref name="TDisposable"/> to be disposed, then creates and returns a new instance of <typeparamref name="TDisposable"/> using the provided <paramref name="creator"/> factory. </summary>
public static TDisposable WaitToCreateInstance(Func<TDisposable> creator)
{
Ticket ticket;
lock (Locker)
{
ticket = LastInLine = new Ticket(creator, LastInLine);
}
return ticket.Fulfill();
}
/// <summary> A queue ticket for an instance creator. </summary>
/// <param name="creator">Factory to create a new instance of <typeparamref name="TDisposable"/></param>
/// <param name="inFront">The ticket immediately in preceding this new ticket. This new ticket must wait for <paramref name="inFront"/> to signal its instance has been disposed before creating a new instance of <typeparamref name="TDisposable"/></param>
private class Ticket(Func<TDisposable> creator, Ticket? inFront) : IDisposable
{
/// <summary> Factory to create a new instance of <typeparamref name="TDisposable"/> </summary>
private Func<TDisposable> Creator { get; } = creator;
/// <summary> Ticket immediately in front of this one. This instance must wait for <see cref="InFront"/> to signal its instance has been disposed before creating a new instance of <typeparamref name="TDisposable"/></summary>
private Ticket? InFront { get; } = inFront;
/// <summary> Wait handle to signal when this ticket's created instance is disposed </summary>
private EventWaitHandle WaitHandle { get; } = new(false, EventResetMode.ManualReset);
/// <summary> This ticket's created instance of <typeparamref name="TDisposable"/> </summary>
private TDisposable? CreatedInstance { get; set; }
/// <summary> Disposes of this ticket and every ticket queued in front of it. </summary>
public void Dispose()
{
WaitHandle.Dispose();
InFront?.Dispose();
}
/// <summary>
/// Waits for the <see cref="InFront"/> ticket's instance to be disposed, then creates and returns a new instance of <typeparamref name="TDisposable"/> using the <see cref="Creator"/> factory.
/// </summary>
public TDisposable Fulfill()
{
#if DEBUG
var sw = Stopwatch.StartNew();
#endif
//Wait for the previous ticket's instance to be disposed, then dispose of the previous ticket.
InFront?.WaitHandle.WaitOne(Timeout.Infinite);
InFront?.Dispose();
#if DEBUG
sw.Stop();
Debug.WriteLine($"Waited {sw.ElapsedMilliseconds}ms to create instance of {typeof(TDisposable).Name}");
#endif
CreatedInstance = Creator();
CreatedInstance.ObjectDisposed += CreatedInstance_ObjectDisposed;
return CreatedInstance;
}
private void CreatedInstance_ObjectDisposed(object? sender, EventArgs e)
{
Debug.WriteLine($"{typeof(TDisposable).Name} Disposed");
if (CreatedInstance is not null)
{
CreatedInstance.ObjectDisposed -= CreatedInstance_ObjectDisposed;
CreatedInstance = default;
}
lock (Locker)
{
if (this == LastInLine)
{
//There are no ticket holders waiting after this one.
//This ticket is fulfilled and will never be waited on.
LastInLine = null;
Dispose();
}
else
{
//Signal the that this ticket has been fulfilled so that
//the next ticket in line may proceed.
WaitHandle.Set();
}
}
}
}
}

View File

@@ -1,9 +1,11 @@
using DataLayer.Configurations;
using Microsoft.EntityFrameworkCore;
using System;
using System.Threading.Tasks;
namespace DataLayer
{
public class LibationContext : DbContext
public class LibationContext : DbContext, INotifyDisposed
{
// IMPORTANT: USING DbSet<>
// ========================
@@ -25,15 +27,20 @@ namespace DataLayer
public DbSet<Category> Categories { get; private set; }
public DbSet<CategoryLadder> CategoryLadders { get; private set; }
public static LibationContext Create(string connectionString)
public event EventHandler ObjectDisposed;
public override void Dispose()
{
var factory = new LibationContextFactory();
var context = factory.Create(connectionString);
return context;
base.Dispose();
ObjectDisposed?.Invoke(this, EventArgs.Empty);
}
public override async ValueTask DisposeAsync()
{
await base.DisposeAsync();
ObjectDisposed?.Invoke(this, EventArgs.Empty);
}
// see DesignTimeDbContextFactoryBase for info about ctors and connection strings/OnConfiguring()
internal LibationContext(DbContextOptions options) : base(options) { }
public LibationContext(DbContextOptions options) : base(options) { }
// typically only called once per execution; NOT once per instantiation
protected override void OnModelCreating(ModelBuilder modelBuilder)

View File

@@ -1,14 +1,41 @@
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
using System;
namespace DataLayer
{
public class LibationContextFactory : DesignTimeDbContextFactoryBase<LibationContext>
public class LibationContextFactory
{
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
=> optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
public static void ConfigureOptions(NpgsqlDbContextOptionsBuilder options)
{
AppContext.SetSwitch("Npgsql.EnableLegacyTimestampBehavior", true);
options.MigrationsAssembly("DataLayer.Postgres");
options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
}
public static LibationContext CreatePostgres(string connectionString)
{
var options = new DbContextOptionsBuilder<LibationContext>();
options.UseNpgsql(connectionString, ConfigureOptions);
return new LibationContext(options.Options);
}
public static LibationContext CreateSqlite(string connectionString)
{
var options = new DbContextOptionsBuilder<LibationContext>();
options
.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.UseSqlite(connectionString, options =>
{
options.MigrationsAssembly("DataLayer.Sqlite");
options.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery);
});
return new LibationContext(options.Options);
}
}
}

View File

@@ -0,0 +1,116 @@
using System;
using System.Linq;
using System.Reflection;
using System.Text;
#nullable enable
namespace DataLayer;
public class MockLibraryBook : LibraryBook
{
protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil)
: base(book, dateAdded, account)
{
SetIncludedUntil(includedUntil);
}
public MockLibraryBook AddSeries(string seriesName, int order)
{
var series = new Series(new AudibleSeriesId(CalculateAsin(seriesName)), seriesName);
Book.UpsertSeries(series, order.ToString());
return this;
}
public MockLibraryBook AddCategoryLadder(params string[] ladder)
{
var newLadder = new CategoryLadder(ladder.Select(c => new Category(new AudibleCategoryId(CalculateAsin(c)), c)).ToList());
Book.SetCategoryLadders(Book.Categories.Select(c => c.CategoryLadder).Append(newLadder));
return this;
}
public MockLibraryBook AddNarrator(string name)
{
var newNarrator = new Contributor(name, CalculateAsin(name));
Book.ReplaceNarrators(Book.Narrators.Append(newNarrator));
return this;
}
public MockLibraryBook AddAuthor(string name)
{
var newAuthor = new Contributor(name, CalculateAsin(name));
Book.ReplaceAuthors(Book.Authors.Append(newAuthor));
return this;
}
public MockLibraryBook WithBookStatus(LiberatedStatus liberatedStatus)
{
//Set the backing field directly to preserve LiberatedStatus.PartialDownload
typeof(UserDefinedItem)
.GetField("_bookStatus", BindingFlags.NonPublic | BindingFlags.Instance)
?.SetValue(Book.UserDefinedItem, liberatedStatus);
return this;
}
public MockLibraryBook WithPdfStatus(LiberatedStatus liberatedStatus)
{
Book.UserDefinedItem.PdfStatus = liberatedStatus;
return this;
}
public MockLibraryBook WithLastDownloaded(Version? lastVersion = null, AudioFormat? format = null, string audioVersion = "1")
{
lastVersion ??= new Version(10, 0, 0, 0);
format ??= AudioFormat.Default;
Book.UserDefinedItem.SetLastDownloaded(lastVersion, format, audioVersion);
return this;
}
public MockLibraryBook WithMyRating(float overallRating = 4, float performanceRating = 4.5f, float storyRating = 5)
{
Book.UserDefinedItem.UpdateRating(overallRating, performanceRating, storyRating);
return this;
}
public static MockLibraryBook CreateBook(
string account = "someone@email.co",
bool absetFromLastScan = false,
DateTime? dateAdded = null,
DateTime? datePublished = null,
DateTime? includedUntil = null,
string title = "Mock Book Title",
string subtitle = "Mock Book Subtitle",
string description = "This is a mock book description.",
int lengthInMinutes = 1400,
ContentType contentType = ContentType.Product,
string firstAuthor = "Author One",
string firstNarrator = "Narrator One",
string localeName = "us",
bool isAbridged = false,
bool isSpatial = false,
string language = "English")
{
var book = new Book(
new AudibleProductId(CalculateAsin(title + subtitle)),
title,
subtitle,
description,
lengthInMinutes,
contentType,
[new Contributor(firstAuthor, CalculateAsin(firstAuthor))],
[new Contributor(firstNarrator, CalculateAsin(firstNarrator))],
localeName);
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
return new MockLibraryBook(
book,
dateAdded ?? DateTime.Now,
account,
includedUntil)
{
AbsentFromLastScan = absetFromLastScan
};
}
private static string CalculateAsin(string name)
=> Convert.ToHexString(System.Security.Cryptography.MD5.HashData(Encoding.UTF8.GetBytes(name))).Substring(0, 10);
}

View File

@@ -25,16 +25,17 @@ namespace DataLayer
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId, bool caseSensative = true)
{
var libraryQuery
= context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId);
.GetLibrary();
public static LibraryBook? GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
}
/// <summary>This is still IQueryable. YOU MUST CALL ToList() YOURSELF</summary>
public static IQueryable<LibraryBook> GetLibrary(this IQueryable<LibraryBook> library)

View File

@@ -48,9 +48,21 @@ namespace DtoImporterService
.Distinct()
.ToHashSet();
Cache = DbContext.Books
.GetBooks(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
if (productIds.Count > 100)
{
//For large imports, it is faster to get the whole library and filter in memory.
Cache = DbContext.Books
.GetBooks()
.ToArray()
.Where(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
}
else
{
Cache = DbContext.Books
.GetBooks(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
}
}
private int upsertBooks(IEnumerable<ImportItem> importItems)
@@ -111,7 +123,8 @@ namespace DtoImporterService
contentType,
authors,
narrators,
importItem.LocaleName)
importItem.LocaleName
)
).Entity;
Cache.Add(book.AudibleProductId, book);
}

View File

@@ -1,7 +1,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net9.0</TargetFramework>
<TargetFramework>net10.0</TargetFramework>
</PropertyGroup>
<PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'">

View File

@@ -8,121 +8,136 @@ using Dinah.Core.Collections.Generic;
namespace DtoImporterService
{
public class LibraryBookImporter : ItemsImporterBase
{
protected override IValidator Validator => new LibraryValidator();
public class LibraryBookImporter : ItemsImporterBase
{
protected override IValidator Validator => new LibraryValidator();
private BookImporter bookImporter { get; }
private BookImporter bookImporter { get; }
public LibraryBookImporter(LibationContext context) : base(context)
{
bookImporter = new BookImporter(DbContext);
}
public LibraryBookImporter(LibationContext context) : base(context)
{
bookImporter = new BookImporter(DbContext);
}
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
protected override int DoImport(IEnumerable<ImportItem> importItems)
{
bookImporter.Import(importItems);
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
var qtyNew = upsertLibraryBooks(importItems);
return qtyNew;
}
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
private int upsertLibraryBooks(IEnumerable<ImportItem> importItems)
{
// technically, we should be able to have duplicate books from separate accounts.
// this would violate the current pk and would be difficult to deal with elsewhere:
// - what to show in the grid
// - which to consider liberated
//
// sqlite cannot alter pk. the work around is an extensive headache
// - update: now possible in .net5/efcore5
//
// currently, inserting LibraryBook will throw error if the same book is in multiple accounts for the same region.
//
// CURRENT SOLUTION: don't re-insert
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//When Books are upserted during the BookImporter run, they are linked to their LibraryBook in the DbContext
//instance. If a LibraryBook has a null book here, that means it's Book was not imported during by BookImporter.
//There should never be duplicates, but this is defensive.
var existingEntries = DbContext.LibraryBooks.AsEnumerable().Where(l => l.Book is not null).ToDictionarySafe(l => l.Book.AudibleProductId);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
//If importItems are contains duplicates by asin, keep the Item that's "available"
var uniqueImportItems = ToDictionarySafe(importItems, dto => dto.DtoItem.ProductId, tieBreak);
int qtyNew = 0;
int qtyNew = 0;
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
foreach (var item in uniqueImportItems.Values)
{
if (existingEntries.TryGetValue(item.DtoItem.ProductId, out LibraryBook existing))
{
if (existing.Account != item.AccountId)
{
//Book is absent from the existing LibraryBook's account. Use the alternate account.
existing.SetAccount(item.AccountId);
}
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
var libraryBook = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
existing.AbsentFromLastScan = isUnavailable(item);
}
else
{
existing = new LibraryBook(
bookImporter.Cache[item.DtoItem.ProductId],
item.DtoItem.DateAdded,
item.AccountId)
{
AbsentFromLastScan = isUnavailable(item)
};
try
{
DbContext.LibraryBooks.Add(libraryBook);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { libraryBook.Book, libraryBook.Account });
}
}
}
try
{
DbContext.LibraryBooks.Add(existing);
qtyNew++;
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding library book. {@DebugInfo}", new { existing.Book, existing.Account });
}
}
existing.SetIncludedUntil(GetExpirationDate(item));
}
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
var scannedAccounts = importItems.Select(i => i.AccountId).Distinct().ToList();
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
//If an existing Book wasn't found in the import, the owning LibraryBook's Book will be null.
//Only change AbsentFromLastScan for LibraryBooks of accounts that were scanned.
foreach (var nullBook in DbContext.LibraryBooks.AsEnumerable().Where(lb => lb.Book is null && lb.Account.In(scannedAccounts)))
nullBook.AbsentFromLastScan = true;
return qtyNew;
}
return qtyNew;
}
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
private static Dictionary<TKey, TSource> ToDictionarySafe<TKey, TSource>(IEnumerable<TSource> source, Func<TSource, TKey> keySelector, Func<TSource, TSource, TSource> tieBreaker)
{
var dictionary = new Dictionary<TKey, TSource>();
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
foreach (TSource newItem in source)
{
TKey key = keySelector(newItem);
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
dictionary[key]
= dictionary.TryGetValue(key, out TSource existingItem)
? tieBreaker(existingItem, newItem)
: newItem;
}
return dictionary;
}
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static ImportItem tieBreak(ImportItem item1, ImportItem item2)
=> isUnavailable(item1) && !isUnavailable(item2) ? item2 : item1;
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isUnavailable(ImportItem item)
=> isFutureRelease(item) || isPlusTitleUnavailable(item);
private static bool isFutureRelease(ImportItem item)
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
=> item.DtoItem.IssueDate is DateTimeOffset dt && dt > DateTimeOffset.UtcNow;
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
}
}
private static bool isPlusTitleUnavailable(ImportItem item)
=> item.DtoItem.ContentType is null
|| (item.DtoItem.IsAyce is true && item.DtoItem.Plans?.Any(p => p.IsAyce) is not true);
/// <summary>
/// Determines when your audible plus or free book will expire from your library
/// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used.
/// In some cases current date is later than end date so exclude.
/// </summary>
/// <returns>The DateTime that this title will become unavailable, otherwise null</returns>
private static DateTime? GetExpirationDate(ImportItem item)
=> item.DtoItem.Plans
?.Where(p => p.IsAyce)
.Select(p => p.EndDate)
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
?.DateTime;
}
}

View File

@@ -23,9 +23,7 @@ namespace FileLiberator
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
if (series is not null)
{
using var context = ApplicationServices.DbContexts.GetContext();
var seriesParent = context.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
LibraryBook seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
if (seriesParent is not null)
{
return Templates.Folder.GetFilename(seriesParent.ToDto(), AudibleFileStorage.BooksDirectory, "");

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