diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 000000000..dfdb8b771 --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +*.sh text eol=lf diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index 612a9c9e7..484d046f4 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -47,7 +47,27 @@ jobs: - name: Unit test if: ${{ inputs.run-unit-tests }} working-directory: ./Source - run: dotnet test + env: + LIBATION_HEADLESS_CAPTURE_OUTPUT: ${{ inputs.OS == 'Debian' && inputs.architecture == 'x64' && format('{0}/cross-platform-captures/linux-x64', github.workspace) || '' }} + run: | + shopt -s nullglob + TEST_PROJECTS=(_Tests/*.Tests/*.csproj) + if (( ${#TEST_PROJECTS[@]} == 0 )); then + echo "::error::No unit-test projects were found." + exit 1 + fi + for TEST_PROJECT in "${TEST_PROJECTS[@]}"; do + dotnet run --project "${TEST_PROJECT}" --configuration Release -- --minimum-expected-tests 1 + done + + - name: Upload Linux contemporary captures + if: ${{ always() && inputs.run-unit-tests && inputs.OS == 'Debian' && inputs.architecture == 'x64' }} + uses: actions/upload-artifact@v7 + with: + name: contemporary-captures-linux-x64 + path: ./cross-platform-captures/linux-x64/*.png + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} - name: Publish id: publish diff --git a/.github/workflows/build-mac.yml b/.github/workflows/build-mac.yml index 9b1f91c2a..9141fa195 100644 --- a/.github/workflows/build-mac.yml +++ b/.github/workflows/build-mac.yml @@ -21,6 +21,10 @@ on: sign-app: type: boolean description: "Wheather to sign an notorize the app bundle and dmg." + release: + type: boolean + default: false + description: "Whether this artifact is being built for a release." architecture: type: string description: "CPU architecture targeted by the build." @@ -32,7 +36,7 @@ jobs: runs-on: macos-latest env: RUNTIME_ID: "osx-${{ inputs.architecture }}" - WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} + WAIT_FOR_NOTARIZE: ${{ inputs.release || vars.WAIT_FOR_NOTARIZE == 'true' }} steps: - uses: apple-actions/import-codesign-certs@v7 if: ${{ inputs.sign-app }} @@ -50,7 +54,16 @@ jobs: - name: Unit test if: ${{ inputs.run-unit-tests }} working-directory: ./Source - run: dotnet test + run: | + shopt -s nullglob + TEST_PROJECTS=(_Tests/*.Tests/*.csproj) + if (( ${#TEST_PROJECTS[@]} == 0 )); then + echo "::error::No unit-test projects were found." + exit 1 + fi + for TEST_PROJECT in "${TEST_PROJECTS[@]}"; do + dotnet run --project "${TEST_PROJECT}" --configuration Release -- --minimum-expected-tests 1 + done - name: Publish id: publish @@ -81,14 +94,15 @@ jobs: - name: Notarize bundle if: ${{ inputs.sign-app }} run: | - if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then - WAIT="--wait" + WAIT_ARGS=() + if [ "${WAIT_FOR_NOTARIZE}" = "true" ]; then + WAIT_ARGS+=(--wait) fi echo "::debug::Submitting the disk image for notarization" # Capture stdout+stderr (2>&1). Use || true so that when notarytool fails (e.g. Apple TOS # agreement required), the script does not exit before we can print RESPONSE—otherwise the # job would fail with no visible error message in the workflow log. - 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) || true + RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} "${WAIT_ARGS[@]}" --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1) || true echo "$RESPONSE" SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }') echo "::notice::Notary Submission Id: $SUBMISSION_ID" @@ -100,9 +114,10 @@ jobs: exit 1 fi - if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then + if [ "${WAIT_FOR_NOTARIZE}" = "true" ]; then echo "::debug::Stapling the notarization ticket to the disk image" xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}" + xcrun stapler validate "./bundle/${{ steps.bundle.outputs.artifact }}" fi - uses: actions/upload-artifact@v7 diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 9b36df179..e7bece2b9 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -28,6 +28,7 @@ jobs: name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})" runs-on: windows-latest strategy: + fail-fast: false matrix: ui: [Avalonia] architecture: [x64] @@ -53,8 +54,25 @@ jobs: - name: Unit test if: ${{ inputs.run-unit-tests }} working-directory: ./Source - run: dotnet test - + env: + LIBATION_HEADLESS_CAPTURE_OUTPUT: ${{ matrix.ui == 'Avalonia' && matrix.architecture == 'x64' && format('{0}/cross-platform-captures/windows-x64', github.workspace) || '' }} + run: | + $testProjects = @(Get-ChildItem -Path "_Tests/*.Tests/*.csproj") + if ($testProjects.Count -eq 0) { throw "No unit-test projects were found." } + foreach ($testProject in $testProjects) { + dotnet run --project $testProject.FullName --configuration Release -- --minimum-expected-tests 1 + if ($LASTEXITCODE -ne 0) { throw "Unit tests failed for $($testProject.FullName)" } + } + + - name: Upload Windows contemporary captures + if: ${{ always() && inputs.run-unit-tests && matrix.ui == 'Avalonia' && matrix.architecture == 'x64' }} + uses: actions/upload-artifact@v7 + with: + name: contemporary-captures-windows-x64 + path: ./cross-platform-captures/windows-x64/*.png + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} + - name: Publish working-directory: ./Source run: | @@ -66,10 +84,20 @@ jobs: "-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 + # PowerShell does not stop at a native command that fails, and the step is judged by the exit code + # of the last one, so a publish that fails anywhere but the end passes as a green build. That is not + # hypothetical: LibationWinForms failed to compile for a while and every run stayed green, uploading + # a Libation-Classic zip with no Libation.exe in it. Check each one. + $projects = @( + "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj", + "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj", + "LibationCli/LibationCli.csproj", + "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj") + + foreach ($project in $projects) { + dotnet publish "$project" $PUBLISH_ARGS + if ($LASTEXITCODE -ne 0) { throw "dotnet publish failed for $project" } + } - name: Zip artifact id: zip @@ -81,6 +109,11 @@ jobs: "WindowsConfigApp.deps.json") foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } } + + # Both UIs publish as Libation.exe. upload-artifact's if-no-files-found only proves the zip exists, + # which it does whether or not the app is inside it, so say outright that it has to be. + if (-not (Test-Path "Libation.exe")) { throw "Libation.exe is missing from the publish output" } + $artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip" "artifact=$artifact" >> $env:GITHUB_OUTPUT Compress-Archive -Path * -DestinationPath "$artifact" @@ -127,4 +160,3 @@ jobs: path: ./bin/${{ steps.setup.outputs.setup }} if-no-files-found: error retention-days: ${{ inputs.retention-days }} - diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index db0fd8418..c535591bc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -40,6 +40,7 @@ jobs: macOS: strategy: + fail-fast: false matrix: architecture: [x64, arm64] uses: ./.github/workflows/build-mac.yml @@ -51,10 +52,12 @@ jobs: retention-days: ${{ inputs.retention-days }} architecture: ${{ matrix.architecture }} sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }} + release: ${{ inputs.release }} secrets: inherit linux: strategy: + fail-fast: false matrix: OS: [Redhat, Debian] architecture: [x64, arm64] @@ -68,5 +71,3 @@ jobs: architecture: ${{ matrix.architecture }} OS: ${{ matrix.OS }} - - diff --git a/.github/workflows/docker.yml b/.github/workflows/docker.yml index 77eef73b0..55da5b21a 100644 --- a/.github/workflows/docker.yml +++ b/.github/workflows/docker.yml @@ -36,7 +36,7 @@ jobs: - name: Login to Docker Hub if: ${{ inputs.release }} - uses: docker/login-action@v4.5.2 + uses: docker/login-action@v4.6.0 with: username: ${{ secrets.docker_username }} password: ${{ secrets.docker_token }} @@ -59,7 +59,9 @@ jobs: context: . platforms: linux/amd64,linux/arm64 push: ${{ steps.metadata.outputs.tags != ''}} + # Always read cache. Only write on release so PR validate cannot fail + # when GHA cache is full or the token cannot write cache. cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }} tags: ${{ steps.metadata.outputs.tags }} labels: ${{ steps.metadata.outputs.labels }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4fe95546a..33635b44b 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -36,6 +36,7 @@ jobs: libation-version: ${{ needs.prerelease.outputs.version }} publish-r2r: true release: true + run-unit-tests: true release: needs: [prerelease, build] diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index df267a04a..2023fa9a7 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -8,11 +8,13 @@ on: branches: [master] paths: - Source/** + - Scripts/** - .github/workflows/** pull_request: branches: [master] paths: - Source/** + - Scripts/** - .github/workflows/** jobs: diff --git a/.vitepress/config.js b/.vitepress/config.js index 24f21e46b..50b8fb961 100644 --- a/.vitepress/config.js +++ b/.vitepress/config.js @@ -94,7 +94,19 @@ export default defineConfig({ text: "Audiobookshelf Auto-Upload", link: "/docs/features/audiobookshelf", }, + { + text: "Daily Download Limit", + link: "/docs/features/daily-download-limit", + }, { text: "Naming Templates", link: "/docs/features/naming-templates" }, + { + text: "Parallel Downloads", + link: "/docs/features/parallel-downloads", + }, + { + text: "Retrying Refused Downloads", + link: "/docs/features/retrying-refused-downloads", + }, { text: "Searching & Filtering", link: "/docs/features/searching-and-filtering", @@ -103,6 +115,7 @@ export default defineConfig({ text: "Easy guide to searching", link: "/docs/features/lucene", }, + { text: "Trash Bin", link: "/docs/features/trash-bin" }, ], }, { @@ -124,6 +137,7 @@ export default defineConfig({ link: "/docs/development/getting-started", }, { text: "Contribute", link: "/docs/development/contribute" }, + { text: "Testing Changes", link: "/docs/development/testing" }, { text: "Website & Docs", link: "/docs/development/website" }, { text: "Linux Setup (Nix)", link: "/docs/development/nix-linux-setup" }, ], diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 000000000..8d16852db --- /dev/null +++ b/AGENTS.md @@ -0,0 +1,75 @@ +# AGENTS.md + +## Cursor Cloud specific instructions + +Libation is a cross-platform .NET desktop/CLI app for downloading and de-DRMing Audible +audiobooks. The core product is the Avalonia GUI (`LibationAvalonia`, assembly name `Libation`) +plus a headless CLI (`LibationCli`) that shares the same config and SQLite library database. + +### Toolchain / environment +- Requires the **.NET 10 SDK**. `global.json` sets a `10.0.101` floor with `rollForward: latestFeature`, + so any newer 10.0.x SDK is used as-is. It is preinstalled at + `~/.dotnet`; `~/.bashrc` exports `DOTNET_ROOT` and adds `~/.dotnet` (and `~/.dotnet/tools`) to + `PATH`. New non-login shells may not have it — use `dotnet` after a login shell, or call + `~/.dotnet/dotnet` directly. +- The startup update script runs `dotnet tool restore` (restores `dotnet-ef`) and + `dotnet restore Source/Libation.slnx`. No other setup is needed. +- The solution file is the new XML format: `Source/Libation.slnx` (there is no classic `.sln`). + +### Building — everything compiles on Linux, only some of it runs +- `dotnet build Source/Libation.slnx` succeeds on Linux with 0 errors. So do + `LibationWinForms`, `HangoverWinForms` and the `LoadByOS/{Windows,MacOS}ConfigApp` helpers, + individually: they target `net10.0-windows7.0` but set `EnableWindowsTargeting`, which is what makes + a non-Windows SDK build them. Compile-check every change to the WinForms projects here; a Linux + build is the only automated check those projects get before a human on Windows sees them. +- Compiling is not running. WinForms has no Linux runtime, so behaviour and layout in + `LibationWinForms` / `HangoverWinForms` still need a human on Windows. Say so explicitly rather than + implying a WinForms change was tested. +- Build the runnable cross-platform apps directly: + - `dotnet build Source/LibationAvalonia/LibationAvalonia.csproj` + - `dotnet build Source/LibationCli/LibationCli.csproj` +- There is no dedicated lint step; the repo's `.editorconfig` is minimal and CI + (`.github/workflows/validate.yml`) only builds and tests. The compiler/analyzer warnings from a + normal build serve as the static-analysis check. (A known transitive `NU1903` SQLite + vulnerability warning is expected and harmless.) + +### Testing +- Test projects live under `Source/_Tests/` and use MSTest on **Microsoft.Testing.Platform** + (configured via `global.json` `test.runner`). Because of this runner, `dotnet test` requires + `--project` for a single project (a positional project path is rejected): + `dotnet test --project Source/_Tests/FileManager.Tests/FileManager.Tests.csproj` +- CI runs `dotnet test` from `Source/` on every platform, Linux included, and it works here too: the + restore covers the whole solution but only the test projects and their references are compiled, so + the Windows-only projects never break the run. Naming one project is simply quicker. +- **Manual UI testing:** `dotnet run Scripts/seed-demo-library.cs` fills the library with fake books + covering every Liberate-column icon and prints the expected result for each row + (`-- --clean` removes them). Read `docs/development/testing.md` before seeding library state by + hand: the yellow lamp is an `.aaxc` file on disk rather than a stored status, `AudioExists` is a + database check so green/error need no files, and a podcast's series is keyed off the parent + book's own ASIN or the grid silently drops the parent row. +- **GNOME Keyring / OS secret store:** Libation's default `TokenStorageMethod` is `Encrypted`, + and the AES-GCM master key is stored via the OS secret store (`OsSecretStore` / + `IdentityTokenStorageWiring`). On Linux that is GNOME Keyring (Secret Service), which **blocks + indefinitely** here: the login keyring is locked with a password nobody has, and the D-Bus call + hangs even with no prompt on screen. Probing availability does not help - the probe is the + blocking call. + Every test project should finish in about a second. If one runs for minutes, assume something + reached the OS secret store; do not sit through it and do not re-run it hoping for a different + result. `ResolveSecretStore` short-circuits on `LIBATION_MASTER_KEY_FILE`, an existing + `libation-master.key` under the Libation files dir, or `LIBATION_MASTER_KEY`, so setting one of + those keeps a test off the OS store entirely. + The tests that deliberately exercise the real store are opt-in via + `LIBATION_TEST_OS_SECRET_STORE=1` and are skipped otherwise. Leave them skipped on Linux; they + will hang if enabled. Always run tests under `timeout` so a regression here cannot stall a session. + +### Running the apps +- GUI: a display is available on `DISPLAY=:1`. Run with + `cd Source/LibationAvalonia && dotnet run`. First launch shows a Welcome/walkthrough and creates + config under `~/.local/share/Libation/` (`Settings.json`, `AccountsSettings.json`, + `LibationContext.db`). The same keyring note above applies when the GUI/CLI first encrypts + account tokens. +- CLI: `cd Source/LibationCli && dotnet run -- ` (e.g. `version`, `get-setting`, + `list-accounts`, `scan`, `liberate`). The CLI reads the same `~/.local/share/Libation/` config + and database as the GUI. +- Actually scanning/downloading a library requires signing into a real Audible account, so full + end-to-end liberation cannot be exercised without credentials. diff --git a/Directory.Build.props b/Directory.Build.props index 309ffac17..6181f6d8d 100644 --- a/Directory.Build.props +++ b/Directory.Build.props @@ -2,4 +2,12 @@ true + + + + + diff --git a/Docker/liberate.sh b/Docker/liberate.sh index 9eaa6888d..412465274 100755 --- a/Docker/liberate.sh +++ b/Docker/liberate.sh @@ -38,6 +38,25 @@ init_config_file() { fi } +init_master_key() { + FILE=libation-master.key + FULLPATH=${LIBATION_CONFIG_DIR}/${FILE} + if [ -n "${LIBATION_MASTER_KEY_FILE}" ]; then + debug "LIBATION_MASTER_KEY_FILE is already set, not looking for ${FILE}" + return 0 + fi + if [ -n "${LIBATION_MASTER_KEY}" ]; then + debug "LIBATION_MASTER_KEY is already set, not looking for ${FILE}" + return 0 + fi + if [ -f ${FULLPATH} ]; then + info "loading ${FILE}" + cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/ + else + debug "${FULLPATH} not found, assuming accounts don't use encrypted tokens" + fi +} + update_settings() { FILE=$1 KEY=$2 @@ -141,6 +160,11 @@ setup_db() { run() { info "scanning accounts" /libation/LibationCli scan + local scan_exit=$? + if [ "${scan_exit}" -ne 0 ]; then + error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken" + exit "${scan_exit}" + fi info "liberating books" /libation/LibationCli liberate } @@ -149,7 +173,8 @@ main() { info "initializing libation" init_config_file AccountsSettings.json init_config_file Settings.json - + init_master_key + info "loading settings" update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}" update_settings Settings.json InProgress /tmp @@ -171,6 +196,13 @@ main() { warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved" fi + # If a command was passed to the container (e.g. `docker run -it libation bash`), + # run that instead of the liberate loop so the environment is still set up + if [ "$#" -gt 0 ]; then + info "executing '$*' instead of the liberate loop" + exec "$@" + fi + # Let the user know what the run type will be if [[ -z "${SLEEP_TIME}" ]]; then SLEEP_TIME=-1 @@ -198,4 +230,4 @@ main() { info "exiting" } -main +main "$@" diff --git a/Dockerfile b/Dockerfile index 170197286..131be19d6 100644 --- a/Dockerfile +++ b/Dockerfile @@ -37,6 +37,8 @@ RUN apt-get update && apt-get -y upgrade && \ COPY --from=build /Source/bin/Publish/Linux-chardonnay /libation COPY Docker/* /libation +RUN ln -s /libation/LibationCli /usr/local/bin/LibationCli + USER ${USER_UID}:${USER_GID} -CMD ["/libation/liberate.sh"] +ENTRYPOINT ["/libation/liberate.sh"] diff --git a/README.md b/README.md index ed128857e..65673d415 100644 --- a/README.md +++ b/README.md @@ -40,13 +40,14 @@ We welcome contributions! - **[Development Getting Started](https://getlibation.com/docs/development/getting-started)**: Setup your environment. - **[Contribute](https://getlibation.com/docs/development/contribute)**: How to contribute code. +- **[Testing Changes](https://getlibation.com/docs/development/testing)**: Run the tests and seed a demo library for UI testing. - **[Website & Docs](https://getlibation.com/docs/development/website)**: How to run and improve the documentation. - **[Linux Setup (Nix)](https://getlibation.com/docs/development/nix-linux-setup)**: Nix-based environment setup. ## Community & Support - **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features. -- **[PayPal](https://paypal.me/mcrackan?locale.x=en_us)**: Support the project if you find it useful. +- **[Donate](https://getlibation.com/donate)**: Support the project if you find it useful. ## License diff --git a/Scripts/Bundle_MacOS.sh b/Scripts/Bundle_MacOS.sh index 19b1633fa..fd6f9cef2 100644 --- a/Scripts/Bundle_MacOS.sh +++ b/Scripts/Bundle_MacOS.sh @@ -24,12 +24,24 @@ then exit 1 fi +if [[ ! "$VERSION" =~ ^[0-9]+\.[0-9]+\.[0-9]+$ ]] +then + echo "The Libation version must contain three period-separated integers for CFBundleShortVersionString." + exit 1 +fi + if [ -z "${ARCH:-}" ] then echo "This script must be called with the Libation cpu architecture as an argument." exit 1 fi +if [ "$ARCH" != "x64" ] && [ "$ARCH" != "arm64" ] +then + echo "The Libation macOS architecture must be x64 or arm64." + exit 1 +fi + if [ "$SIGN_WITH_KEY" != "true" ] then echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information." @@ -65,7 +77,6 @@ create_dmg_with_retry() { fi echo "hdiutil create failed on attempt ${attempt}" - hdiutil detach -force /Volumes/Libation 2>/dev/null || true rm -f "${output_path}" "${output_path}.temp.dmg" 2>/dev/null || true sync @@ -94,9 +105,7 @@ mkdir -p $BUNDLE_CONTENTS mkdir -p $BUNDLE_RESOURCES mkdir -p $BUNDLE_MACOS -mv "${BIN_DIR}/"* $BUNDLE_MACOS - -if [ $? -ne 0 ]; then +if ! mv "${BIN_DIR}/"* "$BUNDLE_MACOS"; then echo "Error moving ${BIN_DIR} files" exit 1 fi @@ -122,33 +131,34 @@ 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" -# Portable sed -i (BSD sed on macOS requires backup arg; use .bak then remove) -sed -i.bak "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak +echo "Moving local ad-hoc entitlements file..." +mv $BUNDLE_MACOS/Libation.adhoc.entitlements ./Libation.adhoc.entitlements echo "Set CFBundleVersion to $VERSION" +# Portable sed -i (BSD sed on macOS requires backup arg; use .bak then remove) sed -i.bak "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist && rm -f $BUNDLE_CONTENTS/Info.plist.bak delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json') for n in "${delfiles[@]}" do echo "Deleting $n" - rm $BUNDLE_MACOS/$n + rm "$BUNDLE_MACOS/$n" done DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg" all_identities=$(security find-identity -v -p codesigning) -identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p') +identity=$(printf '%s\n' "$all_identities" | sed -n 's/.*"\(.*\)".*/\1/p') if [ "$SIGN_WITH_KEY" == "true" ]; then echo "Signing executables in: $BUNDLE" codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE" - codesign --verify --verbose "$BUNDLE" + codesign --verify --deep --strict --verbose=2 "$BUNDLE" else - echo "Signing with empty key: $BUNDLE" - codesign --force --deep -s - $BUNDLE + echo "Signing with an ad-hoc key and local hardened-runtime entitlements: $BUNDLE" + echo "The local-only library-validation exception is required because ad-hoc signatures have no shared Team ID." + codesign --force --deep --options=runtime --entitlements "./Libation.adhoc.entitlements" -s - "$BUNDLE" + codesign --verify --deep --strict --verbose=2 "$BUNDLE" fi echo "Creating app disk image: $DMG_FILE" @@ -157,7 +167,7 @@ mv $BUNDLE ./Libation/$BUNDLE mv Libation_DS_Store Libation/.DS_Store mkdir Libation/.background mv background.png Libation/.background/ -ln -s /Applications "./Libation/ " +ln -s /Applications "./Libation/Applications" mkdir ./bundle create_dmg_with_retry ./Libation "./bundle/$DMG_FILE" # Create a .DS_Store by: diff --git a/Scripts/README.md b/Scripts/README.md new file mode 100644 index 000000000..1a3c27a65 --- /dev/null +++ b/Scripts/README.md @@ -0,0 +1,15 @@ +# Scripts + +Developer utilities. None of these ship in a Libation install - they exist only in a source checkout. + +| Script | Purpose | Documented in | +|--------|---------|---------------| +| `seed-demo-accounts.cs` | Seed fake Audible accounts, to reach what is gated on having one without signing in | [Testing Changes](https://getlibation.com/docs/development/testing) | +| `seed-demo-library.cs` | Seed a library covering every Liberate-column icon, for manual UI testing | [Testing Changes](https://getlibation.com/docs/development/testing) | +| `seed-download-history.cs` | Seed completed downloads so the daily download limit can be tested without downloading | [Testing Changes](https://getlibation.com/docs/development/testing) | +| `Bundle_Debian.sh` | Build the Linux `.deb` package | Used by `.github/workflows/build-linux.yml` | +| `Bundle_Redhat.sh` | Build the Linux RPM package | Used by `.github/workflows/build-linux.yml` | +| `Bundle_MacOS.sh` | Build the macOS app bundle | Used by `.github/workflows/build-mac.yml` | +| `Windows/` | Windows installer (Inno Setup) | [Windows/README.md](Windows/README.md) | + +Usage for the testing scripts lives in the docs rather than here, so there is only one copy to keep current. diff --git a/Scripts/Windows/libation.ico b/Scripts/Windows/libation.ico index 88dff50b6..665451c95 100644 Binary files a/Scripts/Windows/libation.ico and b/Scripts/Windows/libation.ico differ diff --git a/Scripts/capture-plans/all-routes.json b/Scripts/capture-plans/all-routes.json new file mode 100644 index 000000000..b0eac2016 --- /dev/null +++ b/Scripts/capture-plans/all-routes.json @@ -0,0 +1,365 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "History", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "History", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "History", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "About", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "About", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "About", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "About", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "About", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "About", + "width": 720, + "height": 560 + } + ] +} diff --git a/Scripts/capture-plans/library.json b/Scripts/capture-plans/library.json new file mode 100644 index 000000000..b784fc9a7 --- /dev/null +++ b/Scripts/capture-plans/library.json @@ -0,0 +1,29 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 960, + "height": 720 + } + ] +} diff --git a/Scripts/capture-plans/make-plans.py b/Scripts/capture-plans/make-plans.py new file mode 100755 index 000000000..fff9d41b7 --- /dev/null +++ b/Scripts/capture-plans/make-plans.py @@ -0,0 +1,101 @@ +#!/usr/bin/env python3 +"""Regenerate the standard contemporary-shell capture plans.""" + +import json +import os + + +HERE = os.path.dirname(os.path.abspath(__file__)) +PROFILES = ["Cellar", "TastingRoom"] +SIZES = [(1456, 1060), (960, 720)] +NARROW = (720, 560) +ROUTES = [ + "Overview", + "Library", + "Downloads", + "Processing", + "History", + "Accounts", + "Settings", + "Tools", + "Trash", + "About", +] + + +def entries(routes, sizes): + return [ + {"profile": profile, "route": route, "width": width, "height": height} + for profile in PROFILES + for route in routes + for width, height in sizes + ] + + +def s7_variant_entries(): + routes = ["Overview", "Library", "Processing"] + variants = [ + ("compact", {"density": "Compact"}), + ("decoration-off", {"decoration": "Off"}), + ("reduced-motion", {"motion": "Reduce"}), + ("scale-200", {"logicalScale": 2}), + ] + result = [] + for variant_name, settings in variants: + for profile in PROFILES: + for route in routes: + entry = { + "profile": profile, + "route": route, + "width": 1456, + "height": 1060, + "file": f"{variant_name}-{profile.lower()}-{route.lower()}.png", + **settings, + } + if route == "Processing": + entry["processingScenario"] = "Mixed" + result.append(entry) + + for route in routes: + entry = { + "profile": "HighContrast", + "route": route, + "width": 1456, + "height": 1060, + "file": f"high-contrast-{route.lower()}.png", + } + if route == "Processing": + entry["processingScenario"] = "Mixed" + result.append(entry) + return result + + +PLANS = { + "all-routes.json": entries(ROUTES, SIZES + [NARROW]), + "s2-shell.json": entries(ROUTES, SIZES + [NARROW]), + "overview.json": entries(["Overview"], SIZES), + "library.json": entries(["Library"], SIZES), + "processing.json": entries(["Processing"], SIZES), + "secondary.json": entries( + ["Downloads", "History", "Accounts", "Settings", "Tools", "Trash"], SIZES + ) + + [ + { + "profile": "Cellar", + "surface": "Onboarding", + "onboardingStep": step, + "onboardingScanActive": step == 4, + "width": 1456, + "height": 1060, + } + for step in range(1, 6) + ], + "s7-variant-matrix.json": s7_variant_entries(), +} + + +for name, items in PLANS.items(): + with open(os.path.join(HERE, name), "w", encoding="utf-8") as plan_file: + json.dump({"settleMs": 900, "entries": items}, plan_file, indent=2) + plan_file.write("\n") + print(f"{name}: {len(items)} entries") diff --git a/Scripts/capture-plans/overview.json b/Scripts/capture-plans/overview.json new file mode 100644 index 000000000..6311032ad --- /dev/null +++ b/Scripts/capture-plans/overview.json @@ -0,0 +1,29 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 960, + "height": 720 + } + ] +} diff --git a/Scripts/capture-plans/processing.json b/Scripts/capture-plans/processing.json new file mode 100644 index 000000000..5d540b68e --- /dev/null +++ b/Scripts/capture-plans/processing.json @@ -0,0 +1,29 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 960, + "height": 720 + } + ] +} diff --git a/Scripts/capture-plans/s1-design-system.json b/Scripts/capture-plans/s1-design-system.json new file mode 100644 index 000000000..d39746563 --- /dev/null +++ b/Scripts/capture-plans/s1-design-system.json @@ -0,0 +1,105 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "surface": "Route", + "route": "Library", + "libraryView": "Details", + "width": 1456, + "height": 1060, + "file": "cellar-library-details-1456x1060.png" + }, + { + "profile": "Cellar", + "surface": "Route", + "route": "Library", + "libraryView": "Details", + "width": 960, + "height": 720, + "file": "cellar-library-details-960x720.png" + }, + { + "profile": "Cellar", + "surface": "ComponentGallery", + "width": 1456, + "height": 1060, + "file": "cellar-component-gallery-1456x1060.png" + }, + { + "profile": "Cellar", + "surface": "ComponentGallery", + "width": 960, + "height": 720, + "file": "cellar-component-gallery-960x720.png" + }, + { + "profile": "Cellar", + "surface": "Route", + "route": "Overview", + "decoration": "Off", + "width": 1456, + "height": 1060, + "file": "cellar-overview-decoration-off-1456x1060.png" + }, + { + "profile": "Cellar", + "surface": "Route", + "route": "Overview", + "decoration": "Off", + "width": 960, + "height": 720, + "file": "cellar-overview-decoration-off-960x720.png" + }, + { + "profile": "TastingRoom", + "surface": "Route", + "route": "Library", + "libraryView": "Details", + "width": 1456, + "height": 1060, + "file": "tastingroom-library-details-1456x1060.png" + }, + { + "profile": "TastingRoom", + "surface": "Route", + "route": "Library", + "libraryView": "Details", + "width": 960, + "height": 720, + "file": "tastingroom-library-details-960x720.png" + }, + { + "profile": "TastingRoom", + "surface": "ComponentGallery", + "width": 1456, + "height": 1060, + "file": "tastingroom-component-gallery-1456x1060.png" + }, + { + "profile": "TastingRoom", + "surface": "ComponentGallery", + "width": 960, + "height": 720, + "file": "tastingroom-component-gallery-960x720.png" + }, + { + "profile": "TastingRoom", + "surface": "Route", + "route": "Overview", + "decoration": "Off", + "width": 1456, + "height": 1060, + "file": "tastingroom-overview-decoration-off-1456x1060.png" + }, + { + "profile": "TastingRoom", + "surface": "Route", + "route": "Overview", + "decoration": "Off", + "width": 960, + "height": 720, + "file": "tastingroom-overview-decoration-off-960x720.png" + } + ] +} diff --git a/Scripts/capture-plans/s2-shell.json b/Scripts/capture-plans/s2-shell.json new file mode 100644 index 000000000..b0eac2016 --- /dev/null +++ b/Scripts/capture-plans/s2-shell.json @@ -0,0 +1,365 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "History", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "History", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "History", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 720, + "height": 560 + }, + { + "profile": "Cellar", + "route": "About", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "About", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "About", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 720, + "height": 560 + }, + { + "profile": "TastingRoom", + "route": "About", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "About", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "About", + "width": 720, + "height": 560 + } + ] +} diff --git a/Scripts/capture-plans/s3-library-flight.json b/Scripts/capture-plans/s3-library-flight.json new file mode 100644 index 000000000..16f9cc89a --- /dev/null +++ b/Scripts/capture-plans/s3-library-flight.json @@ -0,0 +1,13 @@ +{ + "entries": [ + { "profile": "Cellar", "route": "Library", "width": 960, "height": 720, "libraryView": "Details", "flightSelectionCount": 3, "openFlight": true, "file": "cellar-library-details-compact.png" }, + { "profile": "Cellar", "route": "Library", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "openFlight": true, "file": "cellar-library-gallery-compact.png" }, + { "profile": "TastingRoom", "route": "Library", "width": 960, "height": 720, "libraryView": "Details", "flightSelectionCount": 3, "openFlight": true, "file": "tastingroom-library-details-compact.png" }, + { "profile": "TastingRoom", "route": "Library", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "openFlight": true, "file": "tastingroom-library-gallery-compact.png" }, + { "profile": "Cellar", "route": "Library", "width": 1456, "height": 1060, "libraryView": "Details", "flightSelectionCount": 3, "openFlight": true, "file": "cellar-library-details-wide.png" }, + { "profile": "Cellar", "route": "Library", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "openFlight": true, "file": "cellar-library-gallery-wide.png" }, + { "profile": "TastingRoom", "route": "Library", "width": 1456, "height": 1060, "libraryView": "Details", "flightSelectionCount": 3, "openFlight": true, "file": "tastingroom-library-details-wide.png" }, + { "profile": "TastingRoom", "route": "Library", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "openFlight": true, "file": "tastingroom-library-gallery-wide.png" }, + { "profile": "TastingRoom", "route": "Library", "width": 1456, "height": 1060, "libraryView": "Details", "flightSelectionCount": 3, "openDetails": true, "file": "tastingroom-library-details-pane-wide.png" } + ] +} diff --git a/Scripts/capture-plans/s4-overview-empty.json b/Scripts/capture-plans/s4-overview-empty.json new file mode 100644 index 000000000..cecf99f5d --- /dev/null +++ b/Scripts/capture-plans/s4-overview-empty.json @@ -0,0 +1,9 @@ +{ + "settleMs": 1200, + "entries": [ + { "profile": "Cellar", "route": "Overview", "width": 1456, "height": 1060, "file": "cellar-overview-empty-account-wide.png" }, + { "profile": "Cellar", "route": "Overview", "width": 960, "height": 720, "file": "cellar-overview-empty-account-compact.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 1456, "height": 1060, "file": "tastingroom-overview-empty-account-wide.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 960, "height": 720, "file": "tastingroom-overview-empty-account-compact.png" } + ] +} diff --git a/Scripts/capture-plans/s4-overview-no-account.json b/Scripts/capture-plans/s4-overview-no-account.json new file mode 100644 index 000000000..c7742cdce --- /dev/null +++ b/Scripts/capture-plans/s4-overview-no-account.json @@ -0,0 +1,9 @@ +{ + "settleMs": 1200, + "entries": [ + { "profile": "Cellar", "route": "Overview", "width": 1456, "height": 1060, "file": "cellar-overview-no-account-wide.png" }, + { "profile": "Cellar", "route": "Overview", "width": 960, "height": 720, "file": "cellar-overview-no-account-compact.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 1456, "height": 1060, "file": "tastingroom-overview-no-account-wide.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 960, "height": 720, "file": "tastingroom-overview-no-account-compact.png" } + ] +} diff --git a/Scripts/capture-plans/s4-overview-populated.json b/Scripts/capture-plans/s4-overview-populated.json new file mode 100644 index 000000000..f8034fe58 --- /dev/null +++ b/Scripts/capture-plans/s4-overview-populated.json @@ -0,0 +1,13 @@ +{ + "settleMs": 1200, + "entries": [ + { "profile": "Cellar", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "file": "cellar-overview-populated-wide.png" }, + { "profile": "Cellar", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "file": "cellar-overview-populated-compact.png" }, + { "profile": "Cellar", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "processingSeedCount": 2, "file": "cellar-overview-processing-wide.png" }, + { "profile": "Cellar", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "processingSeedCount": 2, "file": "cellar-overview-processing-compact.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "file": "tastingroom-overview-populated-wide.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "file": "tastingroom-overview-populated-compact.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "processingSeedCount": 2, "file": "tastingroom-overview-processing-wide.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "processingSeedCount": 2, "file": "tastingroom-overview-processing-compact.png" } + ] +} diff --git a/Scripts/capture-plans/s5-processing-decanter.json b/Scripts/capture-plans/s5-processing-decanter.json new file mode 100644 index 000000000..58443cda2 --- /dev/null +++ b/Scripts/capture-plans/s5-processing-decanter.json @@ -0,0 +1,22 @@ +{ + "settleMs": 1200, + "entries": [ + { "profile": "Cellar", "route": "Processing", "width": 1456, "height": 1060, "processingScenario": "Empty", "file": "cellar-processing-empty-wide.png" }, + { "profile": "TastingRoom", "route": "Processing", "width": 1456, "height": 1060, "processingScenario": "Empty", "file": "tastingroom-processing-empty-wide.png" }, + { "profile": "Cellar", "route": "Processing", "width": 1456, "height": 1060, "processingScenario": "Mixed", "file": "cellar-processing-mixed-wide.png" }, + { "profile": "TastingRoom", "route": "Processing", "width": 1456, "height": 1060, "processingScenario": "Mixed", "file": "tastingroom-processing-mixed-wide.png" }, + { "profile": "Cellar", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Empty", "file": "cellar-decanter-empty-wide.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Empty", "file": "tastingroom-decanter-empty-wide.png" }, + { "profile": "Cellar", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Mixed", "file": "cellar-decanter-mixed-wide.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 1456, "height": 1060, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Mixed", "file": "tastingroom-decanter-mixed-wide.png" }, + + { "profile": "Cellar", "route": "Processing", "width": 960, "height": 720, "processingScenario": "Empty", "file": "cellar-processing-empty-compact.png" }, + { "profile": "TastingRoom", "route": "Processing", "width": 960, "height": 720, "processingScenario": "Empty", "file": "tastingroom-processing-empty-compact.png" }, + { "profile": "Cellar", "route": "Processing", "width": 960, "height": 720, "processingScenario": "Mixed", "file": "cellar-processing-mixed-compact.png" }, + { "profile": "TastingRoom", "route": "Processing", "width": 960, "height": 720, "processingScenario": "Mixed", "file": "tastingroom-processing-mixed-compact.png" }, + { "profile": "Cellar", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Empty", "openDecanter": true, "file": "cellar-decanter-empty-compact.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Empty", "file": "tastingroom-decanter-empty-compact.png" }, + { "profile": "Cellar", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Mixed", "openDecanter": true, "file": "cellar-decanter-mixed-compact.png" }, + { "profile": "TastingRoom", "route": "Overview", "width": 960, "height": 720, "libraryView": "Gallery", "flightSelectionCount": 3, "processingScenario": "Mixed", "file": "tastingroom-decanter-mixed-compact.png" } + ] +} diff --git a/Scripts/capture-plans/s7-variant-matrix.json b/Scripts/capture-plans/s7-variant-matrix.json new file mode 100644 index 000000000..7631a9035 --- /dev/null +++ b/Scripts/capture-plans/s7-variant-matrix.json @@ -0,0 +1,227 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "compact-cellar-overview.png", + "density": "Compact" + }, + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "compact-cellar-library.png", + "density": "Compact" + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "compact-cellar-processing.png", + "density": "Compact", + "processingScenario": "Mixed" + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "compact-tastingroom-overview.png", + "density": "Compact" + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "compact-tastingroom-library.png", + "density": "Compact" + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "compact-tastingroom-processing.png", + "density": "Compact", + "processingScenario": "Mixed" + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "decoration-off-cellar-overview.png", + "decoration": "Off" + }, + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "decoration-off-cellar-library.png", + "decoration": "Off" + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "decoration-off-cellar-processing.png", + "decoration": "Off", + "processingScenario": "Mixed" + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "decoration-off-tastingroom-overview.png", + "decoration": "Off" + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "decoration-off-tastingroom-library.png", + "decoration": "Off" + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "decoration-off-tastingroom-processing.png", + "decoration": "Off", + "processingScenario": "Mixed" + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "reduced-motion-cellar-overview.png", + "motion": "Reduce" + }, + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "reduced-motion-cellar-library.png", + "motion": "Reduce" + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "reduced-motion-cellar-processing.png", + "motion": "Reduce", + "processingScenario": "Mixed" + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "reduced-motion-tastingroom-overview.png", + "motion": "Reduce" + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "reduced-motion-tastingroom-library.png", + "motion": "Reduce" + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "reduced-motion-tastingroom-processing.png", + "motion": "Reduce", + "processingScenario": "Mixed" + }, + { + "profile": "Cellar", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "scale-200-cellar-overview.png", + "logicalScale": 2 + }, + { + "profile": "Cellar", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "scale-200-cellar-library.png", + "logicalScale": 2 + }, + { + "profile": "Cellar", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "scale-200-cellar-processing.png", + "logicalScale": 2, + "processingScenario": "Mixed" + }, + { + "profile": "TastingRoom", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "scale-200-tastingroom-overview.png", + "logicalScale": 2 + }, + { + "profile": "TastingRoom", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "scale-200-tastingroom-library.png", + "logicalScale": 2 + }, + { + "profile": "TastingRoom", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "scale-200-tastingroom-processing.png", + "logicalScale": 2, + "processingScenario": "Mixed" + }, + { + "profile": "HighContrast", + "route": "Overview", + "width": 1456, + "height": 1060, + "file": "high-contrast-overview.png" + }, + { + "profile": "HighContrast", + "route": "Library", + "width": 1456, + "height": 1060, + "file": "high-contrast-library.png" + }, + { + "profile": "HighContrast", + "route": "Processing", + "width": 1456, + "height": 1060, + "file": "high-contrast-processing.png", + "processingScenario": "Mixed" + } + ] +} diff --git a/Scripts/capture-plans/secondary.json b/Scripts/capture-plans/secondary.json new file mode 100644 index 000000000..f9bebd939 --- /dev/null +++ b/Scripts/capture-plans/secondary.json @@ -0,0 +1,189 @@ +{ + "settleMs": 900, + "entries": [ + { + "profile": "Cellar", + "route": "Downloads", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Downloads", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "History", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "History", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Accounts", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Settings", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Tools", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "route": "Trash", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Downloads", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "History", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Accounts", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Settings", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Tools", + "width": 960, + "height": 720 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 1456, + "height": 1060 + }, + { + "profile": "TastingRoom", + "route": "Trash", + "width": 960, + "height": 720 + }, + { + "profile": "Cellar", + "surface": "Onboarding", + "onboardingStep": 1, + "onboardingScanActive": false, + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "surface": "Onboarding", + "onboardingStep": 2, + "onboardingScanActive": false, + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "surface": "Onboarding", + "onboardingStep": 3, + "onboardingScanActive": false, + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "surface": "Onboarding", + "onboardingStep": 4, + "onboardingScanActive": true, + "width": 1456, + "height": 1060 + }, + { + "profile": "Cellar", + "surface": "Onboarding", + "onboardingStep": 5, + "onboardingScanActive": false, + "width": 1456, + "height": 1060 + } + ] +} diff --git a/Scripts/capture-ui.sh b/Scripts/capture-ui.sh new file mode 100755 index 000000000..c4464d999 --- /dev/null +++ b/Scripts/capture-ui.sh @@ -0,0 +1,204 @@ +#!/usr/bin/env bash +# Capture contemporary-shell screenshots from an isolated demo profile on macOS. +# +# Scripts/capture-ui.sh [--no-build] +# +# Avalonia's macOS RenderTargetBitmap omits some composed controls. The app therefore +# walks the real capture plan and signals visual readiness, while this driver captures +# the live window content with screencapture. The real Libation profile is never opened. +set -euo pipefail + +if [[ $# -lt 3 || $# -gt 4 ]]; then + echo "usage: $0 [--no-build]" >&2 + exit 2 +fi +if [[ $# -eq 4 && $4 != "--no-build" ]]; then + echo "unknown option: $4" >&2 + exit 2 +fi +if [[ $(uname -s) != "Darwin" ]]; then + echo "capture-ui.sh currently uses the macOS screencapture fallback required by S0." >&2 + exit 2 +fi + +PROFILE="$(cd "$1" && pwd)" +PLAN="$(cd "$(dirname "$2")" && pwd)/$(basename "$2")" +ROOT="$(cd "$(dirname "$0")/.." && pwd)" +OUT="$3" +DOTNET="${DOTNET:-$HOME/.dotnet/dotnet}" +BUILD_ROOT="$ROOT/../.demo/capture-app" +mkdir -p "$OUT" "$BUILD_ROOT" +OUT="$(cd "$OUT" && pwd)" + +for required in "$PROFILE/Settings.json" "$PROFILE/LibationContext.db" "$PROFILE/libation-master.key" "$PLAN"; do + if [[ ! -f $required ]]; then + echo "required capture input is missing: $required" >&2 + exit 2 + fi +done + +if [[ ${4:-} != "--no-build" ]]; then + DOTNET_ROOT="$(dirname "$DOTNET")" "$DOTNET" build \ + "$ROOT/Source/LibationAvalonia/LibationAvalonia.csproj" \ + -c Release --disable-build-servers -m:1 -v:minimal -o "$BUILD_ROOT" +fi +APP="$BUILD_ROOT/Libation" +if [[ ! -x $APP ]]; then + echo "capture apphost is missing: $APP (run without --no-build)" >&2 + exit 2 +fi + +HANDSHAKE="$(mktemp -d "${TMPDIR:-/tmp}/libation-capture.XXXXXX")" +MANIFEST="$HANDSHAKE/entries.tsv" +CAPTURE_LOG="$HANDSHAKE/capture-log.txt" +WINDOW_HELPER="$HANDSHAKE/macos-window-id" +/usr/bin/xcrun swiftc "$ROOT/Scripts/macos-window-id.swift" -o "$WINDOW_HELPER" +python3 - "$PLAN" "$MANIFEST" <<'PY' +import json +import sys + +plan_path, manifest_path = sys.argv[1:] +with open(plan_path, encoding="utf-8") as source: + plan = json.load(source) +with open(manifest_path, "w", encoding="utf-8") as target: + for index, entry in enumerate(plan["entries"]): + surface = entry.get("surface", "Route").lower() + if surface == "componentgallery": + subject = "componentgallery" + elif surface == "onboarding": + subject = f"onboarding-step{entry.get('onboardingStep', 1)}" + else: + subject = entry["route"].lower() + name = entry.get("file") or ( + f"{entry['profile'].lower()}-{subject}-" + f"{entry['width']}x{entry['height']}.png" + ) + if any(character in name for character in ("\t", "\n")): + raise SystemExit(f"capture file name contains a control character: {name!r}") + target.write(f"{index}\t{name}\t{entry['width']}\t{entry['height']}\n") +PY + +APP_PID="" +CAFFEINATE_PID="" +cleanup() { + if [[ -n $APP_PID ]] && kill -0 "$APP_PID" 2>/dev/null; then + kill "$APP_PID" 2>/dev/null || true + wait "$APP_PID" 2>/dev/null || true + fi + if [[ -n $CAFFEINATE_PID ]] && kill -0 "$CAFFEINATE_PID" 2>/dev/null; then + kill "$CAFFEINATE_PID" 2>/dev/null || true + wait "$CAFFEINATE_PID" 2>/dev/null || true + fi + rm -f "$HANDSHAKE"/ready-*.txt "$HANDSHAKE"/ack-*.txt \ + "$HANDSHAKE"/window-*.png "$MANIFEST" "$CAPTURE_LOG" "$WINDOW_HELPER" + rmdir "$HANDSHAKE" 2>/dev/null || true +} +trap cleanup EXIT INT TERM + +DOTNET_ROOT="$(dirname "$DOTNET")" +export DOTNET_ROOT +export DOTNET_ROOT_ARM64="$DOTNET_ROOT" +export LIBATION_FILES_DIR="$PROFILE" +export LIBATION_MASTER_KEY_FILE="$PROFILE/libation-master.key" +export LIBATION_CAPTURE_PLAN="$PLAN" +export LIBATION_CAPTURE_OUT="$OUT" +export LIBATION_CAPTURE_OS_HANDSHAKE="$HANDSHAKE" + +"$APP" & +APP_PID=$! +/usr/bin/caffeinate -d -u -w "$APP_PID" & +CAFFEINATE_PID=$! + +# The S0 plan owns the 900-second full-run timeout. +DEADLINE=$((SECONDS + 900)) +PLANNED=0 +MISSING=0 +while IFS=$'\t' read -r INDEX NAME WIDTH HEIGHT; do + PLANNED=$((PLANNED + 1)) + STEM="$(printf '%04d' "$INDEX")" + READY="$HANDSHAKE/ready-$STEM.txt" + ACK="$HANDSHAKE/ack-$STEM.txt" + while [[ ! -s $READY ]]; do + if ! kill -0 "$APP_PID" 2>/dev/null; then + set +e + wait "$APP_PID" + STATUS=$? + set -e + APP_PID="" + echo "Libation exited $STATUS before capture $NAME became ready." >&2 + exit 1 + fi + if ((SECONDS >= DEADLINE)); then + echo "capture plan exceeded its 900-second timeout while waiting for $NAME" >&2 + exit 124 + fi + sleep 0.05 + done + + IFS=$'\t' read -r READY_NAME READY_WIDTH READY_HEIGHT < "$READY" + if [[ $READY_NAME != "$NAME" || $READY_WIDTH != "$WIDTH" || $READY_HEIGHT != "$HEIGHT" ]]; then + echo "capture handshake mismatch for entry $INDEX" >&2 + exit 1 + fi + + WINDOW_INFO="" + while [[ -z $WINDOW_INFO ]]; do + set +e + WINDOW_INFO="$("$WINDOW_HELPER" "$APP_PID" 2>/dev/null)" + WINDOW_STATUS=$? + set -e + if [[ $WINDOW_STATUS -eq 0 && -n $WINDOW_INFO ]]; then + break + fi + WINDOW_INFO="" + if ! kill -0 "$APP_PID" 2>/dev/null; then + echo "Libation exited before its ready window could be identified for $NAME." >&2 + exit 1 + fi + if ((SECONDS >= DEADLINE)); then + echo "capture plan exceeded its 900-second timeout while identifying the window for $NAME" >&2 + exit 124 + fi + sleep 0.05 + done + IFS=$'\t' read -r WINDOW_ID WINDOW_WIDTH WINDOW_HEIGHT <<< "$WINDOW_INFO" + if [[ -z ${WINDOW_HEIGHT:-} || $WINDOW_WIDTH -lt $WIDTH || $WINDOW_HEIGHT -lt $HEIGHT ]]; then + echo "invalid Libation window metadata for $NAME: $WINDOW_INFO" >&2 + exit 1 + fi + + TARGET="$OUT/$NAME" + RAW="$HANDSHAKE/window-$STEM.png" + mkdir -p "$(dirname "$TARGET")" + /usr/sbin/screencapture -x -o -l"$WINDOW_ID" "$RAW" + python3 "$ROOT/Scripts/crop-macos-window.py" \ + "$RAW" "$TARGET" "$WINDOW_WIDTH" "$WINDOW_HEIGHT" "$WIDTH" "$HEIGHT" + rm -f "$RAW" + if [[ ! -s $TARGET ]]; then + echo "screencapture did not write $TARGET" >&2 + MISSING=$((MISSING + 1)) + fi + PIXEL_WIDTH="$(/usr/bin/sips -g pixelWidth "$TARGET" 2>/dev/null | awk '/pixelWidth/ {print $2}')" + PIXEL_HEIGHT="$(/usr/bin/sips -g pixelHeight "$TARGET" 2>/dev/null | awk '/pixelHeight/ {print $2}')" + printf '%s\t%sx%s\trequested %sx%s\twindow %sx%s\tmacOS direct-window screencapture\n' \ + "$NAME" "$PIXEL_WIDTH" "$PIXEL_HEIGHT" "$WIDTH" "$HEIGHT" \ + "$WINDOW_WIDTH" "$WINDOW_HEIGHT" >> "$CAPTURE_LOG" + : > "$ACK" +done < "$MANIFEST" + +set +e +wait "$APP_PID" +STATUS=$? +set -e +APP_PID="" +cp "$CAPTURE_LOG" "$OUT/capture-log.txt" + +while IFS=$'\t' read -r _ NAME _ _; do + if [[ ! -s $OUT/$NAME ]]; then + echo "missing: $NAME" >&2 + MISSING=$((MISSING + 1)) + fi +done < "$MANIFEST" + +echo "app exit $STATUS; $PLANNED planned; $MISSING missing; capture: macOS direct-window screencapture; output: $OUT" +[[ $STATUS -eq 0 && $MISSING -eq 0 ]] diff --git a/Scripts/contact-sheet.py b/Scripts/contact-sheet.py new file mode 100755 index 000000000..9f865881a --- /dev/null +++ b/Scripts/contact-sheet.py @@ -0,0 +1,60 @@ +#!/usr/bin/env python3 +"""Place an implementation capture beside its reference mockup for review. + + python3 Scripts/contact-sheet.py capture.png mockup.png out.png + [--crop x,y,w,h] [--left "Implementation"] [--right "Mockup"] + +--crop trims the mockup (in mockup pixels) to the region a screen slice owns. +""" + +import argparse + +from PIL import Image, ImageDraw, ImageFont + + +HEIGHT = 1000 +GUTTER = 24 +LABEL_H = 36 + + +def fit(image: Image.Image, height: int) -> Image.Image: + width = round(image.width * height / image.height) + return image.convert("RGB").resize((width, height), Image.Resampling.LANCZOS) + + +def main() -> None: + parser = argparse.ArgumentParser() + parser.add_argument("capture") + parser.add_argument("mockup") + parser.add_argument("out") + parser.add_argument("--crop", help="x,y,w,h in mockup pixels") + parser.add_argument("--left", default="Implementation") + parser.add_argument("--right", default="Mockup") + args = parser.parse_args() + + with Image.open(args.capture) as capture: + left = fit(capture, HEIGHT) + with Image.open(args.mockup) as mockup: + if args.crop: + x, y, width, height = (int(value) for value in args.crop.split(",")) + mockup = mockup.crop((x, y, x + width, y + height)) + right = fit(mockup, HEIGHT) + + sheet = Image.new( + "RGB", (left.width + GUTTER + right.width, HEIGHT + LABEL_H), (28, 22, 24) + ) + draw = ImageDraw.Draw(sheet) + try: + font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Arial.ttf", 20) + except OSError: + font = ImageFont.load_default() + draw.text((8, 8), args.left, fill=(243, 233, 215), font=font) + draw.text((left.width + GUTTER + 8, 8), args.right, fill=(243, 233, 215), font=font) + sheet.paste(left, (0, LABEL_H)) + sheet.paste(right, (left.width + GUTTER, LABEL_H)) + sheet.save(args.out) + print(f"{args.out}: {sheet.width}x{sheet.height}") + + +if __name__ == "__main__": + main() diff --git a/Scripts/crop-macos-window.py b/Scripts/crop-macos-window.py new file mode 100644 index 000000000..b3c29b6ef --- /dev/null +++ b/Scripts/crop-macos-window.py @@ -0,0 +1,46 @@ +#!/usr/bin/env python3 +"""Crop a macOS whole-window screenshot down to Avalonia's client area.""" + +from pathlib import Path +import sys + +from PIL import Image + + +def main() -> int: + if len(sys.argv) != 7: + raise SystemExit( + "usage: crop-macos-window.py " + " " + ) + + source, target = map(Path, sys.argv[1:3]) + window_width, window_height, content_width, content_height = map( + int, sys.argv[3:] + ) + + with Image.open(source) as image: + scale_x = image.width / window_width + scale_y = image.height / window_height + if abs(scale_x - scale_y) > 0.01: + raise SystemExit( + f"inconsistent capture scale: horizontal {scale_x:g}, vertical {scale_y:g}" + ) + + target_width = round(content_width * scale_x) + target_height = round(content_height * scale_y) + if target_width > image.width or target_height > image.height: + raise SystemExit( + f"requested content {target_width}x{target_height} exceeds " + f"captured window {image.width}x{image.height}" + ) + + left = (image.width - target_width) // 2 + top = image.height - target_height + image.crop((left, top, left + target_width, image.height)).save(target) + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/Scripts/demo-profile.cs b/Scripts/demo-profile.cs new file mode 100644 index 000000000..46ad476ab --- /dev/null +++ b/Scripts/demo-profile.cs @@ -0,0 +1,114 @@ +#:project ../Source/AppScaffolding/AppScaffolding.csproj + +using AppScaffolding; +using LibationFileManager; +using System.Diagnostics; +using System.Security.Cryptography; + +// Creates or updates an isolated Libation profile for UI verification. Nothing here touches the +// real profile: the folder is selected through LIBATION_FILES_DIR before Configuration loads. +// +// dotnet run Scripts/demo-profile.cs -- [--style Cellar] [--route Overview] +// [--view Gallery] [--accounts 1] [--reset] +// +// After this script, seed books with seed-demo-library.cs and covers with seed-demo-covers.py. + +var dir = args.FirstOrDefault(a => !a.StartsWith("--")) + ?? throw new ArgumentException("Pass the profile directory as the first argument."); +dir = Path.GetFullPath(dir); + +string Opt(string name, string fallback) +{ + var i = Array.IndexOf(args, name); + return i >= 0 && i + 1 < args.Length ? args[i + 1] : fallback; +} + +if (args.Contains("--reset") && Directory.Exists(dir)) + Directory.Delete(dir, recursive: true); + +Directory.CreateDirectory(dir); +var books = Directory.CreateDirectory(Path.Combine(dir, "Books")).FullName; +var inProgress = Directory.CreateDirectory(Path.Combine(dir, "InProgress")).FullName; + +var keyFile = Path.Combine(dir, "libation-master.key"); +if (!File.Exists(keyFile)) + File.WriteAllBytes(keyFile, RandomNumberGenerator.GetBytes(32)); + +var settingsFile = Path.Combine(dir, LibationFiles.SETTINGS_JSON); +if (!File.Exists(settingsFile)) + File.WriteAllText(settingsFile, "{}"); + +Environment.SetEnvironmentVariable(LibationFiles.LIBATION_FILES_DIR, dir); +Environment.SetEnvironmentVariable("LIBATION_MASTER_KEY_FILE", keyFile); + +var config = LibationScaffolding.RunPreConfigMigrations(); +LibationScaffolding.RunPostConfigMigrations(config); +config.Books = books; +config.InProgress = inProgress; +config.FirstLaunch = false; +config.CheckForUpgradesAtStartup = false; +config.ContemporaryLastRoute = Opt("--route", "Overview"); +config.SaveContemporaryExperienceSettings(new ContemporaryExperienceSettings( + Enum.Parse(Opt("--style", "Cellar")), + DensityMode.Comfortable, + DecorationLevel.Full, + ReducedMotionPreference.Full, + UseSystemTypography: false, + Enum.Parse(Opt("--view", "Gallery")), + NavigationRailPreference.Automatic, + ShowDecanterDock: true, + PersistFlightBetweenSessions: false, + UseContemporaryShell: true)); + +Console.WriteLine($"Settings written: {settingsFile}"); + +// The CLI runs the same migrations as the GUI and creates LibationContext.db and AccountsSettings.json. +var scriptsDir = Path.GetDirectoryName(Path.GetFullPath("Scripts/demo-profile.cs")) ?? "."; +var cliProject = FindUp("Source/LibationCli/LibationCli.csproj", scriptsDir) + ?? throw new FileNotFoundException( + "Could not locate Source/LibationCli/LibationCli.csproj above Scripts/demo-profile.cs. Run this script from the repository root."); +var cliAssembly = Path.GetFullPath(Path.Combine(Path.GetDirectoryName(cliProject)!, "..", "bin", "Release", "LibationCli.dll")); + +Run(Dotnet(), $"build \"{cliProject}\" -c Release --disable-build-servers -m:1 -v:minimal"); +Run(Dotnet(), $"\"{cliAssembly}\" version"); +Run(Dotnet(), $"\"{cliAssembly}\" list-accounts"); +Run(Dotnet(), $"\"{cliAssembly}\" search --bare __S0_DEMO_PROFILE_INIT__"); + +var accounts = int.Parse(Opt("--accounts", "0")); +if (accounts > 0) +{ + var accountsScript = Path.Combine(Path.GetDirectoryName(cliProject)!, "..", "..", "Scripts", "seed-demo-accounts.cs"); + Run(Dotnet(), $"run \"{Path.GetFullPath(accountsScript)}\" -- --count {accounts} \"{dir}\""); +} + +var requiredFiles = new[] { "Settings.json", "libation-master.key", "LibationContext.db", "AccountsSettings.json" }; +foreach (var required in requiredFiles) + Console.WriteLine($"{(File.Exists(Path.Combine(dir, required)) ? "ok " : "MISSING ")}{required}"); + +return requiredFiles.All(required => File.Exists(Path.Combine(dir, required))) ? 0 : 2; + +static string Dotnet() + => Environment.GetEnvironmentVariable("DOTNET") is { Length: > 0 } explicitPath + ? explicitPath + : Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), ".dotnet", "dotnet"); + +static string? FindUp(string relative, string start) +{ + for (var d = new DirectoryInfo(start); d is not null; d = d.Parent) + { + var candidate = Path.Combine(d.FullName, relative); + if (File.Exists(candidate)) + return candidate; + } + return null; +} + +static void Run(string file, string arguments) +{ + var psi = new ProcessStartInfo(file, arguments) { UseShellExecute = false }; + psi.Environment["DOTNET_ROOT"] = Path.GetDirectoryName(file); + using var process = Process.Start(psi) ?? throw new InvalidOperationException($"Could not start {file}"); + process.WaitForExit(); + if (process.ExitCode != 0) + throw new InvalidOperationException($"{file} {arguments} exited {process.ExitCode}"); +} diff --git a/Scripts/macos-window-id.swift b/Scripts/macos-window-id.swift new file mode 100644 index 000000000..7e44910fa --- /dev/null +++ b/Scripts/macos-window-id.swift @@ -0,0 +1,42 @@ +#!/usr/bin/env swift +import CoreGraphics +import Foundation + +guard CommandLine.arguments.count == 2, + let requestedPid = Int32(CommandLine.arguments[1]) else { + fputs("usage: macos-window-id.swift \n", stderr) + exit(2) +} + +// Direct-window capture can render a layer-zero window even when macOS has placed +// that process on another Space. Restricting discovery to the current Space makes +// an otherwise valid isolated capture wait until the full-plan timeout whenever a +// normal installed Libation window is already active. +let options: CGWindowListOption = [.excludeDesktopElements] +guard let windows = CGWindowListCopyWindowInfo(options, kCGNullWindowID) + as? [[CFString: Any]] else { + exit(1) +} + +let matches = windows.compactMap { window -> (id: CGWindowID, bounds: CGRect)? in + guard let owner = window[kCGWindowOwnerPID] as? NSNumber, + owner.int32Value == requestedPid, + let layer = window[kCGWindowLayer] as? NSNumber, + layer.intValue == 0, + let number = window[kCGWindowNumber] as? NSNumber, + let boundsDictionary = window[kCGWindowBounds] as? NSDictionary, + let bounds = CGRect(dictionaryRepresentation: boundsDictionary), + bounds.width > 0, + bounds.height > 0 else { + return nil + } + return (number.uint32Value, bounds) +} + +guard let window = matches.max(by: { + $0.bounds.width * $0.bounds.height < $1.bounds.width * $1.bounds.height +}) else { + exit(1) +} + +print("\(window.id)\t\(Int(window.bounds.width.rounded()))\t\(Int(window.bounds.height.rounded()))") diff --git a/Scripts/seed-demo-accounts.cs b/Scripts/seed-demo-accounts.cs new file mode 100644 index 000000000..6cff13bde --- /dev/null +++ b/Scripts/seed-demo-accounts.cs @@ -0,0 +1,236 @@ +#:package AudibleApi@11.0.3.1 + +using AudibleApi; +using AudibleApi.Authorization; +using AudibleApi.Cryptography; +using Newtonsoft.Json; +using Newtonsoft.Json.Linq; + +// Adds fake Audible accounts to AccountsSettings.json, so the parts of Libation that only appear once an +// account exists can be tested without signing in to Audible. +// +// dotnet run Scripts/seed-demo-accounts.cs [--count 1] [--clean] [path-to-Libation-files-folder] +// +// Run Libation at least once first so AccountsSettings.json exists, and close it before seeding: the file is +// read at startup and written back on save, so a running app would overwrite whatever this wrote. +// +// The accounts carry structurally valid but meaningless tokens. Libation will count them, list them and +// enable everything that is gated on having an account, but any scan or download attempt fails at Audible. +// +// Real accounts in the file are left alone. --clean removes only the accounts this script added, which are +// the ones whose AccountId matches demo*@example.com. +// +// Keep the package version above in step with AudibleApi in Source/AudibleUtilities/AudibleUtilities.csproj, +// so the JSON this writes is the shape the app expects to read. + +// Fixture material. A syntactically valid RSA key and ADP token so the identity parses; neither authenticates. +const string SamplePrivateKey = @" +-----BEGIN RSA PRIVATE KEY----- +MIIEpgIBAAKCAQEA5nPbGSVDmlEH2tJa6kz/P2HI8IeirhfPHdmi+X/nsb9i3WNf +tmEdZxfK26IValQDXvBH17a1gr0HD6pYse1XsV2w0HxiW1RW+ZnjL8/fzPdkSOb+ +4xKlqRopCueBSdDGgAF06spZ3IeHLfEFOJX4dO1Y73pFBUkA0k53LT12L2Tjay/r +buZHJqIzxmwja7/nkiWL0Xo7UySHtQACYsKEatu6yHBS+cPTlGR/qeUpeJTHwDLP +7ZQ7kWzJGY1mfInYekjlZLsMsWswso3pg1vPyHgxzM2BWhY8m6mlXQ9G/USxBTib +MNuMtpR73XsgamneFCc+Uv1cxw7ofZ41YOOAbQIDAQABAoIBAQDIre8HkKm0Aggj +B7df/TjxCsgenR6PF/Cmf9UqC7XJ1W3UeCrq+NrP4aonZJfdhdeBnyAQuuyJMu6p +N6ARISuSKpJEm2xTN7idluJ9yjmLlYtg6LbhKmXUQhGniz3M999DrQERTLDAF80h +tpbjVcWMnPsrX4AnQBFVEjs5zCHU1hD+X463EmUHBWyT975jbZ8Fy7/fTzkdzLnn +qE5lROALr2MCAAwQRFbRE6dd52vnXaBrVcAtRzjATts3WG3+SNi2Fm/OrYqQcY9e +lBexNviT8VcldOAMrO10E2u0d+tvxFzwB3ABMvaVamrEZky4XSfB6aLzpD0JJj1s +UHnIiVwJAoGBAPl8nLll/J9rud/N2HiAX2YkP0MC0HW4yM3KxLtXKyXrP5qBpaci +wTDUmSWEEE3GUJMM1Z4d9tl9Lz2MhU2KqkEvLI3kQ7aUu33PYUBGMVcUzhFQ49lU +Nzz8YB183iqo31o/DKk2Cr5gI7SykQZ0gn/urZkEJeErLzlhPXcyeY5jAoGBAOx4 +CGucVdv5MbdXZP8jVzxuvUlSp7BIQJ2phQXDFBNApFKnZn7yBYBx7dqzleymGm+R +INZAurg3SNw4nvbQc3Z2dJ8I+n5ErjFCKp1IedVxx1eMEfecTwrQZuUwLISIyjqF +czSJNwcNqzCx67z397/Cg5K/0pu6uIe0r7xozcbvAoGBAOOvZ9CDVPOg+rdXQvFm +Jqou9lUPonNtOkUlgjl+qfAnK5q0KxvHSgxoWYO1bLOuAybQlbuBmSCPcKd5MMa9 +f/eRN9YetfVQ83Mz6YshBDJ22EFRUz+p7eeIY6dFp/PCvmO8Gq/qlA996dglBtmf +RuG+T0vQT0mZgbWaGuBHfkwFAoGBAMOLg1MRxgKRMKavk6pU3EfyP3+J5XemWCDI +1WLtbgV5uClNmzmxBBGypQHs7jbzKPtHpULn5kB+HzdVb0clG8ZDsK7u6s5OF0pO +sBS+oVl7rF/eSeFcFhUYP26ZhsbWo3z/bERuj926VO2AxDPRTsP5o3pQPGZhY0V9 +irGgbUJrAoGBAOseS3J4BqYM4R3Hr7cRAhvzSjIkeTcDF1zTOa4FZDHBxZ6g2PNq +8ekhtfn1zPczsPTF1vNuqEISKLxaPkVPiw0mtaZQjVwpF/IOxMNjWVLp6oJf8Mm2 +BxlXqPnQ4mG66oqSFQgDEmFdMhRb2of6xL1gYYL62C80G2T7QtmPfSab +-----END RSA PRIVATE KEY----- +"; + +const string SampleAdpToken = "{enc:abcdefg}{key:1234}{iv:56789}{name:QURQVG9rZW5FbmNyeXB0aW9uS2V5}{serial:Mg==}"; + +const string DemoAccountSuffix = "@example.com"; +const string DemoAccountPrefix = "demo"; + +var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase); +var count = IntArg("--count") ?? 1; +var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _)); + +if (count < 1) +{ + Console.Error.WriteLine("--count must be at least 1."); + return 1; +} + +if (FindLibationFiles(pathArg) is not string libationFiles) +{ + Console.Error.WriteLine("Could not find a Libation accounts file."); + Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:"); + Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-accounts.cs C:\Users\me\AppData\Local\Libation"); + return 1; +} + +var accountsPath = Path.Combine(libationFiles, "AccountsSettings.json"); +Console.WriteLine($"Accounts file: {accountsPath}"); + +var root = JObject.Parse(File.ReadAllText(accountsPath)); +if (root["Accounts"] is not JArray accounts) +{ + accounts = []; + root["Accounts"] = accounts; +} + +/// +/// Whether this script wrote the account, decided from its id rather than a marker property. +/// +/// +/// A property would be tidier but does not survive: AudibleUtilities.Account serializes exactly five members +/// and has no [JsonExtensionData], so Newtonsoft ignores an extra property on the way in and never writes it +/// back out. Verified by adding one and renaming the account in the app - the save dropped it. Anything that +/// dirties an account does the same, including a token refresh during a scan, and --clean would then leave +/// demo accounts behind with no way to find them. Giving the shipping Account class a demo flag to serve a +/// dev script is not a trade worth making. +/// +/// The id is the right anchor instead: it is immutable, always persisted, and example.com is reserved by +/// RFC 2606, so no real Audible login can collide with it. +/// +static bool IsDemo(JToken account) + => account["AccountId"]?.Value() is string id + && id.StartsWith(DemoAccountPrefix, StringComparison.OrdinalIgnoreCase) + && id.EndsWith(DemoAccountSuffix, StringComparison.OrdinalIgnoreCase); + +if (clean) +{ + var removed = accounts.Where(IsDemo).ToList(); + foreach (var account in removed) + account.Remove(); + + File.WriteAllText(accountsPath, root.ToString(Formatting.Indented)); + Console.WriteLine($"Removed {removed.Count} demo account(s). {accounts.Count} account(s) remain."); + return 0; +} + +var realAccounts = accounts.Count(a => !IsDemo(a)); +if (realAccounts > 0) + Console.WriteLine($"Leaving {realAccounts} real account(s) untouched."); + +var added = 0; +foreach (var accountId in DemoAccountIds(count)) +{ + if (accounts.Any(a => string.Equals(a["AccountId"]?.Value(), accountId, StringComparison.OrdinalIgnoreCase))) + { + Console.WriteLine($" {accountId} already present, skipping"); + continue; + } + + accounts.Add(BuildAccount(accountId)); + added++; +} + +File.WriteAllText(accountsPath, root.ToString(Formatting.Indented)); + +Console.WriteLine($"Added {added} demo account(s). Libation will now see {accounts.Count} account(s)."); +Console.WriteLine(); +Console.WriteLine("Start Libation. What the account count changes:"); +Console.WriteLine(" 1 account Import > Scan Library, Import > Remove Library Books"); +Console.WriteLine(" 2 or more Import > Scan Library of All / Some Accounts, and the matching Remove items"); +Console.WriteLine(" any an empty library offers 'Scan Library' rather than 'Add Account'"); +Console.WriteLine(); +Console.WriteLine("Do not scan or download: these accounts have meaningless tokens and Audible will refuse them."); +return 0; + +/// +/// The first account matches the one seed-demo-library.cs assigns its books to, so seeded books and a +/// seeded account describe the same library rather than two unrelated ones. +/// +static IEnumerable DemoAccountIds(int count) +{ + yield return $"{DemoAccountPrefix}{DemoAccountSuffix}"; + + for (var n = 2; n <= count; n++) + yield return $"{DemoAccountPrefix}{n}{DemoAccountSuffix}"; +} + +/// +/// An account entry carrying a registered-looking identity. The values are fixture material, the same shape +/// AudibleApi writes after a real registration, so the file parses and the account loads. +/// +static JObject BuildAccount(string accountId) +{ + var identity = new Identity(Localization.Get("us")); + identity.Update( + new PrivateKey(SamplePrivateKey), + new AdpToken(SampleAdpToken), + new AccessToken($"Atna|_DEMO_ACCESS_{accountId}", new DateTime(2200, 1, 1, 12, 0, 0, DateTimeKind.Utc)), + new RefreshToken($"Atnr|_DEMO_REFRESH_{accountId}"), + [new KeyValuePair("session-id", "demo-cookie-value")], + deviceSerialNumber: "demo-device-serial", + deviceType: "demo-device-type", + amazonAccountId: "demo-amazon-account", + deviceName: "Demo Device", + storeAuthenticationCookie: "demo-store-auth-cookie"); + + // Write the tokens as plaintext rather than reaching for the OS secret store, which this script has no + // business touching. Libation reads either, whatever its own Token storage preference is set to. + IdentityTokenStorage.Configure(TokenStorageMethod.Plaintext, protector: null); + + return new JObject + { + ["AccountId"] = accountId, + ["AccountName"] = $"Demo ({accountId})", + ["LibraryScan"] = true, + ["DecryptKey"] = "", + ["IdentityTokens"] = JObject.Parse(JsonConvert.SerializeObject(identity, Identity.GetJsonSerializerSettings())) + }; +} + +int? IntArg(string name) +{ + var i = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); + return i >= 0 && i + 1 < args.Length && int.TryParse(args[i + 1], out var value) ? value : null; +} + +/// Locate the Libation files folder the same way Libation itself does. +static string? FindLibationFiles(string? explicitPath) +{ + var candidates = new List(); + + if (explicitPath is not null) + candidates.Add(explicitPath); + + foreach (var folder in KnownLibationFolders()) + { + candidates.Add(folder); + + // appsettings.json may redirect the files folder somewhere else entirely. + var appSettings = Path.Combine(folder, "appsettings.json"); + if (!File.Exists(appSettings)) + continue; + + try + { + if (System.Text.Json.JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement + .TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path) + candidates.Add(path); + } + catch (System.Text.Json.JsonException) { } + } + + return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "AccountsSettings.json"))); +} + +static IEnumerable KnownLibationFolders() +{ + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"); + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"); + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Libation"); + yield return Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}"); + yield return AppContext.BaseDirectory; +} diff --git a/Scripts/seed-demo-covers.py b/Scripts/seed-demo-covers.py new file mode 100644 index 000000000..9ee2125a1 --- /dev/null +++ b/Scripts/seed-demo-covers.py @@ -0,0 +1,68 @@ +#!/usr/bin/env python3 +"""Write placeholder cover JPEGs for seeded demo books. + + python3 Scripts/seed-demo-covers.py + +Files follow PictureStorage's naming: _80x80.jpg, _300x300, _500x500 and Native. +Each cover is a solid color derived from the picture id with the title's initials, so it is +recognizable in screenshots without pretending to be real artwork. +""" +import hashlib +import os +import sqlite3 +import sys + +from PIL import Image, ImageDraw, ImageFont + +SIZES = [("_80x80", 80), ("_300x300", 300), ("_500x500", 500), ("Native", 500)] + + +def initials(title: str) -> str: + words = [word for word in title.replace(":", " ").split() if word[0].isalpha()] + return "".join(word[0] for word in words[:2]).upper() or "?" + + +def color_for(picture_id: str) -> tuple[int, int, int]: + digest = hashlib.sha1(picture_id.encode()).digest() + return (60 + digest[0] % 120, 50 + digest[1] % 100, 60 + digest[2] % 120) + + +def main(folder: str) -> int: + db = os.path.join(folder, "LibationContext.db") + images = os.path.join(folder, "Images") + os.makedirs(images, exist_ok=True) + rows = sqlite3.connect(db).execute( + "select PictureId, Title from Books where PictureId is not null and PictureId like 'DEMO%'" + ).fetchall() + for picture_id, title in rows: + base = color_for(picture_id) + for suffix, px in SIZES: + image = Image.new("RGB", (px, px), base) + draw = ImageDraw.Draw(image) + draw.rectangle( + [px * 0.08, px * 0.08, px * 0.92, px * 0.92], + outline=(240, 230, 210), + width=max(1, px // 60), + ) + try: + font = ImageFont.truetype("/System/Library/Fonts/Supplemental/Georgia.ttf", px // 3) + except OSError: + font = ImageFont.load_default() + text = initials(title) + box = draw.textbbox((0, 0), text, font=font) + draw.text( + ((px - box[2]) / 2, (px - box[3]) / 2 - px * 0.05), + text, + fill=(245, 235, 215), + font=font, + ) + image.save(os.path.join(images, f"{picture_id}{suffix}.jpg"), quality=85) + print(f"Wrote {len(rows) * len(SIZES)} cover files for {len(rows)} books into {images}") + return 0 + + +if __name__ == "__main__": + if len(sys.argv) != 2: + print(__doc__) + sys.exit(2) + sys.exit(main(sys.argv[1])) diff --git a/Scripts/seed-demo-library.cs b/Scripts/seed-demo-library.cs new file mode 100644 index 000000000..ec0e1636a --- /dev/null +++ b/Scripts/seed-demo-library.cs @@ -0,0 +1,554 @@ +#:package Microsoft.Data.Sqlite@10.0.7 + +using Microsoft.Data.Sqlite; +using System.Text.Json; + +// Seeds a Libation library with one book per Liberate-column state, plus a few titles that +// subtitle removal changes, for manual UI testing. +// +// dotnet run Scripts/seed-demo-library.cs [--clean] [path-to-Libation-files-folder] +// +// Run Libation at least once first so the database exists, and close it before seeding. +// Pass --clean to delete previously seeded demo books instead of adding them. +// +// Reproducing every icon by hand is tedious, and two of them are not database state at all: +// the yellow lamp means "a partial download is sitting on disk", so this writes placeholder +// .aaxc files as well. +// +// Writing rows directly also leaves Libation's search index stale, which makes every filter on the +// seeded books come back empty, so this deletes the index and lets Libation rebuild it. + +const int NotLiberated = 0, Liberated = 1, Error = 2; +const int Product = 1, Episode = 2, Parent = 4; +const string AsinPrefix = "DEMO"; + +var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase); +var countIndex = Array.FindIndex(args, a => a.Equals("--count", StringComparison.OrdinalIgnoreCase)); +var generatedCount = countIndex >= 0 && countIndex + 1 < args.Length ? int.Parse(args[countIndex + 1]) : 0; +var pathArg = args + .Where((a, i) => !a.StartsWith("--") && !(countIndex >= 0 && i == countIndex + 1)) + .FirstOrDefault(); + +if (FindLibationFiles(pathArg) is not string libationFiles) +{ + Console.Error.WriteLine("Could not find a Libation database."); + Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:"); + Console.Error.WriteLine(@" dotnet run Scripts/seed-demo-library.cs C:\Users\me\AppData\Local\Libation"); + return 1; +} + +var dbPath = Path.Combine(libationFiles, "LibationContext.db"); +Console.WriteLine($"Database: {dbPath}"); + +using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString()); +connection.Open(); +using var transaction = connection.BeginTransaction(); + +if (clean) +{ + // Books cascade to UserDefinedItem, LibraryBooks, Supplement, BookContributor and SeriesBook, + // but the Series and Contributors rows those pointed at are left behind. + var removed = Execute($"delete from Books where AudibleProductId like '{AsinPrefix}%'"); + Execute($"delete from Series where AudibleSeriesId like '{AsinPrefix}%' and SeriesId not in (select SeriesId from SeriesBook)"); + Execute("delete from Contributors where (AudibleContributorId = Name or AudibleContributorId = 'DEMOAUTH') and ContributorId not in (select ContributorId from BookContributor)"); + transaction.Commit(); + + foreach (var partial in Directory.Exists(DownloadsInProgress()) + ? Directory.EnumerateFiles(DownloadsInProgress(), $"{AsinPrefix}*.aaxc") + : []) + File.Delete(partial); + + Console.WriteLine($"Removed {removed} demo book(s) and their placeholder downloads."); + InvalidateSearchIndex(); + return 0; +} + +var books = generatedCount > 0 ? BuildGeneratedBooks(generatedCount) : BuildDemoBooks(); + +var added = 0; +var partials = new List(); + +foreach (var book in books) +{ + if (Scalar("select count(*) from Books where AudibleProductId = $asin", ("$asin", book.Asin)) > 0) + { + Console.WriteLine($" {book.Asin} already present, skipping"); + continue; + } + + Execute( + """ + insert into Books + (AudibleProductId, ContentType, Description, IsAbridged, IsSpatial, LengthInMinutes, + Locale, Rating_OverallRating, Rating_PerformanceRating, Rating_StoryRating, Subtitle, Title, PictureId) + values ($asin, $contentType, 'Seeded by seed-demo-library.cs', 0, 0, $length, 'us', 0, 0, 0, $subtitle, $title, $pictureId) + """, + ("$asin", book.Asin), ("$contentType", book.ContentType), ("$title", book.Title), ("$subtitle", book.Subtitle), + ("$length", book.LengthInMinutes), ("$pictureId", (object?)book.PictureId ?? DBNull.Value)); + + var bookId = Scalar("select BookId from Books where AudibleProductId = $asin", ("$asin", book.Asin)); + + // PartialDownload is never persisted - the BookStatus setter rewrites it to NotLiberated, + // because it is derived from a file on disk instead. + Execute( + """ + insert into UserDefinedItem + (BookId, BookStatus, IsFinished, PdfStatus, Rating_OverallRating, + Rating_PerformanceRating, Rating_StoryRating, Tags) + values ($bookId, $bookStatus, 0, $pdfStatus, 0, 0, 0, '') + """, + ("$bookId", bookId), ("$bookStatus", book.BookStatus), ("$pdfStatus", (object?)book.PdfStatus ?? DBNull.Value)); + + Execute( + """ + insert into LibraryBooks (BookId, AbsentFromLastScan, Account, DateAdded, IncludedUntil, IsAudiblePlus, IsDeleted) + values ($bookId, $isAbsent, 'demo@example.com', $dateAdded, $includedUntil, $isPlus, $isDeleted) + """, + ("$bookId", bookId), + ("$isAbsent", book.IsAbsent ? 1 : 0), + ("$dateAdded", book.DateAdded), + ("$includedUntil", book.IsPlus ? "2027-01-31 00:00:00" : (object)DBNull.Value), + ("$isPlus", book.IsPlus ? 1 : 0), + ("$isDeleted", book.IsDeleted ? 1 : 0)); + + var authorId = GetOrCreateId( + "select ContributorId from Contributors where Name = $id", + "insert into Contributors (Name, AudibleContributorId) values ($id, $id)", + book.Author); + Execute( + "insert into BookContributor (BookId, ContributorId, Role, [Order]) values ($bookId, $contributorId, 1, 0)", + ("$bookId", bookId), ("$contributorId", authorId)); + if (book.Narrator is not null) + { + var narratorId = GetOrCreateId( + "select ContributorId from Contributors where Name = $id", + "insert into Contributors (Name, AudibleContributorId) values ($id, $id)", + book.Narrator); + Execute( + "insert into BookContributor (BookId, ContributorId, Role, [Order]) values ($bookId, $contributorId, 2, 0)", + ("$bookId", bookId), ("$contributorId", narratorId)); + } + + // The PDF glyph itself keys off UserDefinedItem.PdfStatus, but Book.HasPdf - which drives the + // context menu and the "Has PDF" detail - needs an actual supplement. + if (book.PdfStatus is not null) + Execute( + "insert into Supplement (BookId, Url) values ($bookId, 'https://example.com/demo.pdf')", + ("$bookId", bookId)); + + if (book.SeriesAsin is not null) + { + var seriesId = GetOrCreateId( + "select SeriesId from Series where AudibleSeriesId = $id", + "insert into Series (AudibleSeriesId, Name) values ($id, 'Demo Series')", + book.SeriesAsin); + + Execute( + "insert into SeriesBook (SeriesId, BookId, [Order]) values ($seriesId, $bookId, $order)", + ("$seriesId", seriesId), ("$bookId", bookId), ("$order", (object?)book.SeriesOrder ?? DBNull.Value)); + } + + if (book.NeedsPartialDownload) + partials.Add(book.Asin); + + added++; +} + +transaction.Commit(); +Console.WriteLine($"Added {added} book(s)."); +InvalidateSearchIndex(); + +if (partials.Count > 0) +{ + var inProgress = DownloadsInProgress(); + Directory.CreateDirectory(inProgress); + foreach (var asin in partials) + File.WriteAllText(Path.Combine(inProgress, $"{asin}.aaxc"), "not a real download"); + + Console.WriteLine($"Wrote {partials.Count} placeholder download(s) to {inProgress}"); +} + +Console.WriteLine(); +Console.WriteLine("Filtering covers these books. Try HasSubtitle , TitleHasColon , or Trashed for no matches."); +Console.WriteLine(); +if (generatedCount == 0) +{ + Console.WriteLine("Start Libation and sort by Title. Expected Liberate column, top to bottom:"); + foreach (var book in books.Where(b => !b.IsDeleted)) + Console.WriteLine($" {book.Title,-46} {book.Expectation}"); +} +else +{ + Console.WriteLine($"Seeded {added} generated titles."); +} + +var trashed = books.Where(b => b.IsDeleted).ToList(); +if (trashed.Count > 0 && generatedCount == 0) +{ + Console.WriteLine(); + Console.WriteLine($"{trashed.Count} seeded book(s) are in the trash, so they are NOT in the grid:"); + foreach (var book in trashed) + Console.WriteLine($" {book.Title,-46} {book.Expectation}"); + Console.WriteLine(); + Console.WriteLine(" - the status bar should end with a clickable \"N in trash\""); + Console.WriteLine(" - Settings > Trash Bin should carry the same count"); + Console.WriteLine(" - filtering for Trashed should find nothing and offer to open the trash bin"); +} +else if (trashed.Count > 0) +{ + Console.WriteLine($"Seeded {trashed.Count} generated title(s) in the trash."); +} + +Console.WriteLine(); +Console.WriteLine("Do not click the stoplights: these books are not real and cannot be downloaded."); +return 0; + +static List BuildDemoBooks() +{ + var books = new List(); + var n = 0; + + // The whole lamp/PDF/Plus matrix, so every stoplight rendering is on screen at once. + foreach (var (lamp, bookStatus, needsPartial) in new[] + { + ("Green", Liberated, false), + ("Yellow", NotLiberated, true), + ("Red", NotLiberated, false), + }) + foreach (var (pdfLabel, pdfStatus) in new (string, int?)[] + { + ("no PDF", null), + ("PDF done", Liberated), + ("PDF todo", NotLiberated), + }) + foreach (var isPlus in new[] { false, true }) + { + var owner = isPlus ? "PLUS" : "purchased"; + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} {lamp} | {pdfLabel} | {owner}", + ContentType: Product, + BookStatus: bookStatus, + PdfStatus: pdfStatus, + IsPlus: isPlus, + NeedsPartialDownload: needsPartial, + Expectation: $"{lamp} lamp, {pdfLabel}, {(isPlus ? "orange PLUS badge" : "NO badge")}")); + } + + // The error icon replaces the stoplight entirely, and ignores both PDF and Plus. + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} Error | purchased", + ContentType: Product, + BookStatus: Error, + PdfStatus: null, + IsPlus: false, + NeedsPartialDownload: false, + Expectation: "red error sign, no stoplight")); + + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} Error | PLUS", + ContentType: Product, + BookStatus: Error, + PdfStatus: null, + IsPlus: true, + NeedsPartialDownload: false, + Expectation: "red error sign, still NO badge")); + + // A podcast: the parent row shows a plus/minus expander rather than a stoplight, and its + // episodes show ordinary stoplights. Libation identifies a podcast's series by the parent + // book's own ASIN, and hides any series whose children it cannot match, so the episodes have + // to point at that same ASIN or the parent row silently disappears. + var seriesAsin = $"{AsinPrefix}{++n:000}"; + books.Add(new DemoBook( + Asin: seriesAsin, + Title: $"{n:00} Demo Series (parent)", + ContentType: Parent, + BookStatus: NotLiberated, + PdfStatus: null, + IsPlus: false, + NeedsPartialDownload: false, + Expectation: "square plus/minus expander, never a badge", + SeriesAsin: seriesAsin)); + + foreach (var (episode, bookStatus, isPlus) in new[] + { + (1, Liberated, false), + (2, NotLiberated, true), + }) + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} Demo Series - episode {episode}", + ContentType: Episode, + BookStatus: bookStatus, + PdfStatus: null, + IsPlus: isPlus, + NeedsPartialDownload: false, + Expectation: $"{(bookStatus == Liberated ? "green" : "red")} lamp, {(isPlus ? "orange PLUS badge" : "NO badge")}", + SeriesAsin: seriesAsin, + SeriesOrder: episode.ToString())); + + // Books missing from the last scan. These keep their ordinary icon - there is no "unavailable" + // glyph - and the grid dims and disables the row around it instead, so they are the rows to + // check when an icon change might read badly under that treatment. + // + // Being absent is not enough on its own: a book which is fully liberated with nothing left to + // fetch stays available, because there is nothing the row would do if you clicked it. + foreach (var (label, bookStatus, pdfStatus, isPlus, expectation) in new (string, int, int?, bool, string)[] + { + ("not downloaded | purchased", NotLiberated, null, false, "red lamp, dimmed and unclickable"), + ("not downloaded | PLUS", NotLiberated, null, true, "red lamp with badge, dimmed and unclickable"), + ("downloaded | purchased", Liberated, null, false, "green lamp, NOT dimmed - nothing left to fetch"), + ("downloaded, PDF todo | purchased", Liberated, NotLiberated, false, "green lamp with PDF arrow, dimmed again - the PDF is outstanding"), + }) + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} Absent | {label}", + ContentType: Product, + BookStatus: bookStatus, + PdfStatus: pdfStatus, + IsPlus: isPlus, + NeedsPartialDownload: false, + Expectation: expectation, + IsAbsent: true)); + + // Books in the trash. Removal is a soft delete, and a trashed book is filtered out of the grid, the + // search index and every status count at once, so nothing on screen distinguishes one from a book that + // was never imported. These rows are how that is checked: they must stay out of the grid while the + // status bar, the Settings > Trash Bin menu item and the empty-search hint all account for them. + foreach (var (label, bookStatus, isPlus, expectation) in new (string, int, bool, string)[] + { + ("purchased", NotLiberated, false, "in the trash bin only, red lamp there"), + ("PLUS", Liberated, true, "in the trash bin only, green lamp with badge there"), + }) + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} Trashed | {label}", + ContentType: Product, + BookStatus: bookStatus, + PdfStatus: null, + IsPlus: isPlus, + NeedsPartialDownload: false, + Expectation: expectation, + IsDeleted: true)); + + // A trashed episode whose parent is still in the library. The trash bin asks for every parent, not just + // deleted ones, so this should appear nested under the series there while the series keeps its other + // episodes in the main grid. + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: $"{n:00} Demo Series - episode 3 (trashed)", + ContentType: Episode, + BookStatus: NotLiberated, + PdfStatus: null, + IsPlus: false, + NeedsPartialDownload: false, + Expectation: "nested under Demo Series in the trash bin, absent from the grid", + SeriesAsin: seriesAsin, + SeriesOrder: "3", + IsDeleted: true)); + + // Titles that subtitle removal changes. stops at the first colon, so it cuts a colon in + // Audible's own title just as readily as it drops Audible's subtitle field, and the grid's Title column + // shows both the same way. The HasSubtitle and TitleHasColon filters are the only way to tell them apart. + foreach (var (title, subtitle, match) in new[] + { + ("A Book Series Omnibus", "Volume One", "HasSubtitle"), + ("A Book Series Omnibus", "Volume Two", "HasSubtitle"), + ("Star Trek: The Next Generation", "", "TitleHasColon"), + ("Dune: Book One", "The Graphic Novel", "both HasSubtitle and TitleHasColon"), + }) + books.Add(new DemoBook( + Asin: $"{AsinPrefix}{++n:000}", + Title: title, + ContentType: Product, + BookStatus: NotLiberated, + PdfStatus: null, + IsPlus: false, + NeedsPartialDownload: false, + Expectation: $"red lamp; filter match: {match}", + Subtitle: subtitle)); + + return books; +} + +/// <summary>Deterministic library of any size for layout and performance captures.</summary> +static List<DemoBook> BuildGeneratedBooks(int count) +{ + string[] first = ["The Quiet", "A Distant", "Beneath the", "Letters from", "The Last", "Notes on", "Songs of", "The Long", "Return to", "Under a", "The Cartographer's", "An Atlas of"]; + string[] second = ["Orchard", "Harbor", "Meridian", "Vineyard", "Winter", "Archive", "Tide", "Lantern", "Border", "Garden", "Mountain", "River", "Cellar", "Library", "Season"]; + string[] authors = ["Mara Ellison", "Tobias Reinholt", "Ines Okafor", "Declan Marsh", "Yuki Amano", "Rosa Villanueva", "Henrik Solberg", "Amara Nwosu", "Léa Fontaine", "Søren Kjær", "Priya Raman", "Oskar Lindqvist"]; + string[] narrators = ["Joanna Petrie", "Marcus Hale", "Adaeze Bello", "Simon Vance-Reed", "Kaori Ito", "Nadia Farouk", "Rob Inglis-Wynn", "Elena Sorokina"]; + var books = new List<DemoBook>(count); + for (var i = 0; i < count; i++) + { + var asin = $"DEMOGEN{i:D6}"; + var status = (i % 10) switch { < 6 => Liberated, < 9 => NotLiberated, _ => Error }; + int? pdf = (i % 4) switch { 0 => Liberated, 1 => NotLiberated, _ => null }; + var daysAgo = (i * 37) % 730; + var date = new DateTime(2026, 9, 1).AddDays(-daysAgo).ToString("yyyy-MM-dd HH:mm:ss"); + var series = i % 5 == 0 ? $"DEMOSER{i / 5 % 40:D3}" : null; + books.Add(new DemoBook( + Asin: asin, + Title: $"{first[i % first.Length]} {second[(i * 7) % second.Length]}{(i % 17 == 0 ? ": A Novel" : "")}", + ContentType: Product, + BookStatus: status, + PdfStatus: pdf, + IsPlus: i % 7 == 0, + NeedsPartialDownload: false, + Expectation: "", + SeriesAsin: series, + SeriesOrder: series is null ? null : ((i / 5) % 3 + 1).ToString(), + IsAbsent: i % 41 == 0, + Subtitle: i % 11 == 0 ? "Book Two of the Demo Sequence" : "", + IsDeleted: i % 53 == 0, + Author: authors[(i * 3) % authors.Length], + Narrator: narrators[(i * 5) % narrators.Length], + LengthInMinutes: 180 + (i * 97) % 1500, + DateAdded: date, + PictureId: asin)); + } + return books; +} + +/// <summary>Locate the Libation files folder the same way Libation itself does.</summary> +static string? FindLibationFiles(string? explicitPath) +{ + var candidates = new List<string>(); + + if (explicitPath is not null) + candidates.Add(explicitPath); + + foreach (var folder in KnownLibationFolders()) + { + candidates.Add(folder); + + // appsettings.json may redirect the files folder somewhere else entirely. + var appSettings = Path.Combine(folder, "appsettings.json"); + if (!File.Exists(appSettings)) + continue; + + try + { + if (JsonDocument.Parse(File.ReadAllText(appSettings)).RootElement + .TryGetProperty("LibationFiles", out var redirect) && redirect.GetString() is string path) + candidates.Add(path); + } + catch (JsonException) { } + } + + return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "LibationContext.db"))); +} + +static IEnumerable<string> KnownLibationFolders() +{ + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"); + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"); + yield return Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.ApplicationData), "Libation"); + yield return Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}"); + yield return AppContext.BaseDirectory; +} + +/// <summary>Where Libation looks for resumable downloads, honouring the InProgress setting.</summary> +string DownloadsInProgress() +{ + var inProgress = Path.Combine(Path.GetTempPath(), $"Libation-{Environment.UserName}"); + + var settings = Path.Combine(libationFiles, "Settings.json"); + if (File.Exists(settings)) + { + try + { + if (JsonDocument.Parse(File.ReadAllText(settings)).RootElement + .TryGetProperty("InProgress", out var configured) + && configured.GetString() is string path && !string.IsNullOrWhiteSpace(path)) + inProgress = path; + } + catch (JsonException) { } + } + + return Path.Combine(inProgress, "DownloadsInProgress"); +} + +/// <summary>Delete the Lucene index so Libation rebuilds it from the database.</summary> +/// <remarks> +/// This script writes rows straight to SQLite, which leaves the index untouched, so every filter on the +/// main grid came back empty no matter what had been seeded. Libation treats a missing index as a full +/// re-index rather than an error, so deleting the folder is enough: the first search rebuilds it from the +/// library, and the rebuild excludes trashed books the same way the grid does. +/// </remarks> +void InvalidateSearchIndex() +{ + var searchEngine = Path.Combine(libationFiles, "SearchEngine"); + if (!Directory.Exists(searchEngine)) + return; + + try + { + Directory.Delete(searchEngine, recursive: true); + Console.WriteLine("Deleted the search index. Libation rebuilds it on the first search or filter."); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException) + { + Console.Error.WriteLine($"Could not delete the search index at {searchEngine}: {ex.Message}"); + Console.Error.WriteLine("Close Libation and delete that folder by hand, or filtering will not find the seeded books."); + } +} + +/// <summary>Look up a row's id by its Audible id, inserting it first if it isn't there yet.</summary> +/// <remarks> +/// Neither Contributors.AudibleContributorId nor Series.AudibleSeriesId is unique, so an upsert +/// would happily insert a duplicate every time this script runs. +/// </remarks> +int GetOrCreateId(string selectSql, string insertSql, string audibleId) +{ + using (var select = Command(selectSql, [("$id", audibleId)])) + if (select.ExecuteScalar() is object existing and not DBNull) + return Convert.ToInt32(existing); + + Execute(insertSql, ("$id", audibleId)); + return Scalar("select last_insert_rowid()"); +} + +int Scalar(string sql, params (string Name, object Value)[] parameters) +{ + using var command = Command(sql, parameters); + return Convert.ToInt32(command.ExecuteScalar()); +} + +int Execute(string sql, params (string Name, object Value)[] parameters) +{ + using var command = Command(sql, parameters); + return command.ExecuteNonQuery(); +} + +SqliteCommand Command(string sql, (string Name, object Value)[] parameters) +{ + var command = connection.CreateCommand(); + command.Transaction = transaction; + command.CommandText = sql; + foreach (var (name, value) in parameters) + command.Parameters.AddWithValue(name, value); + return command; +} + +record DemoBook( + string Asin, + string Title, + int ContentType, + int BookStatus, + int? PdfStatus, + bool IsPlus, + bool NeedsPartialDownload, + string Expectation, + string? SeriesAsin = null, + string? SeriesOrder = null, + bool IsAbsent = false, + string Subtitle = "", + bool IsDeleted = false, + string Author = "Demo Author", + string? Narrator = null, + int LengthInMinutes = 600, + string DateAdded = "2026-08-13 00:00:00", + string? PictureId = null); diff --git a/Scripts/seed-download-history.cs b/Scripts/seed-download-history.cs new file mode 100644 index 000000000..77f6a4b19 --- /dev/null +++ b/Scripts/seed-download-history.cs @@ -0,0 +1,116 @@ +#:package Microsoft.Data.Sqlite@10.0.7 + +using Microsoft.Data.Sqlite; + +// Seeds Libation's DownloadHistory table with fake completed downloads, for manual testing of the +// daily download limit without downloading anything. +// +// dotnet run Scripts/seed-download-history.cs [--count 50] [--age-seconds 7200] [--owned] +// [--mb 300] [--clean] [path-to-Libation-files-folder] +// +// The limit uses a rolling 24 hour window, so --age-seconds controls how soon the rows fall out of it: +// +// --age-seconds 7200 two hours ago: the limit stays reached for the next 22 hours +// --age-seconds 86325 just under 24 hours ago: a paused queue resumes itself about a minute from now +// +// Pass --clean to delete all rows. Libation can be running; each row is just a row. + +const string FakeAsinPrefix = "FAKE"; + +var clean = args.Contains("--clean", StringComparer.OrdinalIgnoreCase); +var owned = args.Contains("--owned", StringComparer.OrdinalIgnoreCase); +var count = IntArg("--count") ?? 50; +var ageSeconds = IntArg("--age-seconds") ?? 7200; +var megabytes = IntArg("--mb") ?? 300; +var pathArg = args.FirstOrDefault(a => !a.StartsWith("--") && !int.TryParse(a, out _)); + +if (FindLibationFiles(pathArg) is not string libationFiles) +{ + Console.Error.WriteLine("Could not find a Libation database."); + Console.Error.WriteLine("Run Libation once to create it, then pass its folder, e.g.:"); + Console.Error.WriteLine(@" dotnet run Scripts/seed-download-history.cs C:\Users\me\AppData\Local\Libation"); + return 1; +} + +var dbPath = Path.Combine(libationFiles, "LibationContext.db"); +Console.WriteLine($"Database: {dbPath}"); + +using var connection = new SqliteConnection(new SqliteConnectionStringBuilder { DataSource = dbPath }.ToString()); +connection.Open(); + +if (!TableExists()) +{ + Console.Error.WriteLine("This database has no DownloadHistory table yet. Start Libation once so it applies migrations, then retry."); + return 1; +} + +if (clean) +{ + Console.WriteLine($"Removed {Execute("delete from DownloadHistory")} download history row(s)."); + return 0; +} + +// Stored as UTC ticks so the rolling window is exact on every provider and across DST changes. +var completedAt = DateTimeOffset.Now.AddSeconds(-ageSeconds); +var bytes = megabytes * 1024L * 1024; + +using var transaction = connection.BeginTransaction(); +for (var i = 0; i < count; i++) +{ + Execute( + """ + insert into DownloadHistory (CompletedAtUtcTicks, AudibleProductId, IsAudiblePlus, Bytes) + values ($ticks, $asin, $isPlus, $bytes) + """, + ("$ticks", completedAt.AddSeconds(i).UtcTicks), + ("$asin", $"{FakeAsinPrefix}{i:0000}"), + ("$isPlus", owned ? 0 : 1), + ("$bytes", bytes)); +} +transaction.Commit(); + +Console.WriteLine( + $"Inserted {count} {(owned ? "purchased" : "Audible Plus")} download(s) of {megabytes} MB each, " + + $"completed {TimeSpan.FromSeconds(ageSeconds):g} ago."); +Console.WriteLine($"Total rows: {Scalar("select count(*) from DownloadHistory")}"); +Console.WriteLine($"The oldest leaves the 24 hour window at {completedAt.AddHours(24):yyyy-MM-dd HH:mm:ss}"); +return 0; + +int? IntArg(string name) +{ + var index = Array.FindIndex(args, a => a.Equals(name, StringComparison.OrdinalIgnoreCase)); + return index >= 0 && index + 1 < args.Length && int.TryParse(args[index + 1], out var value) ? value : null; +} + +bool TableExists() + => Scalar("select count(*) from sqlite_master where type = 'table' and name = 'DownloadHistory'") > 0; + +int Execute(string sql, params (string Name, object Value)[] parameters) +{ + using var command = connection.CreateCommand(); + command.CommandText = sql; + foreach (var (name, value) in parameters) + command.Parameters.AddWithValue(name, value); + return command.ExecuteNonQuery(); +} + +long Scalar(string sql) +{ + using var command = connection.CreateCommand(); + command.CommandText = sql; + return Convert.ToInt64(command.ExecuteScalar()); +} + +static string? FindLibationFiles(string? explicitPath) +{ + if (explicitPath is not null) + return File.Exists(Path.Combine(explicitPath, "LibationContext.db")) ? explicitPath : null; + + string[] candidates = + [ + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.LocalApplicationData), "Libation"), + Path.Combine(Environment.GetFolderPath(Environment.SpecialFolder.UserProfile), "Libation"), + ]; + + return candidates.FirstOrDefault(c => File.Exists(Path.Combine(c, "LibationContext.db"))); +} diff --git a/Source/AaxDecrypter/AaxDecrypter.csproj b/Source/AaxDecrypter/AaxDecrypter.csproj index bd043b8a2..3ad40a9e9 100644 --- a/Source/AaxDecrypter/AaxDecrypter.csproj +++ b/Source/AaxDecrypter/AaxDecrypter.csproj @@ -14,11 +14,15 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="AAXClean.Codecs" Version="3.0.2" /> + <PackageReference Include="AAXClean.Codecs" Version="3.1.0" /> </ItemGroup> <ItemGroup> <ProjectReference Include="..\FileManager\FileManager.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + </Project> diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 810bfe60b..9bb4b23b3 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -179,13 +179,17 @@ public async Task BeginDownloadingAsync() DownloadTask = Task.Run(() => DownloadLoopInternal(client, response), _cancellationSource.Token); } - private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse) + // avoid infinite retry loops if the server consistently fails for a particular title + private const int MAX_TLS_RETRIES = 5; + + private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockResponse) { try { - long startPosition = WritePosition; + var startPosition = WritePosition; - while (WritePosition < ContentLength && !IsCancelled) + var tlsRetryCount = 0; + while (WritePosition < ContentLength && !IsCancelled) { try { @@ -206,8 +210,22 @@ private async Task DownloadLoopInternal(HttpClient client, BlockResponse blockRe blockResponse = await RequestNextByteRangeAsync(client); Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}."); - } - } + } + catch (IOException e) + when (e.InnerException is System.ComponentModel.Win32Exception { NativeErrorCode: -2146893008 } + && WritePosition != startPosition + && WritePosition < ContentLength && !IsCancelled + && ++tlsRetryCount <= MAX_TLS_RETRIES) + { + Serilog.Log.Logger.Warning($"TLS decryption failure at position 0x{WritePosition:X10}. Reconnecting and resuming download."); + + _writeFile.Position = startPosition = WritePosition; + blockResponse.Dispose(); + blockResponse = await RequestNextByteRangeAsync(client); + + Serilog.Log.Logger.Debug($"Resuming the file download starting at position 0x{WritePosition:X10}."); + } + } } catch (Exception ex) { diff --git a/Source/AppScaffolding/AppScaffolding.csproj b/Source/AppScaffolding/AppScaffolding.csproj index 8db3bd3f9..14abd104e 100644 --- a/Source/AppScaffolding/AppScaffolding.csproj +++ b/Source/AppScaffolding/AppScaffolding.csproj @@ -2,7 +2,7 @@ <Project Sdk="Microsoft.NET.Sdk"> <PropertyGroup> <TargetFramework>net10.0</TargetFramework> - <Version>13.7.0</Version> + <Version>14.0.2</Version> <Nullable>enable</Nullable> </PropertyGroup> <ItemGroup> @@ -16,6 +16,9 @@ <ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" /> <ProjectReference Include="..\AudibleUtilities\AudibleUtilities.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <DebugType>embedded</DebugType> diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index c6b3a38fb..d08a7935c 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -94,6 +94,7 @@ public static void RunPostConfigMigrations(Configuration config, bool ephemeralS { config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath); } + config.ValidateEnumSettings(); DeleteOpenSqliteFiles(config); AudibleApiStorage.EnsureAccountsSettingsFileExists(); IdentityTokenStorageWiring.Apply(config); @@ -102,9 +103,6 @@ public static void RunPostConfigMigrations(Configuration config, bool ephemeralS // migrations go below here // - Migrations.migrate_to_v6_6_9(config); - Migrations.migrate_to_v11_5_0(config); - Migrations.migrate_to_v11_6_5(config); Migrations.migrate_to_v12_0_1(config); } @@ -115,29 +113,74 @@ public static void RunPostConfigMigrations(Configuration config, bool ephemeralS /// </summary> private static void DeleteOpenSqliteFiles(Configuration config) { - var walFile = SqliteStorage.DatabasePath + "-wal"; - var shmFile = SqliteStorage.DatabasePath + "-shm"; + var dbFile = SqliteStorage.DatabasePath; + var walFile = dbFile + "-wal"; + var shmFile = dbFile + "-shm"; + + // If another Libation process currently has the database open, its WAL/SHM are live state. + // Deleting them would corrupt that process's database (and ours), so leave everything alone. + // See issue #1931. + if (IsFileLockedByAnotherProcess(dbFile)) + { + Log.Logger.Information("Skipping SQLite WAL/SHM cleanup: the database appears to be open in another process."); + return; + } + + // A non-empty WAL from a previous run that ended abruptly can hold committed-but-uncheckpointed + // transactions. Deleting it would silently discard that data; instead, leave it in place and let + // SQLite recover it when the database is next opened. Only remove an already-checkpointed WAL. if (File.Exists(walFile)) { - try - { - FileManager.FileUtility.SaferDelete(walFile); - } - catch (Exception ex) - { - Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile); - } + if (WalHoldsUnrecoveredTransactions(walFile)) + Log.Logger.Information("Leaving SQLite WAL in place so SQLite can recover it on open: {WalFile}", walFile); + else + FileManager.FileUtility.TrySaferDelete(walFile); } + + // The SHM (shared-memory index) is rebuilt from the WAL/database, so it is safe to remove once + // no other process holds the database. if (File.Exists(shmFile)) + FileManager.FileUtility.TrySaferDelete(shmFile); + } + + /// <summary>True if <paramref name="path"/> exists and cannot be opened exclusively, i.e. another process holds it.</summary> + private static bool IsFileLockedByAnotherProcess(string path) + { + if (!File.Exists(path)) + return false; + + try { - try - { - FileManager.FileUtility.SaferDelete(shmFile); - } - catch (Exception ex) - { - Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {ShmFile}", shmFile); - } + using var _ = new FileStream(path, FileMode.Open, FileAccess.ReadWrite, FileShare.None); + return false; + } + catch (IOException) + { + return true; + } + catch (UnauthorizedAccessException) + { + // Can't prove another process holds it, but we also can't safely touch it. Be conservative. + return true; + } + } + + /// <summary> + /// True if the WAL may contain committed transactions SQLite still needs to recover. A WAL longer + /// than its 32-byte header may hold frames; treat any such file as unrecovered so it is preserved. + /// </summary> + private static bool WalHoldsUnrecoveredTransactions(string walFile) + { + const long walHeaderSize = 32; + try + { + return new FileInfo(walFile).Length > walHeaderSize; + } + catch (Exception ex) + { + // If we can't measure it, assume it matters and keep it rather than risk data loss. + Log.Logger.Warning(ex, "Could not inspect SQLite WAL file; leaving it in place: {WalFile}", walFile); + return true; } } static bool migrationsRun = false; @@ -163,65 +206,28 @@ public static void RunPostMigrationScaffolding(Variety variety, Configuration co } private static void ensureSerilogConfig(Configuration config) - { - if (config.GetObject("Serilog") is JObject serilog) - { - bool fileChanged = false; - if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink) - { - zipFileSink["Name"] = "File"; - fileChanged = true; - } - var hooks = typeof(FileSinkHook).AssemblyQualifiedName; - if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs - && fileSinkArgs["hooks"]?.Value<string>() != hooks) - { - fileSinkArgs["hooks"] = hooks; - fileChanged = true; - } - - if (fileChanged) - config.SetNonString(serilog.DeepClone(), "Serilog"); - return; - } + => config.EnsureSerilogConfig(); - var serilogObj = new JObject + /// <summary> + /// Hands everything <see cref="StartupLog"/> collected before this point to Serilog, and points it at + /// Serilog from here on. Startup runs long before logging exists, so without this those diagnostics are + /// simply dropped. This is also the only place in Libation that maps a startup entry onto a Serilog level. + /// </summary> + private static void replayStartupLog() + => StartupLog.ReplayTo(entry => { - { "MinimumLevel", "Information" }, - { "WriteTo", new JArray - { - // ABOUT SINKS - // Only File sink is currently used. By user request (June 2024) others packages are included for experimental use. - - // new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved - new JObject - { - { "Name", "File" }, - { "Args", - new JObject - { - // for this sink to work, a path must be provided. we override this below - { "path", Path.Combine(config.LibationFiles.Location, "Log.log") }, - { "rollingInterval", "Month" }, - // Serilog template formatting examples - // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" - // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation - // - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}"; - // output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation - // {Properties:j} needed for expanded exception logging - { "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }, - { "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook - } - } - } - } - }, - // better exception logging with: Serilog.Exceptions library -- WithExceptionDetails - { "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace - { "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } }, - }; - config.SetNonString(serilogObj, "Serilog"); - } + var level = entry.Level switch + { + StartupLogLevel.Debug => Serilog.Events.LogEventLevel.Debug, + StartupLogLevel.Warning => Serilog.Events.LogEventLevel.Warning, + StartupLogLevel.Error => Serilog.Events.LogEventLevel.Error, + _ => Serilog.Events.LogEventLevel.Information, + }; + + // The message is already rendered: startup cannot build a Serilog template without + // referencing Serilog, which is the whole point of StartupLog. See issue #2001. + Log.Logger.Write(level, entry.Exception, "[startup {StartupTimestamp:HH:mm:ss.fff}] {StartupMessage}", entry.Timestamp, entry.Message); + }); // to restore original: Console.SetOut(origOut); private static TextWriter origOut { get; } = Console.Out; @@ -229,6 +235,7 @@ private static void ensureSerilogConfig(Configuration config) private static void configureLogging(Configuration config) { config.ConfigureLogging(); + replayStartupLog(); Log.Information( "Paths: LibationFiles={LibationFiles} AppsettingsJson={AppsettingsJson} SQLiteDb={SqliteDb}", config.LibationFiles.Location, @@ -304,11 +311,20 @@ private static void logStartupState(Configuration config) // begin logging session with a form feed Log.Logger.Information("\r\n\f"); + // -1 means the count could not be taken. Listing a directory no longer throws when it stops being + // readable partway through, so an incomplete walk has to be asked about rather than caught: a count + // that silently means "as many as I managed to read" is worse than no count in a bug report. static int fileCount(FileManager.LongPath? longPath) { if (longPath is null) return -1; - try { return FileManager.FileUtility.SaferEnumerateFiles(longPath).Count(); } + + var complete = true; + try + { + var count = FileManager.FileUtility.SaferEnumerateFiles(longPath, onIncomplete: _ => complete = false).Count(); + return complete ? count : -1; + } catch { return -1; } } @@ -320,6 +336,11 @@ static int fileCount(FileManager.LongPath? longPath) Configuration.OS, Environment.OSVersion, InteropFactory.InteropFunctionsType, + // A file Windows refused to load, or a half-applied upgrade, is usually explained by one + // of these three, and none of them is visible anywhere else in a bug report. + InstallFolder = Configuration.ProcessDirectory, + ApplicationControl = ApplicationControlPolicy.GetState(), + InstallFolderCloudSync = CloudSyncedFolders.GetSyncStatus(Configuration.ProcessDirectory), Mode = mode, LogLevel_Verbose_Enabled = Log.Logger.IsVerboseEnabled(), LogLevel_Debug_Enabled = Log.Logger.IsDebugEnabled(), @@ -329,7 +350,12 @@ static int fileCount(FileManager.LongPath? longPath) LogLevel_Fatal_Enabled = Log.Logger.IsFatalEnabled(), config.AutoScan, - config.BetaOptIn, + // These silently exclude titles from every scan, so a log without them can't explain a missing book + config.ImportEpisodes, + config.ImportPlusTitles, + config.DownloadEpisodes, + // Off means no startup upgrade prompt, which is otherwise indistinguishable from a broken check + config.CheckForUpgradesAtStartup, config.UseCoverAsFolderIcon, config.LibationFiles, AudibleFileStorage.BooksDirectory, @@ -352,10 +378,10 @@ static int fileCount(FileManager.LongPath? longPath) private static void wireUpSystemEvents(Configuration configuration) { LibraryCommands.LibrarySizeChanged += (_, libraryBooks) - => SearchEngineCommands.FullReIndex(libraryBooks); + => SearchEngineCommands.OnLibrarySizeChanged(libraryBooks); LibraryCommands.BookUserDefinedItemCommitted += (_, books) - => SearchEngineCommands.UpdateBooks(books); + => SearchEngineCommands.OnBookUserDefinedItemCommitted(books); } public static VersionCheckResult GetLatestRelease() @@ -463,59 +489,6 @@ private static (Version? releaseVersion, Octokit.Release?, Octokit.ReleaseAsset? internal static class Migrations { - public static void migrate_to_v6_6_9(Configuration config) - { - var writeToPath = $"Serilog.WriteTo"; - - // remove WriteTo[].Name == Console - { - if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1)) - { - for (var i = length1 - 1; i >= 0; i--) - { - var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value); - - if (exists && value == "Console") - UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i); - } - } - } - - // add Serilog.Exceptions -- WithExceptionDetails - { - // outputTemplate should contain "{Properties:j}" - { - // re-calculate. previous loop may have changed the length - if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2)) - { - var propertyName = "outputTemplate"; - for (var i = 0; i < length2; i++) - { - var jsonPath = $"{writeToPath}[{i}].Args"; - var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value); - - var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}"; - - if (exists && value != newValue) - UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue); - } - } - } - - // Serilog.Using must include "Serilog.Exceptions" - UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions"); - - // Serilog.Enrich must include "WithExceptionDetails" - UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails"); - } - } - - class FilterState_6_6_9 - { - public bool UseDefault { get; set; } - public List<string> Filters { get; set; } = []; - } - public static void migrate_to_v12_0_1(Configuration config) { //Migrate from version 1 file cache to the dictionary-based version 2 cache @@ -588,59 +561,4 @@ void migrate() catch { /* eat */ } } } - - public static void migrate_to_v11_6_5(Configuration config) - { - //Settings migration for unsupported sample rates (#1116) - if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000) - config.MaxSampleRate = AAXClean.SampleRate.Hz_8000; - else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000) - config.MaxSampleRate = AAXClean.SampleRate.Hz_48000; - } - - public static void migrate_to_v11_5_0(Configuration config) - { - // Read file, but convert old format to new (with Name field) as necessary. - if (!File.Exists(QuickFilters.JsonFile)) - { - QuickFilters.InMemoryState = new(); - return; - } - - try - { - if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile)) - is QuickFilters.FilterState inMemState) - { - QuickFilters.InMemoryState = inMemState; - return; - } - } - catch - { - // Eat - } - - try - { - if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile)) - is FilterState_6_6_9 inMemState) - { - // Copy old structure to new. - QuickFilters.InMemoryState = new() - { - UseDefault = inMemState.UseDefault - }; - foreach (var oldFilter in inMemState.Filters) - QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null)); - - return; - } - Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue"); - } - catch - { - // Eat - } - } } diff --git a/Source/ApplicationServices/AbsentFromLastScanUserMessage.cs b/Source/ApplicationServices/AbsentFromLastScanUserMessage.cs new file mode 100644 index 000000000..d0c2bf233 --- /dev/null +++ b/Source/ApplicationServices/AbsentFromLastScanUserMessage.cs @@ -0,0 +1,25 @@ +using Dinah.Core; + +namespace ApplicationServices; + +/// <summary> +/// What to tell the user about titles a run left alone because the last library scan did not find them. +/// <para> +/// Audible will not license a title it no longer lists for the account - a returned title, or one that has +/// left the Plus catalog - so attempting one collects a refusal every run and downloads nothing. Both hosts +/// say it in these words so that the same skip does not read as two different things. +/// </para> +/// </summary> +public static class AbsentFromLastScanUserMessage +{ + /// <summary>The reason, short enough to head a list of skip reasons.</summary> + public const string Label = "Absent from your last library scan"; + + /// <summary>What to do about it.</summary> + public const string Advice = "run Scan, or `libationcli scan`, then try again"; + + /// <summary>The line a CLI run prints in place of attempting them.</summary> + public static string BuildCliSkippedSummary(int count) + => $"Skipped {"title".PluralizeWithCount(count)} absent from your last library scan. " + + $"Audible will not license a title it no longer lists, so {Advice}. To attempt them anyway: libationcli liberate --force."; +} diff --git a/Source/ApplicationServices/ApplicationServices.csproj b/Source/ApplicationServices/ApplicationServices.csproj index 7c5dcd6e9..099ccca8f 100644 --- a/Source/ApplicationServices/ApplicationServices.csproj +++ b/Source/ApplicationServices/ApplicationServices.csproj @@ -6,10 +6,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="CsvHelper" Version="33.1.0"> - <PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets> - </PackageReference> - <PackageReference Include="ClosedXML" Version="0.105.0"> + <PackageReference Include="ClosedXML" Version="0.105.1"> <PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets> </PackageReference> </ItemGroup> @@ -21,6 +18,10 @@ <ProjectReference Include="..\DataLayer.Sqlite\DataLayer.Sqlite.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <DebugType>embedded</DebugType> </PropertyGroup> diff --git a/Source/ApplicationServices/BulkSetDownloadStatus.cs b/Source/ApplicationServices/BulkSetDownloadStatus.cs index 58fac5b0b..677047750 100644 --- a/Source/ApplicationServices/BulkSetDownloadStatus.cs +++ b/Source/ApplicationServices/BulkSetDownloadStatus.cs @@ -60,7 +60,7 @@ public int Discover() if (books2change.Any()) actionSets.Add(( - $"{"book".PluralizeWithCount(books2change.Count)} to 'Not Downloaded'", + $"{"book".PluralizeWithCount(books2change.Count)} to 'Download Pending'", LiberatedStatus.NotLiberated, books2change)); } diff --git a/Source/ApplicationServices/CsvWriter.cs b/Source/ApplicationServices/CsvWriter.cs new file mode 100644 index 000000000..e45de9ef0 --- /dev/null +++ b/Source/ApplicationServices/CsvWriter.cs @@ -0,0 +1,104 @@ +using System; +using System.Collections.Generic; +using System.Globalization; +using System.IO; +using System.Linq; +using System.Reflection; + +namespace ApplicationServices; + +/// <summary>Column header for csv and xlsx export. Mirrors the CsvHelper attribute it replaced.</summary> +[AttributeUsage(AttributeTargets.Property)] +internal sealed class NameAttribute(params string[] names) : Attribute +{ + public string[] Names { get; } = names; +} + +/// <summary>Excludes a property from csv export. Mirrors the CsvHelper attribute it replaced.</summary> +[AttributeUsage(AttributeTargets.Property)] +internal sealed class IgnoreAttribute : Attribute { } + +/// <summary> +/// Minimal write-only CSV serializer, output-compatible with the way this project used +/// CsvHelper: the delimiter is the culture's list separator, records end with CRLF, +/// fields containing the delimiter, quotes, newlines, or leading/trailing spaces are +/// quoted per RFC 4180, and values are formatted with the culture. Columns are the +/// public instance properties of each record's runtime type, in declaration order, +/// honoring <see cref="NameAttribute"/> and <see cref="IgnoreAttribute"/>. +/// </summary> +internal sealed class CsvWriter(TextWriter writer, CultureInfo culture) : IDisposable +{ + private readonly string delimiter = culture.TextInfo.ListSeparator; + private readonly Dictionary<Type, List<(string Header, PropertyInfo Property)>> columnCache = new(); + private bool rowHasFields; + + public void WriteHeader(Type type) + { + foreach (var (header, _) in getColumns(type)) + writeField(header); + } + + public void NextRecord() + { + writer.Write("\r\n"); + rowHasFields = false; + } + + public void WriteRecords<T>(IEnumerable<T> records) where T : notnull + { + foreach (var record in records) + { + foreach (var (_, property) in getColumns(record.GetType())) + writeField(toString(property.GetValue(record))); + NextRecord(); + } + } + + public void Dispose() => writer.Dispose(); + + private void writeField(string field) + { + if (rowHasFields) + writer.Write(delimiter); + writer.Write(escape(field)); + rowHasFields = true; + } + + private string escape(string field) + { + if (field.Length == 0) + return field; + + var needsQuoting + = field.Contains('"') + || field[0] == ' ' + || field[^1] == ' ' + || field.Contains(delimiter) + || field.Contains('\r') + || field.Contains('\n'); + + return needsQuoting ? $"\"{field.Replace("\"", "\"\"")}\"" : field; + } + + private string toString(object? value) + => value switch + { + null => "", + string s => s, + IFormattable formattable => formattable.ToString(null, culture), + _ => value.ToString() ?? "" + }; + + private List<(string Header, PropertyInfo Property)> getColumns(Type type) + { + if (!columnCache.TryGetValue(type, out var columns)) + columnCache[type] = columns = type + .GetProperties(BindingFlags.Public | BindingFlags.Instance) + .Where(p => p.GetMethod is not null + && p.GetIndexParameters().Length == 0 + && p.GetCustomAttribute<IgnoreAttribute>() is null) + .Select(p => (p.GetCustomAttribute<NameAttribute>()?.Names.FirstOrDefault() ?? p.Name, p)) + .ToList(); + return columns; + } +} diff --git a/Source/ApplicationServices/DbContexts.cs b/Source/ApplicationServices/DbContexts.cs index cbd403242..1b21b9605 100644 --- a/Source/ApplicationServices/DbContexts.cs +++ b/Source/ApplicationServices/DbContexts.cs @@ -132,6 +132,23 @@ public static List<LibraryBook> GetDeletedLibraryBooks() return context.GetDeletedLibraryBooks(); } + /// <summary>How many books belong in the search index. A row count only; no entities are loaded.</summary> + public static int GetIndexableBookCount() + { + using var context = GetContext(); + return context.GetIndexableBookCount(); + } + + /// <summary> + /// How many <see cref="LibraryBook"/> rows are in the trash. A row count only; no entities are loaded, + /// so this is cheap enough to refresh whenever the library changes. + /// </summary> + public static int GetTrashedBookCount() + { + using var context = GetContext(); + return context.GetLibraryBookCountsByTrashFlag().InTrash; + } + public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null) { using var context = GetContext(); diff --git a/Source/ApplicationServices/DeferredDownload.cs b/Source/ApplicationServices/DeferredDownload.cs new file mode 100644 index 000000000..8c6adb2e0 --- /dev/null +++ b/Source/ApplicationServices/DeferredDownload.cs @@ -0,0 +1,62 @@ +using DataLayer; +using Dinah.Core; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ApplicationServices; + +/// <summary>One title Libation is waiting on before attempting it again, and why.</summary> +public sealed record DeferredDownload( + string Account, + string AudibleProductId, + DownloadFailureKind Kind, + int ConsecutiveFailures, + DateTimeOffset LastFailedAt, + DateTimeOffset RetryAfter, + string? Reason) +{ + /// <summary>The label used in the log, the CLI summary and the GUI's skipped-titles breakdown.</summary> + public string KindLabel => Kind switch + { + DownloadFailureKind.LicenseDenied => "Audible denied a download license", + DownloadFailureKind.AssetUnavailable => "Audible has no downloadable audio yet", + DownloadFailureKind.ServiceInterruption => "A possible Audible service interruption", + _ => "A previous failure" + }; +} + +/// <summary> +/// The titles a bulk or automatic download run should leave alone for now, looked up by library book. +/// <para> +/// Read once per run: a run that takes hours must not have its own failures start suppressing the titles +/// still ahead of it in the same pass. +/// </para> +/// </summary> +public sealed class DownloadDeferrals +{ + /// <summary>No title is deferred. Used by targeted and forced runs, which must attempt what was asked.</summary> + public static DownloadDeferrals None { get; } = new([]); + + private readonly Dictionary<(string Account, string AudibleProductId), DeferredDownload> byBook; + + private DownloadDeferrals(IEnumerable<DeferredDownload> deferred) + => byBook = deferred.ToDictionary(d => (d.Account, d.AudibleProductId)); + + public static DownloadDeferrals Create(IEnumerable<DeferredDownload> deferred) => new(deferred); + + /// <summary>Reads the store. Never throws: a failure here must not stop a download run.</summary> + public static DownloadDeferrals Load(DateTimeOffset now) + => Create(DownloadAttemptFailureStore.GetDeferred(now)); + + public int Count => byBook.Count; + public bool Any => byBook.Count > 0; + + public DeferredDownload? Find(LibraryBook libraryBook) + => libraryBook.Account is { } account + && byBook.TryGetValue((account, libraryBook.Book.AudibleProductId), out var deferred) + ? deferred + : null; + + public bool IsDeferred(LibraryBook libraryBook) => Find(libraryBook) is not null; +} diff --git a/Source/ApplicationServices/DeferredDownloadUserMessage.cs b/Source/ApplicationServices/DeferredDownloadUserMessage.cs new file mode 100644 index 000000000..9b88310ff --- /dev/null +++ b/Source/ApplicationServices/DeferredDownloadUserMessage.cs @@ -0,0 +1,59 @@ +using DataLayer; +using Dinah.Core; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ApplicationServices; + +/// <summary>What to tell the user about titles a run held back, instead of the full warning per title per run.</summary> +public static class DeferredDownloadUserMessage +{ + /// <summary> + /// A compact breakdown for the log, eg: + /// "Audible denied a download license: 3, Audible has no downloadable audio yet: 1". + /// </summary> + public static string BuildLogBreakdown(IEnumerable<DeferredDownload> deferred) + { + var breakdown = string.Join(", ", GroupByKind(deferred).Select(g => $"{g.First().KindLabel}: {g.Count()}")); + return breakdown is "" ? "none" : breakdown; + } + + /// <summary> + /// The lines a CLI run prints in place of a full warning per title. Says how many were held back, why, + /// when the soonest will be attempted again, and how to override. + /// </summary> + public static IEnumerable<string> BuildCliSkippedLines(IReadOnlyCollection<DeferredDownload> skipped, DateTimeOffset now) + { + if (skipped.Count == 0) + yield break; + + yield return $"Skipped {"title".PluralizeWithCount(skipped.Count)} that recently failed to download. Libation will try again by itself."; + + foreach (var group in GroupByKind(skipped)) + yield return $" {group.First().KindLabel}: {group.Count()} (next attempt {DescribeWhen(group.Min(d => d.RetryAfter), now)})"; + + yield return " To try one now: libationcli liberate <ASIN>. For all of them: libationcli liberate --force."; + } + + /// <summary>"in about 3 hours" / "in about 12 days (9/14/2026)" - a summary should not need a clock to read.</summary> + public static string DescribeWhen(DateTimeOffset when, DateTimeOffset now) + { + var wait = when - now; + + if (wait <= TimeSpan.Zero) + return "on the next run"; + if (wait < TimeSpan.FromHours(1)) + return $"in about {"minute".PluralizeWithCount(Math.Max(1, (int)wait.TotalMinutes))}"; + + // Rounded to hours first, so a wait of exactly one day does not read as 23 or 24 hours depending on + // how long the run took to reach this line. + var hours = (int)Math.Round(wait.TotalHours); + return hours < 24 + ? $"in about {"hour".PluralizeWithCount(hours)}" + : $"in about {"day".PluralizeWithCount((int)Math.Round(hours / 24d))} ({when.ToLocalTime():d})"; + } + + private static IEnumerable<IGrouping<DownloadFailureKind, DeferredDownload>> GroupByKind(IEnumerable<DeferredDownload> deferred) + => deferred.GroupBy(d => d.Kind).OrderBy(g => g.Key); +} diff --git a/Source/ApplicationServices/DownloadAttemptFailureStore.cs b/Source/ApplicationServices/DownloadAttemptFailureStore.cs new file mode 100644 index 000000000..1679c7cdb --- /dev/null +++ b/Source/ApplicationServices/DownloadAttemptFailureStore.cs @@ -0,0 +1,176 @@ +using DataLayer; +using Dinah.Core; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ApplicationServices; + +/// <summary> +/// Reads and writes the record of refused download attempts that keeps a scheduled run from asking Audible +/// for the same license every time. +/// <para> +/// Every method swallows its own errors. This is bookkeeping that makes downloading quieter; it must never +/// be the reason a download fails, and a broken query must leave downloading exactly as it was before this +/// existed. +/// </para> +/// </summary> +public static class DownloadAttemptFailureStore +{ + /// <summary> + /// Records a failed attempt, extending the wait before the title is attempted again. A failure of a + /// different kind than last time restarts the count: Audible changed its mind about why, so the previous + /// wait no longer describes the situation. + /// </summary> + public static void Record(LibraryBook libraryBook, DownloadFailureKind kind, string? reason, DateTimeOffset? failedAt = null) + { + ArgumentNullException.ThrowIfNull(libraryBook); + + if (string.IsNullOrWhiteSpace(libraryBook.Account) || string.IsNullOrWhiteSpace(libraryBook.Book.AudibleProductId)) + return; + + try + { + var when = failedAt ?? DateTimeOffset.Now; + var account = libraryBook.Account; + var productId = libraryBook.Book.AudibleProductId; + + using var context = DbContexts.GetContext(); + var existing = context.DownloadAttemptFailures + .SingleOrDefault(f => f.Account == account && f.AudibleProductId == productId); + + var consecutiveFailures = existing is null || existing.Kind != kind ? 1 : existing.ConsecutiveFailures + 1; + var retryAfter = DownloadRetryBackoff.GetRetryAfter(kind, consecutiveFailures, when); + + if (existing is null) + context.DownloadAttemptFailures.Add(new DownloadAttemptFailure(productId, account, kind, consecutiveFailures, when, retryAfter, Truncate(reason))); + else + existing.Record(kind, consecutiveFailures, when, retryAfter, Truncate(reason)); + + context.SaveChanges(); + + Serilog.Log.Logger.Information( + "Not attempting {audibleProductId} again until {retryAfter}. {@DebugInfo}", + productId, + retryAfter.ToLocalTime(), + new { Title = libraryBook.Book.TitleWithSubtitle, Account = account.ToMask(), kind, consecutiveFailures, reason }); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error( + ex, + "Failed to record a refused download attempt. The title will be attempted again on the next run. {@DebugInfo}", + new { libraryBook.Book.AudibleProductId, Title = libraryBook.Book.TitleWithSubtitle, kind }); + } + } + + /// <summary> + /// Forgets any record for this title, so it is attempted again at the next opportunity. Called when a + /// download succeeds and when the user asks for the title explicitly. + /// </summary> + public static void Clear(LibraryBook libraryBook) + { + ArgumentNullException.ThrowIfNull(libraryBook); + Clear(libraryBook.Account, libraryBook.Book.AudibleProductId); + } + + public static void Clear(string? account, string? audibleProductId) + { + if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(audibleProductId)) + return; + + try + { + using var context = DbContexts.GetContext(); + + // ExecuteDelete rather than a load-then-remove so the common case (nothing recorded) is one + // statement. Called after every successful download. + var deleted = context.DownloadAttemptFailures + .Where(f => f.Account == account && f.AudibleProductId == audibleProductId) + .ExecuteDelete(); + + if (deleted > 0) + Serilog.Log.Logger.Debug("Cleared the recorded download failure for {audibleProductId}", audibleProductId); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to clear the recorded download failure for {audibleProductId}", audibleProductId); + } + } + + /// <summary>Titles whose wait has not elapsed. Empty when the query fails, so downloading carries on.</summary> + public static IReadOnlyList<DeferredDownload> GetDeferred(DateTimeOffset now) + { + try + { + var ticks = now.UtcTicks; + + using var context = DbContexts.GetContext(); + return Project(context.DownloadAttemptFailures.AsNoTracking().Where(f => f.RetryAfterUtcTicks > ticks)); + } + catch (Exception ex) + { + // Failing open is the safer default: a broken query must not stop titles from being downloaded. + Serilog.Log.Logger.Error(ex, "Failed to read recorded download failures. Treating every title as ready to attempt."); + return []; + } + } + + /// <summary>The current wait for one title, or null when it is ready to be attempted. Null when the query fails.</summary> + public static DeferredDownload? Find(LibraryBook libraryBook, DateTimeOffset now) + { + ArgumentNullException.ThrowIfNull(libraryBook); + + var account = libraryBook.Account; + var productId = libraryBook.Book.AudibleProductId; + + if (string.IsNullOrWhiteSpace(account) || string.IsNullOrWhiteSpace(productId)) + return null; + + try + { + var ticks = now.UtcTicks; + + using var context = DbContexts.GetContext(); + return Project( + context.DownloadAttemptFailures + .AsNoTracking() + .Where(f => f.Account == account && f.AudibleProductId == productId && f.RetryAfterUtcTicks > ticks)) + .FirstOrDefault(); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to read the recorded download failure for {audibleProductId}", productId); + return null; + } + } + + private static List<DeferredDownload> Project(IQueryable<DownloadAttemptFailure> query) + => query + // Materialise the columns first: the record's constructor is not translatable to SQL. + .Select(f => new + { + f.Account, + f.AudibleProductId, + f.Kind, + f.ConsecutiveFailures, + f.LastFailedAtUtcTicks, + f.RetryAfterUtcTicks, + f.Reason + }) + .ToList() + .Select(f => new DeferredDownload( + f.Account, + f.AudibleProductId, + f.Kind, + f.ConsecutiveFailures, + new DateTimeOffset(f.LastFailedAtUtcTicks, TimeSpan.Zero), + new DateTimeOffset(f.RetryAfterUtcTicks, TimeSpan.Zero), + f.Reason)) + .ToList(); + + /// <summary>Audible's messages can run long; the full text is already in the log.</summary> + private static string? Truncate(string? reason) + => reason is null || reason.Length <= 400 ? reason : reason[..400]; +} diff --git a/Source/ApplicationServices/DownloadHistoryStore.cs b/Source/ApplicationServices/DownloadHistoryStore.cs new file mode 100644 index 000000000..f1d86b09d --- /dev/null +++ b/Source/ApplicationServices/DownloadHistoryStore.cs @@ -0,0 +1,85 @@ +using DataLayer; +using LibationFileManager; +using Microsoft.EntityFrameworkCore; +using System; +using System.Collections.Generic; +using System.Linq; + +namespace ApplicationServices; + +/// <summary> +/// Reads and writes the record of successful audiobook downloads backing the daily download limit. +/// <para> +/// Caches nothing: every read is a fresh query, so a queue paused for hours sees entries age out of the +/// rolling window, and downloads performed by a concurrently running CLI or second container are counted too. +/// </para> +/// </summary> +public static class DownloadHistoryStore +{ + /// <summary>Kept a little beyond the 24 hour window so the table stays small without losing anything in use.</summary> + private static readonly TimeSpan RetentionPeriod = TimeSpan.FromHours(48); + + /// <summary> + /// Records a finished download. Never throws: bookkeeping must not fail a download that already succeeded. + /// </summary> + public static void Record(string? audibleProductId, bool isAudiblePlus, long bytes, DateTimeOffset? completedAt = null) + { + try + { + var when = completedAt ?? DateTimeOffset.Now; + + using var context = DbContexts.GetContext(); + context.DownloadHistory.Add(new DownloadHistory(when, audibleProductId, isAudiblePlus, bytes)); + + var cutoff = (when - RetentionPeriod).UtcTicks; + var expired = context.DownloadHistory.Where(dh => dh.CompletedAtUtcTicks < cutoff); + context.DownloadHistory.RemoveRange(expired); + + context.SaveChanges(); + + Serilog.Log.Logger.Debug( + "Recorded download for the daily download limit. {@DebugInfo}", + new { audibleProductId, isAudiblePlus, bytes, completedAt = when }); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error( + ex, + "Failed to record a completed download for the daily download limit. The download itself succeeded. {@DebugInfo}", + new { audibleProductId, isAudiblePlus, bytes }); + } + } + + /// <summary>Downloads completed at or after <paramref name="since"/>. Empty when the query fails.</summary> + public static IReadOnlyList<DownloadHistoryEntry> GetSince(DateTimeOffset since) + { + try + { + var ticks = since.UtcTicks; + + using var context = DbContexts.GetContext(); + return context.DownloadHistory + .AsNoTracking() + .Where(dh => dh.CompletedAtUtcTicks >= ticks) + .OrderBy(dh => dh.CompletedAtUtcTicks) + .Select(dh => new { dh.CompletedAtUtcTicks, dh.AudibleProductId, dh.IsAudiblePlus, dh.Bytes }) + .ToList() + .Select(dh => new DownloadHistoryEntry( + new DateTimeOffset(dh.CompletedAtUtcTicks, TimeSpan.Zero), + dh.AudibleProductId, + dh.IsAudiblePlus, + dh.Bytes)) + .ToList(); + } + catch (Exception ex) + { + // Failing open is the safer default: a broken query must not block downloading. + Serilog.Log.Logger.Error(ex, "Failed to read download history. Treating the last 24 hours as empty."); + return []; + } + } + + /// <summary>The rolling window the daily download limit uses.</summary> + public static IReadOnlyList<DownloadHistoryEntry> GetCurrentWindow(DateTimeOffset now) + => GetSince(now - DailyDownloadLimit.Window); +} diff --git a/Source/ApplicationServices/DownloadRetryBackoff.cs b/Source/ApplicationServices/DownloadRetryBackoff.cs new file mode 100644 index 000000000..986c563e6 --- /dev/null +++ b/Source/ApplicationServices/DownloadRetryBackoff.cs @@ -0,0 +1,53 @@ +using DataLayer; +using System; +using System.Collections.Generic; + +namespace ApplicationServices; + +/// <summary> +/// How long to leave a title alone after a download attempt Audible refused, so a scheduled run stops asking +/// for the same license every time. +/// <para> +/// The wait doubles with each failure in a row, up to a cap, and every kind of failure has a finite cap: a +/// title held back is always attempted again eventually. Audible never distinguishes "you will never have +/// rights to this" from "not right now", so nothing here may be permanent. +/// </para> +/// </summary> +public static class DownloadRetryBackoff +{ + private static readonly Dictionary<DownloadFailureKind, (TimeSpan First, TimeSpan Max)> schedule = new() + { + // An eligibility refusal changes only when the account or the catalog changes. A day matches the + // advice Libation already gives for a Plus title ("try again in 1 to 2 days"), which is the most + // common refusal that clears by itself. + [DownloadFailureKind.LicenseDenied] = (TimeSpan.FromDays(1), TimeSpan.FromDays(30)), + + // A preorder becomes downloadable on its release date, which nobody can predict from the error, so + // keep checking within a week. + [DownloadFailureKind.AssetUnavailable] = (TimeSpan.FromHours(6), TimeSpan.FromDays(7)), + + // Short: an outage that has passed should not delay a title any longer than it has to. + [DownloadFailureKind.ServiceInterruption] = (TimeSpan.FromHours(1), TimeSpan.FromHours(12)), + }; + + /// <summary>How long to wait after the <paramref name="consecutiveFailures"/>th failure in a row.</summary> + public static TimeSpan GetWait(DownloadFailureKind kind, int consecutiveFailures) + { + var (first, max) = schedule.TryGetValue(kind, out var found) + ? found + : schedule[DownloadFailureKind.ServiceInterruption]; + + // Doubled one step at a time and stopped at the cap: first * 2^n overflows a TimeSpan long before a + // plausible failure count, let alone the absurd ones a corrupt row could hold. + var doublings = Math.Max(0, consecutiveFailures - 1); + var wait = first; + for (var i = 0; i < doublings && wait < max; i++) + wait += wait; + + return wait > max ? max : wait; + } + + /// <summary>When a title becomes eligible for another automatic attempt.</summary> + public static DateTimeOffset GetRetryAfter(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset failedAt) + => failedAt + GetWait(kind, consecutiveFailures); +} diff --git a/Source/ApplicationServices/ExportDto.cs b/Source/ApplicationServices/ExportDto.cs index f170f7cf2..e78acb574 100644 --- a/Source/ApplicationServices/ExportDto.cs +++ b/Source/ApplicationServices/ExportDto.cs @@ -1,5 +1,4 @@ -using CsvHelper.Configuration.Attributes; -using DataLayer; +using DataLayer; using Newtonsoft.Json; using System; using System.Linq; diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index bfa51a1ca..3a85d63a0 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -11,6 +11,7 @@ using System; using System.Collections.Generic; using System.Linq; +using System.Runtime.CompilerServices; using System.Text; using System.Threading; using System.Threading.Tasks; @@ -62,33 +63,31 @@ public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<Librar try { logTime($"pre {nameof(scanAccountsAsync)} all"); - var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true); + var scan = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true); logTime($"post {nameof(scanAccountsAsync)} all"); + var libraryItems = scan.Items; var totalCount = libraryItems.Count; Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}"); - return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList(); - } - catch (AudibleApi.Authentication.LoginFailedException lfEx) - { - lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location); + var existing = existingLibrary?.ToList() ?? []; - // nuget Serilog.Exceptions would automatically log custom properties - // However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement: - // https://github.com/RehanSaeed/Serilog.Exceptions - // work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc - Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new - { - lfEx.RequestUrl, - ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode, - ResponseStatusCodeDesc = lfEx.ResponseStatusCode, - lfEx.ResponseInputFields, - lfEx.ResponseBodyFilePaths - }); - throw; + EnsureScanCanIdentifyInactiveBooks(totalCount, existing.Count, scan.FailedAccounts); + + var inactive = existing.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList(); + + Log.Logger.Information( + "Books absent from the library scan. {@DebugInfo}", + new { InactiveCount = inactive.Count, CandidateCount = existing.Count, ScannedCount = totalCount }); + + return inactive; } - catch (Exception ex) + catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx)) + { + Log.Logger.Error(lfEx, "Error scanning library. Authentication failed."); + throw; + } + catch (Exception ex) { Log.Logger.Error(ex, "Error scanning library"); throw; @@ -107,6 +106,22 @@ public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<Librar } } + /// <summary> + /// Refuse to call anything "no longer in your library" from a scan that could not see the whole library. + /// Importing tolerates a partial scan because it only adds and updates; removal cannot, because a book + /// missing from one bad scan is indistinguishable from a book the user no longer owns. + /// </summary> + /// <exception cref="LibraryScanIncompleteException">The scan cannot support that decision.</exception> + public static void EnsureScanCanIdentifyInactiveBooks(int scannedItemCount, int existingBookCount, IReadOnlyCollection<string>? failedAccounts) + { + if (failedAccounts?.Count > 0) + throw LibraryScanIncompleteException.ForFailedAccounts(failedAccounts); + + // An account whose every book vanished at once is a broken scan, not an emptied library. + if (scannedItemCount == 0 && existingBookCount > 0) + throw LibraryScanIncompleteException.ForEmptyScan(existingBookCount); + } + #region FULL LIBRARY scan and import public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts) => ImportAccountAsync(accounts, allowInteractiveLogin: true); @@ -136,10 +151,14 @@ public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<Librar | LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series | LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs | LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin - | LibraryOptions.ResponseGroupOptions.IsFinished, + | LibraryOptions.ResponseGroupOptions.IsFinished + //product_details is the only group that returns the copyright line, which Libation + //needs for Widevine downloads: those files arrive without an embedded cprt tag. + | LibraryOptions.ResponseGroupOptions.ProductDetails, ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215 }; - var importItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin); + //Importing only adds and updates, so a partially scanned library is still worth importing. + var importItems = (await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin)).Items; logTime($"post {nameof(scanAccountsAsync)} all"); var totalCount = importItems.Count; @@ -156,25 +175,12 @@ public static async Task<List<LibraryBook>> FindInactiveBooks(IEnumerable<Librar return (totalCount, newCount); } - catch (AudibleApi.Authentication.LoginFailedException lfEx) - { - lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location); - - // nuget Serilog.Exceptions would automatically log custom properties - // However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement: - // https://github.com/RehanSaeed/Serilog.Exceptions - // work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc - Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new - { - lfEx.RequestUrl, - ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode, - ResponseStatusCodeDesc = lfEx.ResponseStatusCode, - lfEx.ResponseInputFields, - lfEx.ResponseBodyFilePaths - }); - throw; - } - catch (Exception ex) + catch (Exception lfEx) when (AuthenticationExceptionHelper.IsAuthenticationFailure(lfEx)) + { + Log.Logger.Error(lfEx, "Error scanning library. Authentication failed."); + throw; + } + catch (Exception ex) { Log.Logger.Error(ex, "Error importing library"); throw; @@ -275,9 +281,18 @@ private static int importSingleToDb(AudibleApi.Common.Item item, string accountI return null; } - private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin) + /// <summary>Outcome of scanning one or more accounts.</summary> + /// <param name="Items">Everything the accounts that did scan returned.</param> + /// <param name="FailedAccounts"> + /// Accounts that could not be scanned. Their books are absent from <paramref name="Items"/> for that reason + /// alone, so a caller deciding what to remove must not treat the result as the whole library. + /// </param> + private sealed record ScanResult(List<ImportItem> Items, IReadOnlyCollection<string> FailedAccounts); + + private static async Task<ScanResult> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin) { var tasks = new List<Task<List<ImportItem>>>(); + var failedAccounts = new List<string>(); await using LogArchiver? archiver = Log.Logger.IsDebugEnabled() @@ -290,11 +305,22 @@ private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts { try { - // get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll) - var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin); + // An account is scanned as a unit: all of its marketplaces or none of them. A partial scan would + // leave the marketplaces that did not run looking empty, and AbsentFromLastScan - which is keyed + // by account id, not by marketplace - would mark their titles absent. + var accountTasks = new List<Task<List<ImportItem>>>(); + + // ScanLocales leads with the account's own marketplace, so any login happens once, up front + foreach (var locale in account.ScanLocales) + { + // get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll) + var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin, storeLocale: locale); + + // add scanAccountAsync as a TASK: do not await + accountTasks.Add(scanAccountAsync(apiExtended, account, locale, libraryOptions, archiver)); + } - // add scanAccountAsync as a TASK: do not await - tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver)); + tasks.AddRange(accountTasks); } catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex)) { @@ -304,32 +330,39 @@ private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts { //Catch to allow other accounts to continue scanning. Log.Logger.Error(ex, "Failed to scan account"); + failedAccounts.Add(account.MaskedLogEntry ?? account.AccountId ?? "[unknown account]"); } } // import library in parallel var arrayOfLists = await Task.WhenAll(tasks); var importItems = arrayOfLists.SelectMany(a => a).ToList(); - return importItems; + return new ScanResult(importItems, failedAccounts); } - private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, LibraryOptions libraryOptions, LogArchiver? archiver) + /// <param name="locale"> + /// The marketplace being read. Usually the account's own; for an account holding titles under a second + /// storefront, each of those in turn. Books are tagged with it, which is how a download later finds its way + /// back to the right store. + /// </param> + private static async Task<List<ImportItem>> scanAccountAsync(ApiExtended apiExtended, Account account, Locale locale, LibraryOptions libraryOptions, LogArchiver? archiver) { ArgumentValidator.EnsureNotNull(account, nameof(account)); - var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); + ArgumentValidator.EnsureNotNull(locale, nameof(locale)); Log.Logger.Information("ImportLibraryAsync. {@DebugInfo}", new { - Account = account.MaskedLogEntry ?? "[null]" + Account = account.MaskedLogEntry ?? "[null]", + LocaleName = locale.Name }); - logTime($"pre scanAccountAsync {account.AccountName}"); + logTime($"pre scanAccountAsync {account.AccountName} {locale.Name}"); try { var dtoItems = await apiExtended.GetLibraryValidatedAsync(libraryOptions); - logTime($"post scanAccountAsync {account.AccountName} qty: {dtoItems.Count}"); + logTime($"post scanAccountAsync {account.AccountName} {locale.Name} qty: {dtoItems.Count}"); await logDtoItemsAsync(dtoItems); @@ -346,12 +379,13 @@ async Task logDtoItemsAsync(IEnumerable<AudibleApi.Common.Item> dtoItems, IEnume { if (archiver is not null) { - var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry}.json"; + var fileName = $"{DateTime.Now:u} {account.MaskedLogEntry} {locale.Name}.json"; var items = await Task.Run(() => JArray.FromObject(dtoItems.Select(i => i.SourceJson))); var scanFile = new JObject { { "Account", account.MaskedLogEntry }, + { "Locale", locale.Name }, { "ScannedDateTime", DateTime.Now.ToString("u") }, }; @@ -404,13 +438,39 @@ public static int SaveContext(LibationContext context) #endregion #region remove/restore books + + /// <summary> + /// Record every change to the trash. Removal is a soft delete, so a book can leave the library and sit + /// out of sight indefinitely; without this the log cannot say when that happened or how much is in there. + /// </summary> + private static void logTrashChange(string action, int qtyChanges) + { + if (qtyChanges < 1) + return; + + try + { + Log.Logger.Information("Trash bin changed. {@DebugInfo}", new + { + Action = action, + Books = qtyChanges, + BooksInTrash = DbContexts.GetTrashedBookCount() + }); + } + catch (Exception ex) + { + //The change itself already succeeded. Never fail it over a log line. + Log.Logger.Warning(ex, "Trash bin changed ({action}, {qtyChanges}) but the trash count could not be read", action, qtyChanges); + } + } + public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook?>? idsToRemove) => Task.Run(() => removeBooks(idsToRemove)); private static int removeBooks(IEnumerable<LibraryBook?>? removeLibraryBooks) { if (removeLibraryBooks is null || !removeLibraryBooks.Any()) return 0; - return DoDbSizeChangeOperation(ctx => + var qtyChanges = DoDbSizeChangeOperation(ctx => { // Entry() NoTracking entities before SaveChanges() foreach (var lb in removeLibraryBooks.OfType<LibraryBook>()) @@ -419,6 +479,9 @@ private static int removeBooks(IEnumerable<LibraryBook?>? removeLibraryBooks) ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; } }); + + logTrashChange("Moved to trash", qtyChanges); + return qtyChanges; } public static Task<int> RestoreBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => restoreBooks(idsToRemove)); @@ -428,7 +491,7 @@ private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks) return 0; try { - return DoDbSizeChangeOperation(ctx => + var qtyChanges = DoDbSizeChangeOperation(ctx => { // Entry() NoTracking entities before SaveChanges() foreach (var lb in libraryBooks) @@ -437,6 +500,9 @@ private static int restoreBooks(this IEnumerable<LibraryBook> libraryBooks) ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified; } }); + + logTrashChange("Restored from trash", qtyChanges); + return qtyChanges; } catch (Exception ex) { @@ -452,11 +518,14 @@ private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook?>? librar return 0; try { - return DoDbSizeChangeOperation(ctx => + var qtyChanges = DoDbSizeChangeOperation(ctx => { ctx.LibraryBooks.RemoveRange(libraryBooks.OfType<LibraryBook>()); ctx.Books.RemoveRange(libraryBooks.OfType<LibraryBook>().Select(lb => lb.Book)); }); + + logTrashChange("Permanently deleted", qtyChanges); + return qtyChanges; } catch (Exception ex) { @@ -579,13 +648,19 @@ private static int updateUserDefinedItem(this IEnumerable<LibraryBook?>? library return 0; int qtyChanges; + var statusChanged = new List<LibraryBook>(); using (var context = DbContexts.GetContext()) { // Entry() instead of Attach() due to possible stack overflow with large tables foreach (var book in nonNullBooks) { + var statusBefore = book.Book.UserDefinedItem.BookStatus; + action?.Invoke(book.Book.UserDefinedItem); + if (book.Book.UserDefinedItem.BookStatus != statusBefore) + statusChanged.Add(book); + var udiEntity = context.Entry(book.Book.UserDefinedItem); udiEntity.State = Microsoft.EntityFrameworkCore.EntityState.Modified; @@ -596,7 +671,16 @@ private static int updateUserDefinedItem(this IEnumerable<LibraryBook?>? library qtyChanges = context.SaveChanges(); } if (qtyChanges > 0) + { + // Changing a title's download status is the user saying they want a different outcome for it, + // so drop any wait Libation was observing before attempting it again. Compared against the + // previous value rather than acting on every call: editing tags or a rating must not quietly + // put a title Audible just refused back into the next scheduled run. + foreach (var book in statusChanged) + DownloadAttemptFailureStore.Clear(book); + BookUserDefinedItemCommitted?.Invoke(null, nonNullBooks); + } return qtyChanges; } @@ -658,7 +742,12 @@ private string toPdfStatusString() } } - public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null) + /// <param name="requestedBy"> + /// Names the caller in the log. Both UIs count the whole library and the visible subset as the + /// library loads, and the two produce identical lines whenever no filter is applied, so without + /// this there is no way to tell which set a given count describes. + /// </param> + public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = null, [CallerMemberName] string requestedBy = "") { libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking(); @@ -674,7 +763,7 @@ public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = nu var booksError = results.Count(r => r.status == LiberatedStatus.Error); var booksUnavailable = results.Count(r => r.absent && r.status is LiberatedStatus.NotLiberated or LiberatedStatus.PartialDownload); - Log.Logger.Information("Book counts. {@DebugInfo}", new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable }); + Log.Logger.Information("Book counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = results.Count, booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable }); var pdfResults = libraryBooks .AsParallel() @@ -684,9 +773,11 @@ public static LibraryStats GetCounts(IEnumerable<LibraryBook>? libraryBooks = nu var pdfsDownloaded = pdfResults.Count(r => r.status == LiberatedStatus.Liberated); var pdfsNotDownloaded = pdfResults.Count(r => !r.absent && r.status == LiberatedStatus.NotLiberated); - var pdfsUnavailable = pdfResults.Count(r => r.absent && r.status == LiberatedStatus.NotLiberated); + // Error means Audible has a supplement listed for the title but will not deliver one, so the count it + // belongs in is the one for PDFs Libation cannot get. Without this such a title is in no count at all. + var pdfsUnavailable = pdfResults.Count(r => r.status == LiberatedStatus.Error || (r.absent && r.status == LiberatedStatus.NotLiberated)); - Log.Logger.Information("PDF counts. {@DebugInfo}", new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable }); + Log.Logger.Information("PDF counts for {RequestedBy}. {@DebugInfo}", requestedBy, new { total = pdfResults.Count, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable }); return new(booksFullyBackedUp, booksDownloadedOnly, booksNoProgress, booksError, booksUnavailable, pdfsDownloaded, pdfsNotDownloaded, pdfsUnavailable, libraryBooks); } diff --git a/Source/ApplicationServices/LibraryExporter.cs b/Source/ApplicationServices/LibraryExporter.cs index f86c8cd7e..acebf5f88 100644 --- a/Source/ApplicationServices/LibraryExporter.cs +++ b/Source/ApplicationServices/LibraryExporter.cs @@ -1,6 +1,4 @@ using ClosedXML.Excel; -using CsvHelper; -using CsvHelper.Configuration.Attributes; using DataLayer; using Newtonsoft.Json; using System.Collections.Generic; diff --git a/Source/ApplicationServices/LibraryScanIncompleteException.cs b/Source/ApplicationServices/LibraryScanIncompleteException.cs new file mode 100644 index 000000000..8bc7ba933 --- /dev/null +++ b/Source/ApplicationServices/LibraryScanIncompleteException.cs @@ -0,0 +1,30 @@ +using Dinah.Core; +using System; +using System.Collections.Generic; + +namespace ApplicationServices; + +/// <summary> +/// A library scan finished without throwing but cannot be trusted to say what is no longer in the account. +/// Deciding "inactive" from such a scan would offer to remove books the user still owns. +/// </summary> +public class LibraryScanIncompleteException : Exception +{ + /// <summary>Accounts that could not be scanned, if that is why the scan is untrustworthy.</summary> + public IReadOnlyCollection<string> FailedAccounts { get; } + + public LibraryScanIncompleteException(string message, IReadOnlyCollection<string>? failedAccounts = null) + : base(message) + => FailedAccounts = failedAccounts ?? []; + + internal static LibraryScanIncompleteException ForFailedAccounts(IReadOnlyCollection<string> failedAccounts) + => new( + $"{"account".PluralizeWithCount(failedAccounts.Count)} could not be scanned, so Libation cannot tell which books are no longer in your library: " + + string.Join(", ", failedAccounts), + failedAccounts); + + internal static LibraryScanIncompleteException ForEmptyScan(int existingBookCount) + => new( + $"The library scan returned no books while Libation still holds {"book".PluralizeWithCount(existingBookCount)}. " + + "Treating that as an empty Audible library would offer to remove books you still own."); +} diff --git a/Source/ApplicationServices/RecordExporter.cs b/Source/ApplicationServices/RecordExporter.cs index cb8ade64b..80220808b 100644 --- a/Source/ApplicationServices/RecordExporter.cs +++ b/Source/ApplicationServices/RecordExporter.cs @@ -1,6 +1,5 @@ using AudibleApi.Common; using ClosedXML.Excel; -using CsvHelper; using DataLayer; using Newtonsoft.Json.Linq; using System; diff --git a/Source/ApplicationServices/SearchEngineCommands.cs b/Source/ApplicationServices/SearchEngineCommands.cs index ebf46368a..26a436daa 100644 --- a/Source/ApplicationServices/SearchEngineCommands.cs +++ b/Source/ApplicationServices/SearchEngineCommands.cs @@ -23,6 +23,7 @@ private static T performSafeQuery<T>(Func<SearchEngine, T> func) lock (IndexLock) { var engine = new SearchEngine(); + repairShortIndex(engine); try { return func(engine); @@ -40,13 +41,98 @@ private static T performSafeQuery<T>(Func<SearchEngine, T> func) } } } + + /// <summary>Set once the index has been measured against the library, so the check costs one query per run.</summary> + private static bool indexCounted; + + /// <summary> + /// Rebuilds an index that holds fewer books than the library does. + /// <para> + /// A book the index never received is not merely unsearchable. Filtering intersects the grid with the + /// query's hits, so a positive term such as <c>Absent</c> cannot return it, while the negated <c>-Absent</c> + /// resolves to "every document in the index" and therefore drops it from the grid - which looks exactly + /// like the negated filter working correctly. Reported as issue #1989. + /// </para> + /// <para> + /// Nothing else notices: the index is only ever written as a whole, and <see cref="tryUpdate"/> + /// deliberately swallows a rebuild that fails so a bad index cannot fail a good scan. A short index + /// therefore stays short until something happens to change the library again. + /// </para> + /// </summary> + private static void repairShortIndex(SearchEngine engine) + { + if (indexCounted) + return; + + // before the work, not after: a check that throws must not run on every query for the rest of the session + indexCounted = true; + + try + { + var indexed = engine.GetIndexedBookCount(); + + // no index yet, or one too damaged to read. Both already have their own recovery. + if (indexed < 0) + return; + + var expected = DbContexts.GetIndexableBookCount(); + + // more documents than books is stale rather than harmful: an extra document matches no grid row. + if (indexed >= expected) + return; + + Log.Warning("The search index holds {Indexed} of {Expected} books. Rebuilding it: search cannot find the rest, and a negated filter hides them.", indexed, expected); + + var failures = fullReIndex(engine); + + if (failures > 0) + Log.Error("{Failures} book(s) could not be added to the search index. Search cannot find them, and a negated filter hides them.", failures); + } + catch (Exception ex) + { + // Searching with a short index still beats not searching at all. + Log.Error(ex, "Could not check the search index against the library."); + } + } #endregion public static event EventHandler? SearchEngineUpdated; + /// <summary> + /// Occurs when the index could not be updated even after automatic repair, so it needs the user's help. + /// </summary> + public static event EventHandler<Exception>? UpdateFailed; + #region Update private static bool isUpdating; + /// <summary>Updates the index after books were added to or removed from the library.</summary> + public static void OnLibrarySizeChanged(List<LibraryBook> libraryBooks) + => tryUpdate(() => FullReIndex(libraryBooks)); + + /// <summary>Updates the index after book details, tags or statuses were committed.</summary> + public static void OnBookUserDefinedItemCommitted(IEnumerable<LibraryBook> books) + => tryUpdate(() => UpdateBooks(books)); + + /// <summary> + /// The database change that triggers an update is committed before the update runs, and this index is derived + /// from that database, so a failure here is reported instead of propagated. Letting it escape reported a + /// successful scan as "Error importing library", and, since this is the first subscriber to those events, + /// stopped the handlers that refresh the grid and the backup counts from running at all. + /// </summary> + private static void tryUpdate(Action update) + { + try + { + update(); + } + catch (Exception ex) + { + Log.Error(ex, "Failed to update the search index. Library changes are saved; search and filter results may be stale until the next update succeeds."); + UpdateFailed?.Invoke(null, ex); + } + } + public static void UpdateBooks(IEnumerable<LibraryBook> books) { // Semi-arbitrary. At some point it's more worth it to do a full re-index than to do one offs. @@ -60,7 +146,7 @@ public static void UpdateBooks(IEnumerable<LibraryBook> books) } } - public static void FullReIndex() => performSafeCommand(fullReIndex); + public static void FullReIndex() => performSafeCommand(e => fullReIndex(e)); public static void FullReIndex(List<LibraryBook> libraryBooks) => performSafeCommand(se => fullReIndex(se, libraryBooks.WithoutParents())); @@ -115,13 +201,15 @@ private static void update(Action<SearchEngine> action) } } - private static void fullReIndex(SearchEngine engine) + /// <returns>How many books could not be indexed.</returns> + private static int fullReIndex(SearchEngine engine) { var library = DbContexts.GetLibrary_Flat_NoTracking(); - fullReIndex(engine, library); + return fullReIndex(engine, library); } - private static void fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks) + /// <returns>How many books could not be indexed.</returns> + private static int fullReIndex(SearchEngine engine, IEnumerable<LibraryBook> libraryBooks) => engine.CreateNewIndex(libraryBooks); #endregion } diff --git a/Source/ApplicationServices/TrashBinSearch.cs b/Source/ApplicationServices/TrashBinSearch.cs new file mode 100644 index 000000000..114356107 --- /dev/null +++ b/Source/ApplicationServices/TrashBinSearch.cs @@ -0,0 +1,53 @@ +using DataLayer; +using Serilog; +using System.Collections.Generic; +using System.Linq; + +namespace ApplicationServices; + +/// <summary> +/// Answers "is the book you searched for in the trash?". +/// A trashed book is filtered out of the library and out of the search index, so searching for one returns +/// nothing at all and looks exactly like a book that was never imported (issue #1925). +/// </summary> +public static class TrashBinSearch +{ + /// <summary> + /// Books in the trash matching <paramref name="searchString"/>, using the same query syntax as the + /// library search so a fielded query means the same thing in both places. + /// </summary> + /// <remarks> + /// Indexes the trash on the spot rather than keeping an index in step with it. Call only once a library + /// search has already come back empty: that happens on Enter, at human speed, so the work is rare. + /// Returns nothing rather than throwing; this only ever powers a hint. + /// </remarks> + public static List<LibraryBook> Search(string? searchString) + { + if (string.IsNullOrWhiteSpace(searchString)) + return []; + + try + { + //GetDeletedLibraryBooks also returns parents so the trash grid can nest episodes; only the + //genuinely deleted are candidates here. + var trashed = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.IsDeleted).ToList(); + if (trashed.Count == 0) + return []; + + var searchEngine = new TempSearchEngine(); + if (!searchEngine.ReindexSearchEngine(trashed)) + return []; + + if (searchEngine.GetSearchResultSet(searchString) is not { } results) + return []; + + var matchedIds = results.Docs.Select(d => d.ProductId).ToHashSet(); + return trashed.Where(lb => matchedIds.Contains(lb.Book.AudibleProductId)).ToList(); + } + catch (System.Exception ex) + { + Log.Logger.Error(ex, "Failed to search the trash bin for {searchString}", searchString); + return []; + } + } +} diff --git a/Source/AudibleUtilities/Account.cs b/Source/AudibleUtilities/Account.cs index 2b9f18f10..3d814bde1 100644 --- a/Source/AudibleUtilities/Account.cs +++ b/Source/AudibleUtilities/Account.cs @@ -1,13 +1,18 @@ using AudibleApi; using AudibleApi.Authorization; using Dinah.Core; +using Dinah.Core.Security; +using LibationFileManager; using Newtonsoft.Json; using System; -using System.Diagnostics.CodeAnalysis; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; namespace AudibleUtilities; -public class Account : IUpdatable +[DebuggerDisplay("{AccountId,nq} - {Locale}")] +public class Account : IUpdatable, ILogMasked { public event EventHandler? Updated; private void update(object? sender = null, EventArgs? e = null) @@ -46,15 +51,18 @@ public bool LibraryScan } } - /// <summary>aka: activation bytes</summary> - [AllowNull] - public string? DecryptKey + /// <summary> + /// aka: activation bytes. A <see cref="SecretString"/> so that no reflective dump - Serilog's structured + /// logging, or Serilog.Exceptions walking a logged exception - can reach the value. Persists as the same + /// bare JSON string it always did. + /// </summary> + public SecretString DecryptKey { get => field; set { - var v = (value ?? "").Trim(); - if (v == field) + var v = (value.Reveal() ?? "").Trim(); + if (v == field.Reveal()) return; field = v; update(); @@ -80,16 +88,135 @@ public Identity? IdentityTokens } } + /// <summary> + /// The marketplace this account is registered with: where it logs in, and the only place its tokens can be + /// refreshed. Every account has exactly one. + /// </summary> [JsonIgnore] public Locale? Locale => IdentityTokens?.Locale; + private readonly List<string> _additionalLocaleNames = new(); + + /// <summary> + /// <para> + /// Further marketplaces this same login holds a library in, beyond <see cref="Locale"/>. A title bought while + /// an Amazon address was temporarily set to another country stays in that country's library forever, and only + /// a scan of that marketplace will ever see it. + /// </para> + /// <para> + /// Only marketplace names live here - no credentials. Audible honors one device registration across every + /// marketplace, so these are read with the very tokens <see cref="Locale"/> registered. + /// </para> + /// <para> + /// Written as null rather than <c>[]</c> when empty, so that the settings file of an account with one + /// marketplace - which is nearly all of them - is exactly what it was before this property existed. + /// </para> + /// </summary> + [JsonProperty(PropertyName = "AdditionalLocaleNames", NullValueHandling = NullValueHandling.Ignore)] + private List<string>? _additionalLocaleNames_json + { + get => _additionalLocaleNames.Count == 0 ? null : _additionalLocaleNames; + // 'set' is only used by json deser + set + { + if (value is null) + return; + + _additionalLocaleNames.Clear(); + foreach (var name in value.Select(canonicalize).OfType<string>()) + if (!_additionalLocaleNames.Contains(name) && name != Locale?.Name) + _additionalLocaleNames.Add(name); + } + } + + /// <summary> + /// The additional marketplaces, resolved. Names that no longer match a known locale are dropped, as is the + /// registered marketplace: json is applied in document order, so a file listing these before its + /// IdentityTokens would slip a duplicate past the check on the way in, and this marketplace would then be + /// scanned twice. + /// </summary> + [JsonIgnore] + public IReadOnlyList<Locale> AdditionalLocales + => _additionalLocaleNames + .Select(Localization.Get) + .Where(l => !string.IsNullOrEmpty(l.CountryCode) && l.Name != Locale?.Name) + .ToList(); + + /// <summary> + /// Every marketplace a scan of this account should read: its own, then any extras. One account is scanned as + /// a unit, so that a title found under one marketplace is never counted absent because another was scanned. + /// </summary> + [JsonIgnore] + public IReadOnlyList<Locale> ScanLocales + => Locale is null ? AdditionalLocales : new[] { Locale }.Concat(AdditionalLocales).ToList(); + + /// <summary>True if <paramref name="localeName"/> is this account's own marketplace or one of its extras.</summary> + public bool HasMarketplace(string? localeName) + => canonicalize(localeName) is string name + && (name == Locale?.Name || _additionalLocaleNames.Contains(name)); + + /// <summary>Adds an extra marketplace. No-op if it is already this account's own, or already added.</summary> + public bool AddMarketplace(string? localeName) + { + if (canonicalize(localeName) is not string name || HasMarketplace(name)) + return false; + + _additionalLocaleNames.Add(name); + update(); + return true; + } + + public bool RemoveMarketplace(string? localeName) + { + if (canonicalize(localeName) is not string name || !_additionalLocaleNames.Remove(name)) + return false; + + update(); + return true; + } + + /// <summary>Replaces the extra marketplaces wholesale. Used by the accounts dialog, which edits a copy.</summary> + public void SetAdditionalMarketplaces(IEnumerable<string?> localeNames) + { + var replacement = (localeNames ?? []) + .Select(canonicalize) + .OfType<string>() + .Where(n => n != Locale?.Name) + .Distinct() + .ToList(); + + if (replacement.SequenceEqual(_additionalLocaleNames)) + return; + + _additionalLocaleNames.Clear(); + _additionalLocaleNames.AddRange(replacement); + update(); + } + + /// <summary> + /// Store the locale's canonical name, so that a country code ('de') and an internal name ('germany') cannot + /// end up in the list as two separate marketplaces. + /// </summary> + private static string? canonicalize(string? localeName) + { + var locale = Localization.Get(localeName); + return string.IsNullOrEmpty(locale.CountryCode) ? null : locale.Name; + } + public Account(string accountId) { AccountId = ArgumentValidator.EnsureNotNullOrWhiteSpace(accountId, nameof(accountId)).Trim(); } - public override string ToString() => $"{AccountId} - {Locale?.Name ?? "[empty]"}"; + /// <summary> + /// Masked, because this is what interpolation and non-destructured logging reach for. Use + /// <see cref="AccountCredentialStatus.FormatAccountLabel"/> for dialogs shown to the account's owner, and see + /// the DebuggerDisplay above for the unmasked form while debugging. + /// </summary> + public override string ToString() => MaskedLogEntry; + /// <summary>Derived from the fields above, so persisting it would only add a stale copy of them.</summary> + [JsonIgnore] public string MaskedLogEntry => @$"AccountId={mask(AccountId)}|AccountName={mask(AccountName)}|Locale={Locale?.Name ?? "[empty]"}"; private static string mask(string? str) => str is null ? "[null]" diff --git a/Source/AudibleUtilities/AccountCredentialStatus.cs b/Source/AudibleUtilities/AccountCredentialStatus.cs new file mode 100644 index 000000000..540901776 --- /dev/null +++ b/Source/AudibleUtilities/AccountCredentialStatus.cs @@ -0,0 +1,37 @@ +using Dinah.Core; + +namespace AudibleUtilities; + +/// <summary>Describes whether an account has usable stored Audible tokens.</summary> +public static class AccountCredentialStatus +{ + /// <summary> + /// True when identity tokens are absent or carry no refresh token, meaning the account was never fully logged + /// in or its credentials were cleared, rather than merely holding an expired access token. + /// </summary> + public static bool LooksLikeMissingCredentials(Account? account) + { + if (account?.IdentityTokens is not { } tokens) + return true; + + if (tokens.IsValid) + return false; + + // without a refresh token there is nothing to renew from, so this needs a fresh login rather than a retry + return string.IsNullOrWhiteSpace(tokens.RefreshToken?.Reveal()); + } + + /// <summary> + /// Account label for dialogs shown to the person who owns the account. Not for logs: those get attached to + /// public issue reports, which is what <see cref="Account.MaskedLogEntry"/> is for. + /// </summary> + public static string FormatAccountLabel(Account? account) + { + if (account is null) + return "an Audible account"; + + return string.IsNullOrWhiteSpace(account.AccountName) || account.AccountName.EqualsInsensitive(account.AccountId) + ? $"'{account.AccountId}'" + : $"'{account.AccountName}' ({account.AccountId})"; + } +} diff --git a/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs b/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs new file mode 100644 index 000000000..cff1603e0 --- /dev/null +++ b/Source/AudibleUtilities/AccountSettingsDecryptFailure.cs @@ -0,0 +1,89 @@ +using AudibleApi.Authorization; + +namespace AudibleUtilities; + +/// <summary> +/// Libation-facing helpers when AccountsSettings.json contains encrypted tokens +/// that cannot be unlocked on this OS/machine (common when copying Windows config into Docker). +/// </summary> +public static class AccountSettingsDecryptFailure +{ + public const string LoadErrorCaption = "Error Loading Account Settings"; + + public const string FaqUrl + = "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"; + + private static readonly string[] ThingsToTryBullets = + [ + "Keep tokens encrypted: on the machine that created the file, export the master key (Settings -> Important -> Export encryption key..., or LibationCli export-master-key libation-master.key), then copy that file into the Docker/config folder next to AccountsSettings.json (or set LIBATION_MASTER_KEY_FILE / LIBATION_MASTER_KEY).", + "Or convert to plaintext: Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens when prompted, then copy the updated AccountsSettings.json again.", + "Or re-authenticate on this machine/container with LibationCli login-external or import-account.", + $"Details: {FaqUrl}", + ]; + + /// <summary> + /// True when <paramref name="ex"/> (or an inner exception) is a decrypt failure from identity token JSON. + /// </summary> + public static bool TryFindInTree(Exception ex, out IdentityTokenDecryptException? match) + { + ArgumentNullException.ThrowIfNull(ex); + IdentityTokenDecryptException? found = null; + walk(ex); + match = found; + return found is not null; + + void walk(Exception? e) + { + if (e is null || found is not null) + return; + + if (e is IdentityTokenDecryptException decryptEx) + { + found = decryptEx; + return; + } + + if (e is AggregateException agg) + { + foreach (var inner in agg.InnerExceptions) + walk(inner); + } + + walk(e.InnerException); + } + } + + public static IEnumerable<string> GetExplainerLines(Exception ex) + { + ArgumentNullException.ThrowIfNull(ex); + + yield return "Encrypted authentication tokens in AccountsSettings.json could not be decrypted on this machine."; + yield return "The encryption key is stored in the OS secret store where the tokens were encrypted (for example Windows DPAPI) and does not travel when you copy the file to Docker or another computer."; + if (TryFindInTree(ex, out var decryptEx) && decryptEx is not null) + yield return $"Underlying error: {decryptEx.Message.TrimEnd('.')}."; + yield return string.Empty; + yield return "Things to try:"; + foreach (var bullet in ThingsToTryBullets) + yield return "• " + bullet; + } + + public static string GetExplainerBody(Exception ex) + => string.Join("\r\n", GetExplainerLines(ex)); + + /// <summary> + /// Dialog body after backup-and-empty recovery when load failed due to token decrypt. + /// </summary> + public static string GetRecoveredDialogBody(Exception ex, string backupPath) + { + ArgumentNullException.ThrowIfNull(ex); + ArgumentException.ThrowIfNullOrWhiteSpace(backupPath); + + return $""" + {GetExplainerBody(ex)} + + Libation created a new, empty account settings file so the app can start. You will need to re-add your Audible account(s) (or copy a plaintext AccountsSettings.json) before scanning or downloading. + + The previous account settings file was archived at '{backupPath}' + """; + } +} diff --git a/Source/AudibleUtilities/AccountSummary.cs b/Source/AudibleUtilities/AccountSummary.cs new file mode 100644 index 000000000..1526e3c4b --- /dev/null +++ b/Source/AudibleUtilities/AccountSummary.cs @@ -0,0 +1,43 @@ +namespace AudibleUtilities; + +/// <summary> +/// What a failure needs to say about an account, captured so a live <see cref="Account"/> - with its tokens and +/// its activation bytes - never rides along on an exception into a log file. +/// <para> +/// Logs get attached to public issue reports, and Serilog.Exceptions writes every public property of a logged +/// exception into one, following nested objects as it goes. So everything public here is masked, and the label +/// meant for the account's owner is reachable only through a method: reflection reads properties and never calls +/// methods. +/// </para> +/// </summary> +public sealed class AccountSummary +{ + /// <summary>Safe to log. Also what <see cref="ToString"/> returns, so interpolating this cannot leak.</summary> + public string MaskedLogEntry { get; } + + /// <summary> + /// True when the account was never fully logged in or its credentials were cleared, rather than merely + /// holding an expired session. + /// </summary> + public bool LooksLikeMissingCredentials { get; } + + private readonly string ownerFacingLabel; + + private AccountSummary(Account account) + { + MaskedLogEntry = account.MaskedLogEntry; + LooksLikeMissingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account); + ownerFacingLabel = AccountCredentialStatus.FormatAccountLabel(account); + } + + /// <summary> + /// The name and address to show the person who owns the account, on their own screen. Never log this: use + /// <see cref="MaskedLogEntry"/>. + /// </summary> + public string RevealOwnerFacingLabel() => ownerFacingLabel; + + public override string ToString() => MaskedLogEntry; + + public static AccountSummary? From(Account? account) + => account is null ? null : new AccountSummary(account); +} diff --git a/Source/AudibleUtilities/AccountsSettings.cs b/Source/AudibleUtilities/AccountsSettings.cs index 2b606f5e2..ddcf56a5e 100644 --- a/Source/AudibleUtilities/AccountsSettings.cs +++ b/Source/AudibleUtilities/AccountsSettings.cs @@ -108,12 +108,46 @@ public void _add(Account account) account.Updated += update; } + /// <summary> + /// The account that can speak to <paramref name="locale"/> for this login: the one registered with that + /// marketplace, or failing that the one carrying it as an extra marketplace. Callers hand this a book's + /// marketplace and get back the credentials that can license it, which is why extras have to resolve here + /// as well as the registered marketplace does. + /// </summary> public Account? GetAccount(string accountId, string? locale) { if (locale is null) return null; - return Accounts.SingleOrDefault(a => a.AccountId == accountId && a.Locale?.Name == locale); + // AccountId is compared case-insensitively: Audible/library data has been observed to differ + // only by letter case (e.g. a stored id capitalized differently than settings), which caused + // spurious "No account found" failures that blocked every affected book. See issue #1931. + var registered = Accounts.SingleOrDefault(a => + a.AccountId.EqualsInsensitive(accountId) + && a.Locale?.Name == locale); + + if (registered is not null) + return registered; + + return Accounts.FirstOrDefault(a => + a.AccountId.EqualsInsensitive(accountId) + && a.HasMarketplace(locale)); + } + + /// <summary> + /// The account already scanning <paramref name="localeName"/> for this login, if any. Adding a marketplace, + /// importing an audible-cli file, and probing all need to know whether a marketplace is spoken for - including + /// by a second row for the same login, which is how multiple marketplaces were handled before one account + /// could hold several. + /// </summary> + public Account? GetAccountClaimingMarketplace(string accountId, string? localeName, Account? excluding = null) + { + var name = Localization.Get(localeName).Name; + + return Accounts.FirstOrDefault(a => + !ReferenceEquals(a, excluding) + && a.AccountId.EqualsInsensitive(accountId) + && a.HasMarketplace(name)); } public bool Delete(string accountId, string locale) @@ -135,24 +169,17 @@ public bool Delete(Account account) return result; } + /// <summary> + /// No two rows for one login may scan the same marketplace, or a scan would import it twice and a download + /// would have two accounts to choose from. Extra marketplaces count: claiming 'us' as an extra collides with + /// another row registered with 'us' just as surely as two 'us' registrations would. + /// </summary> private void validate(Account account) { ArgumentValidator.EnsureNotNull(account, nameof(account)); - var accountId = account.AccountId; - var locale = account?.IdentityTokens?.Locale?.Name; - - var acct = GetAccount(accountId, locale); - - // new: ok - if (acct is null) - return; - - // same account instance: ok - if (acct == account) - return; - - // same account id + locale, different instance: bad - throw new InvalidOperationException("Cannot add an account with the same account Id and Locale"); + foreach (var locale in account.ScanLocales) + if (GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is not null) + throw new InvalidOperationException("Cannot add an account with the same account Id and Locale"); } } diff --git a/Source/AudibleUtilities/ApiExtended.cs b/Source/AudibleUtilities/ApiExtended.cs index 546b510e7..12b8cbc94 100644 --- a/Source/AudibleUtilities/ApiExtended.cs +++ b/Source/AudibleUtilities/ApiExtended.cs @@ -5,13 +5,9 @@ using Newtonsoft.Json.Linq; using Polly; using Polly.Retry; -using System; -using System.Collections.Generic; using System.Diagnostics; -using System.Linq; -using System.Threading; +using System.Globalization; using System.Threading.Channels; -using System.Threading.Tasks; namespace AudibleUtilities; @@ -23,6 +19,10 @@ public class ApiExtended private const int MaxConcurrency = 10; private const int BatchSize = 50; + /// <summary>Extra attempts to re-request catalog products that Audible omitted from an otherwise successful response.</summary> + private const int MissingAsinRetries = 2; + /// <summary>Upper bound on how many dropped titles a single log entry will name.</summary> + private const int MaxLoggedTitles = 50; private ApiExtended(Api api) => Api = api; @@ -32,7 +32,46 @@ public class ApiExtended public static Task<ApiExtended> CreateAsync(Account account) => CreateAsync(account, allowInteractiveLogin: true); - public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin) + /// <summary> + /// Starts a fresh interactive login even when the stored tokens are currently + /// usable. This is intentionally separate from <see cref="CreateAsync(Account)"/>, + /// whose normal contract is to reuse stored authorization first. + /// </summary> + public static async Task ReauthenticateAsync(Account account) + { + ArgumentValidator.EnsureNotNull(account, nameof(account)); + ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId)); + var locale = ArgumentValidator.EnsureNotNull(account.Locale, nameof(account.Locale)); + if (LoginChoiceFactory is null) + throw new AuthenticationRequiredException( + account, + $"Interactive login is not available for {account.MaskedLogEntry} in this context (CLI/Docker)."); + + await InteractiveLoginGate.WaitAsync(); + try + { + Serilog.Log.Logger.Information("Beginning forced interactive account authentication. {@DebugInfo}", new + { + Account = account.MaskedLogEntry ?? "[null]", + LocaleName = locale.Name, + }); + _ = await EzApiCreator.GetApiAsync( + LoginChoiceFactory(account), + locale, + AudibleApiStorage.AccountsSettingsFile, + account.GetIdentityTokensJsonPath()); + } + finally + { + InteractiveLoginGate.Release(); + } + } + + /// <param name="storeLocale"> + /// The marketplace to read, when it is not the one the account is registered with. The account's own tokens + /// are used either way; only the store host changes. Null means the account's own marketplace. + /// </param> + public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin, Locale? storeLocale = null) { ArgumentValidator.EnsureNotNull(account, nameof(account)); ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId)); @@ -42,22 +81,49 @@ public static async Task<ApiExtended> CreateAsync(Account account, bool allowInt { Serilog.Log.Logger.Information("{@DebugInfo}", new { - AccountMaskedLogEntry = account.MaskedLogEntry + AccountMaskedLogEntry = account.MaskedLogEntry, + StoreLocaleName = (storeLocale ?? locale).Name }); var api = await EzApiCreator.GetApiAsync( locale, AudibleApiStorage.AccountsSettingsFile, - account.GetIdentityTokensJsonPath()); + account.GetIdentityTokensJsonPath(), + storeLocale); return new ApiExtended(api); } catch (Exception ex) { - if (!allowInteractiveLogin) - throw new AuthenticationRequiredException(account, innerException: ex); - - if (LoginChoiceFactory is null) - throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api"); + if (!allowInteractiveLogin || LoginChoiceFactory is null) + { + // an account that was never logged in needs a first login, not a renewal, and saying so saves the + // user hunting for an expired session that never existed + var missingCredentials = AccountCredentialStatus.LooksLikeMissingCredentials(account); + + Serilog.Log.Logger.Error(ex, + missingCredentials + ? "Stored credentials are missing or incomplete and interactive login is not available. {@DebugInfo}" + : "Stored credentials could not be used and interactive login is not available. {@DebugInfo}", + new + { + Account = account.MaskedLogEntry ?? "[null]", + LocaleName = locale.Name, + AllowInteractiveLogin = allowInteractiveLogin, + LoginChoiceFactorySet = LoginChoiceFactory is not null, + LooksLikeMissingCredentials = missingCredentials + }); + + // masked: this message is logged verbatim, both as {Exception} and as ExceptionDetail.Message + throw new AuthenticationRequiredException( + account, + message: $"Stored credentials for {account.MaskedLogEntry} " + + (missingCredentials ? "are missing or incomplete" : "could not be used") + + (LoginChoiceFactory is null + ? " and interactive login is not available in this context (CLI/Docker)." + : " and interactive login was not allowed.") + + $" {ex.Message}", + innerException: ex); + } await InteractiveLoginGate.WaitAsync(); try @@ -75,6 +141,15 @@ public static async Task<ApiExtended> CreateAsync(Account account, bool allowInt AudibleApiStorage.AccountsSettingsFile, account.GetIdentityTokensJsonPath()); + // login happens against the account's own marketplace, so the api it hands back reads that one. + // re-create once the tokens exist if some other marketplace is the one actually wanted. + if (storeLocale is not null && storeLocale.Name != locale.Name) + api = await EzApiCreator.GetApiAsync( + locale, + AudibleApiStorage.AccountsSettingsFile, + account.GetIdentityTokensJsonPath(), + storeLocale); + return new ApiExtended(api); } finally @@ -119,7 +194,14 @@ private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions) { Serilog.Log.Logger.Debug("Beginning library scan."); - List<Item> items = new(); + //Read the import filters once so a settings change mid-scan can't produce a half-filtered library. + var importEpisodes = Configuration.Instance.ImportEpisodes; + var importPlusTitles = Configuration.Instance.ImportPlusTitles; + + var items = new List<Item>(); + var libraryItemCount = 0; + var episodeItemsExcluded = 0; + var plusTitlesExcluded = 0; var sw = Stopwatch.StartNew(); var totalTime = TimeSpan.Zero; using var semaphore = new SemaphoreSlim(MaxConcurrency); @@ -131,7 +213,9 @@ private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions) //Get relationship asins from episode-type items and write them to episodeChannel where they will be batched and queried. await foreach (var itemsBatch in Api.GetLibraryItemsPagesAsync(libraryOptions, BatchSize, semaphore)) { - if (Configuration.Instance.ImportEpisodes) + libraryItemCount += itemsBatch.Length; + + if (importEpisodes) { var episodes = itemsBatch.Where(i => i.IsEpisodes).ToList(); var series = itemsBatch.Where(i => i.IsSeriesParent).ToList(); @@ -154,11 +238,18 @@ private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions) items.AddRange(episodes); items.AddRange(series); } + else + episodeItemsExcluded += itemsBatch.Count(i => i.IsSeriesParent || i.IsEpisodes); + + var booksInBatch = itemsBatch.Where(i => !i.IsSeriesParent && !i.IsEpisodes).ToList(); + + if (!importPlusTitles) + { + var ownedInBatch = booksInBatch.Where(i => i.IsAyce is not true).ToList(); + plusTitlesExcluded += booksInBatch.Count - ownedInBatch.Count; + booksInBatch = ownedInBatch; + } - var booksInBatch - = itemsBatch - .Where(i => !i.IsSeriesParent && !i.IsEpisodes) - .Where(i => i.IsAyce is not true || Configuration.Instance.ImportPlusTitles); items.AddRange(booksInBatch); } @@ -181,21 +272,30 @@ var booksInBatch Serilog.Log.Logger.Debug("Begin indexing series episodes"); items.AddRange(allEps); - //Set the Item.Series info for episodes and parents. - foreach (var parent in items.Where(i => i.IsSeriesParent)) - { - var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true); - SetSeries(parent, children); - } - - int orphansRemoved = items.RemoveAll(i => (i.IsEpisodes || i.IsSeriesParent) && i.Series is null); - if (orphansRemoved > 0) - Serilog.Log.Debug("{orphansRemoved} podcast orphans not imported", orphansRemoved); + //Name the casualties: an unlinked episode silently vanishes from the library, and without this + //there is nothing in the log to explain why. + var orphans = LinkEpisodesToSeries(items).Select(describe).Distinct().ToList(); + if (orphans.Count > 0) + Serilog.Log.Logger.Warning( + "{orphansRemoved} podcast episodes were not imported because their series parent was missing from this scan. {@DebugInfo}", + orphans.Count, + new { Orphans = orphans.Take(MaxLoggedTitles).ToList(), Truncated = orphans.Count > MaxLoggedTitles }); sw.Stop(); totalTime += sw.Elapsed; Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds); Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms."); + Serilog.Log.Logger.Information("Library scan tally. {@DebugInfo}", new + { + LibraryItems = libraryItemCount, + EpisodesFetched = allEps.Count, + OrphanedEpisodesDropped = orphans.Count, + ImportEpisodes = importEpisodes, + EpisodeItemsExcluded = episodeItemsExcluded, + ImportPlusTitles = importPlusTitles, + PlusTitlesExcluded = plusTitlesExcluded, + ItemsToImport = items.Count + }); Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items)); var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList(); @@ -203,6 +303,32 @@ var booksInBatch throw new ImportValidationException(items, allExceptions); return items; + + static string describe(Item item) => $"[{item.Asin}] {item.Title}"; + } + + /// <summary> + /// Set <see cref="Item.Series"/> on every series parent in <paramref name="items"/> and on the episodes + /// belonging to it, then drop the episodes and parents left unlinked. An episode is left unlinked when + /// its parent is not among <paramref name="items"/>, which happens when Audible omits the parent from a + /// catalog response or when the parent is a container Libation does not treat as a series (e.g. a season). + /// </summary> + /// <returns>The unlinked items removed from <paramref name="items"/>.</returns> + public static List<Item> LinkEpisodesToSeries(List<Item> items) + { + ArgumentValidator.EnsureNotNull(items, nameof(items)); + + foreach (var parent in items.Where(i => i.IsSeriesParent)) + { + var children = items.Where(i => i.IsEpisodes && i.Relationships?.Any(r => r.Asin == parent.Asin) is true); + SetSeries(parent, children); + } + + var unlinked = items.Where(isUnlinked).ToList(); + items.RemoveAll(isUnlinked); + return unlinked; + + static bool isUnlinked(Item item) => (item.IsEpisodes || item.IsSeriesParent) && item.Series is null; } #region episodes and podcasts @@ -244,15 +370,27 @@ private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins try { var sw = Stopwatch.StartNew(); - var items = await Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media - | CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc - | CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview - | CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series - | CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs); + + //Audible sometimes omits products from a response that is otherwise successful. Those episodes + //would silently disappear from the library, so re-request them before accepting the loss. + var (items, missing) = await FetchRetryingMissingAsync( + asins, + getCatalogProductsAsync, + MissingAsinRetries, + attempt => Serilog.Log.Logger.Debug($"Batch {batchNum} Retry {attempt}: Re-fetching asins Audible did not return")); + sw.Stop(); Serilog.Log.Logger.Debug($"Batch {batchNum} End: Retrieved {items.Count} items in {sw.ElapsedMilliseconds} ms"); + if (missing.Count > 0) + Serilog.Log.Logger.Warning( + "Audible did not return {missingCount} of the {requestedCount} catalog products requested in batch {batchNum}. Those titles are missing from this scan. {@DebugInfo}", + missing.Count, + asins.Count, + batchNum, + new { MissingAsins = missing }); + return items; } catch (Exception ex) @@ -263,6 +401,52 @@ private async Task<List<Item>> getProductsAsync(int batchNum, List<string> asins finally { semaphore.Release(); } } + private Task<List<Item>> getCatalogProductsAsync(List<string> asins) + => Api.GetCatalogProductsAsync(asins, CatalogOptions.ResponseGroupOptions.Rating | CatalogOptions.ResponseGroupOptions.Media + | CatalogOptions.ResponseGroupOptions.Relationships | CatalogOptions.ResponseGroupOptions.ProductDesc + | CatalogOptions.ResponseGroupOptions.Contributors | CatalogOptions.ResponseGroupOptions.ProvidedReview + | CatalogOptions.ResponseGroupOptions.ProductPlans | CatalogOptions.ResponseGroupOptions.Series + | CatalogOptions.ResponseGroupOptions.CategoryLadders | CatalogOptions.ResponseGroupOptions.ProductExtendedAttrs); + + /// <summary>Requested asins for which <paramref name="received"/> holds no matching <see cref="Item"/>.</summary> + public static List<string> GetMissingAsins(IEnumerable<string> requested, IEnumerable<Item> received) + { + ArgumentValidator.EnsureNotNull(requested, nameof(requested)); + ArgumentValidator.EnsureNotNull(received, nameof(received)); + + var found = received.Select(i => i.Asin).OfType<string>().ToHashSet(StringComparer.OrdinalIgnoreCase); + return requested.Where(a => !found.Contains(a)).ToList(); + } + + /// <summary> + /// Fetch <paramref name="asins"/>, re-requesting any that <paramref name="fetch"/> fails to return. + /// Audible's catalog endpoint can answer 200 while quietly omitting products. + /// </summary> + /// <returns>Everything that was returned, plus the asins still unaccounted for after the last attempt.</returns> + public static async Task<(List<Item> Items, List<string> Missing)> FetchRetryingMissingAsync( + List<string> asins, + Func<List<string>, Task<List<Item>>> fetch, + int maxRetries, + Action<int>? onRetry = null) + { + ArgumentValidator.EnsureNotNull(asins, nameof(asins)); + ArgumentValidator.EnsureNotNull(fetch, nameof(fetch)); + + var items = await fetch(asins); + var missing = GetMissingAsins(asins, items); + + for (var attempt = 1; attempt <= maxRetries && missing.Count > 0; attempt++) + { + onRetry?.Invoke(attempt); + + var retriedItems = await fetch(missing); + items.AddRange(retriedItems); + missing = GetMissingAsins(missing, retriedItems); + } + + return (items, missing); + } + public static void SetSeries(Item parent, IEnumerable<Item> children) { ArgumentValidator.EnsureNotNull(parent, nameof(parent)); @@ -291,21 +475,21 @@ public static void SetSeries(Item parent, IEnumerable<Item> children) } int lastEpNum = -1, dupeCount = 0; - foreach (var child in children.OrderBy(i => i.EpisodeNumber).ThenBy(i => i.PublicationDateTime)) + foreach (var child in children.OrderBy(i => UsableEpisodeNumber(i)).ThenBy(i => i.PublicationDateTime)) { string sequence; - if (child.EpisodeNumber is null) + var episodeNumber = UsableEpisodeNumber(child); + if (episodeNumber is null) { - // This should properly be Single() not FirstOrDefault(), but FirstOrDefault is defensive for malformed data from audible - sequence = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin)?.Sort?.ToString() ?? "0"; + sequence = FallbackSeriesSequence(parent, child); } else { //multipart episodes may have the same episode number - if (child.EpisodeNumber == lastEpNum) + if (episodeNumber == lastEpNum) dupeCount++; else - lastEpNum = child.EpisodeNumber.Value; + lastEpNum = episodeNumber.Value; sequence = (lastEpNum + dupeCount).ToString(); } @@ -324,5 +508,69 @@ public static void SetSeries(Item parent, IEnumerable<Item> children) }; } } + + /// <summary> + /// Nine digits. Big enough for YYYYMMDD-style numbering; too small for unix timestamps, + /// Integer.MAX_VALUE, and the other sentinel integers Audible has sent as episode order (issue #2024). + /// </summary> + private const long MaxPlausibleSeriesOrder = 1_000_000_000; + + private static bool IsPlausibleSeriesOrder(long n) + => n >= 0 && n < MaxPlausibleSeriesOrder; + + /// <summary> + /// Audible sometimes serializes a missing episode_number as a sentinel integer (Integer.MAX_VALUE + /// was the one in #2024) instead of omitting the field. Treat any implausibly large value the same way. + /// </summary> + private static int? UsableEpisodeNumber(Item child) + => child.EpisodeNumber is int n && IsPlausibleSeriesOrder(n) ? n : null; + + /// <summary> + /// When episode_number is missing or implausibly large, use relationship sort/sequence + /// or the catalog series sequence. Prefer the parent's child relationship (the historical source), + /// then the child's parent relationship, then any series sequence Audible already attached. + /// </summary> + private static string FallbackSeriesSequence(Item parent, Item child) + { + var fromParent = parent.Relationships?.FirstOrDefault(r => r.Asin == child.Asin); + if (UsableRelationshipOrder(fromParent) is string parentOrder) + return parentOrder; + + var fromChild = child.Relationships?.FirstOrDefault(r => r.Asin == parent.Asin); + if (UsableRelationshipOrder(fromChild) is string childOrder) + return childOrder; + + var catalogSequence = child.Series?.FirstOrDefault(s => s.Asin == parent.Asin)?.Sequence; + if (IsUsableOrderString(catalogSequence)) + return catalogSequence!; + + return "0"; + } + + private static string? UsableRelationshipOrder(Relationship? relationship) + { + if (relationship is null) + return null; + if (IsUsableSort(relationship.Sort)) + return relationship.Sort!.Value.ToString(); + if (IsUsableOrderString(relationship.Sequence)) + return relationship.Sequence; + return null; + } + + private static bool IsUsableSort(long? sort) + => sort is long n && IsPlausibleSeriesOrder(n); + + /// <summary> + /// Bare integers that are too large are sentinels or timestamps. Mixed forms like "1-6" or "2.1" + /// are real series orders and are left alone. + /// </summary> + private static bool IsUsableOrderString(string? value) + { + if (string.IsNullOrWhiteSpace(value) || value == "-1") + return false; + return !long.TryParse(value, NumberStyles.Integer, CultureInfo.InvariantCulture, out var n) + || IsPlausibleSeriesOrder(n); + } #endregion } diff --git a/Source/AudibleUtilities/AudibleUtilities.csproj b/Source/AudibleUtilities/AudibleUtilities.csproj index cfe943236..0e91008b0 100644 --- a/Source/AudibleUtilities/AudibleUtilities.csproj +++ b/Source/AudibleUtilities/AudibleUtilities.csproj @@ -7,7 +7,7 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="AudibleApi" Version="10.3.1.1" /> + <PackageReference Include="AudibleApi" Version="13.1.1.1" /> <PackageReference Include="Google.Protobuf" Version="3.34.1" /> </ItemGroup> @@ -28,4 +28,8 @@ <DependentUpon>Cdm.cs</DependentUpon> </Compile> </ItemGroup> + + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> </Project> diff --git a/Source/AudibleUtilities/AuthenticationExceptionHelper.cs b/Source/AudibleUtilities/AuthenticationExceptionHelper.cs index 1608b7d34..af9d66ea7 100644 --- a/Source/AudibleUtilities/AuthenticationExceptionHelper.cs +++ b/Source/AudibleUtilities/AuthenticationExceptionHelper.cs @@ -1,5 +1,3 @@ -using AudibleApi.Authentication; - namespace AudibleUtilities; public static class AuthenticationExceptionHelper @@ -14,7 +12,7 @@ public static bool IsAuthenticationFailure(Exception ex) for (var current = ex; current is not null; current = current.InnerException) { - if (current is AuthenticationRequiredException or LoginFailedException) + if (current is AuthenticationRequiredException) return true; if (current is InvalidOperationException { Message: var message } @@ -24,4 +22,28 @@ public static bool IsAuthenticationFailure(Exception ex) return false; } + + /// <summary> + /// Finds the <see cref="AuthenticationRequiredException"/> in <paramref name="ex"/> or its inner chain, which is + /// the one that knows which account needs a login. + /// </summary> + public static AuthenticationRequiredException? FindAuthenticationRequired(Exception ex) + { + for (var current = ex; current is not null; current = current.InnerException) + { + if (current is AuthenticationRequiredException auth) + return auth; + } + + if (ex is AggregateException aggregate) + { + foreach (var inner in aggregate.InnerExceptions) + { + if (FindAuthenticationRequired(inner) is { } found) + return found; + } + } + + return null; + } } diff --git a/Source/AudibleUtilities/AuthenticationRequiredException.cs b/Source/AudibleUtilities/AuthenticationRequiredException.cs index b9946b8bd..3cdfeab71 100644 --- a/Source/AudibleUtilities/AuthenticationRequiredException.cs +++ b/Source/AudibleUtilities/AuthenticationRequiredException.cs @@ -6,9 +6,14 @@ namespace AudibleUtilities; /// </summary> public sealed class AuthenticationRequiredException : Exception { - public Account? Account { get; } + /// <summary> + /// A log-safe summary rather than the <see cref="Account"/> itself. Serilog.Exceptions reflects over every + /// public property of a logged exception, so holding the live account published its address - and would have + /// published its activation bytes - into logs people attach to public issue reports. + /// </summary> + public AccountSummary? AccountInfo { get; } public AuthenticationRequiredException(Account? account, string? message = null, Exception? innerException = null) : base(message ?? "Audible authentication is required.", innerException) - => Account = account; + => AccountInfo = AccountSummary.From(account); } diff --git a/Source/AudibleUtilities/Extensions.cs b/Source/AudibleUtilities/Extensions.cs index 9e843acc2..d8d8132bf 100644 --- a/Source/AudibleUtilities/Extensions.cs +++ b/Source/AudibleUtilities/Extensions.cs @@ -20,5 +20,25 @@ public static class Extensions .Select(p => p.EndDate) .FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now) ?.DateTime; + + /// <summary> + /// The book's summary with Audible's HTML markup flattened away, ready to be stored and used + /// as-is. See <see cref="HtmlText.ToPlainText"/>. + /// </summary> + public string PlainTextDescription() + => HtmlText.ToPlainText(item.Description); + + /// <summary> + /// The publisher's copyright line, e.g. "©2024 Bentley Little (P)2025 Journalstone". + /// Only present when the product_details response group was requested, and often null anyway. + /// </summary> + /// <remarks><see cref="Item.Copyright"/> is typed as <see cref="object"/> because Audible has + /// never been observed returning anything but a string or null there. Anything else is + /// discarded rather than stringified, so a shape we do not understand cannot reach a file tag. + /// </remarks> + public string? CopyrightString() + => item.Copyright is string copyright && !string.IsNullOrWhiteSpace(copyright) + ? copyright.Trim() + : null; } } diff --git a/Source/AudibleUtilities/HtmlText.cs b/Source/AudibleUtilities/HtmlText.cs new file mode 100644 index 000000000..aa21d5e69 --- /dev/null +++ b/Source/AudibleUtilities/HtmlText.cs @@ -0,0 +1,48 @@ +using System.Net; +using System.Text.RegularExpressions; + +namespace AudibleUtilities; + +/// <summary> +/// Audible returns a book's summary as HTML. Nothing downstream of the API renders markup - not the +/// library grid, not the exports, and least of all the metadata tags written into an audio file - so +/// the markup is flattened here, on the way in, rather than carried around and stripped at each use. +/// </summary> +public static partial class HtmlText +{ + /// <param name="paragraphSeparator">Joins the block-level runs. A single newline matches what + /// Audible itself embeds in the description tags of its .aaxc files.</param> + public static string ToPlainText(string? html, string paragraphSeparator = "\n") + { + if (string.IsNullOrWhiteSpace(html)) + return ""; + + // Not every summary is marked up. Running the unmarked ones through the parser risks a stray + // '<' swallowing the rest of the sentence, and buys nothing. + if (!html.Contains('<')) + return (WebUtility.HtmlDecode(html) ?? html).Trim(); + + // Replace block-level boundaries with newlines, then strip all remaining tags. + var stripped = StripTags().Replace(BlockBoundary().Replace(html, "\n"), ""); + + var text = WebUtility.HtmlDecode(stripped) ?? stripped; + + var paragraphs = text + .Replace("\r\n", "\n") + .Replace('\r', '\n') + .Split('\n') + .Select(line => line.Trim()) + .Where(line => line.Length > 0); + + return string.Join(paragraphSeparator, paragraphs); + } + + /// <summary>Every tag that ends a run of text. A line break counts as a paragraph break because + /// Audible's summaries mark up paragraphs and nothing finer.</summary> + [GeneratedRegex(@"<\s*br\s*/?\s*>|<\s*/\s*(?:p|div|li|tr|blockquote|h[1-6])\s*>", RegexOptions.IgnoreCase)] + private static partial Regex BlockBoundary(); + + /// <summary>Matches any HTML/XML tag.</summary> + [GeneratedRegex(@"<[^>]+>")] + private static partial Regex StripTags(); +} diff --git a/Source/AudibleUtilities/IdentityTokenStorageWiring.cs b/Source/AudibleUtilities/IdentityTokenStorageWiring.cs index 45db7fb84..40aff4686 100644 --- a/Source/AudibleUtilities/IdentityTokenStorageWiring.cs +++ b/Source/AudibleUtilities/IdentityTokenStorageWiring.cs @@ -1,7 +1,9 @@ using AudibleApi.Authorization; +using Dinah.Core.IO; using Dinah.Core.Security; using LibationFileManager; using System.ComponentModel; +using System.Security.Cryptography; namespace AudibleUtilities; @@ -13,6 +15,15 @@ public static class IdentityTokenStorageWiring { public const string ApplicationName = "Libation"; + /// <summary>Env var: path to a raw 32-byte master key file (from <c>export-master-key</c>).</summary> + public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE"; + + /// <summary>Env var: Base64-encoded 32-byte master key.</summary> + public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY"; + + /// <summary>Default master key file name under the Libation files directory.</summary> + public const string DefaultMasterKeyFileName = "libation-master.key"; + private static Lock Gate { get; } = new(); private static Configuration? _wiredConfig; @@ -47,7 +58,7 @@ public static void ConfigureFrom(Configuration config) ArgumentNullException.ThrowIfNull(config); var method = config.TokenStorageMethod; - var store = OsSecretStore.Create(ApplicationName); + var store = ResolveSecretStore(config); AesGcmSecretProtector? protector = store.IsAvailable ? new AesGcmSecretProtector(store) : null; @@ -56,6 +67,36 @@ public static void ConfigureFrom(Configuration config) IdentityTokenStorage.Configure(method, protector); } + /// <summary> + /// Resolve the secret store used for the AES-GCM master key. + /// Supported priority: <see cref="MasterKeyFileEnvVar"/> -> existing default <see cref="DefaultMasterKeyFileName"/> + /// under Libation files -> <see cref="MasterKeyEnvVar"/> -> OS-bound <see cref="OsSecretStore"/>. + /// If the OS store is unavailable, falls through to + /// <see cref="TryCreateLastResortPortableMasterKeyStore"/> (headless compatibility path; not the preferred setup). + /// </summary> + public static IOsSecretStore ResolveSecretStore(Configuration config) + { + ArgumentNullException.ThrowIfNull(config); + + var keyFileEnv = Environment.GetEnvironmentVariable(MasterKeyFileEnvVar); + if (!string.IsNullOrWhiteSpace(keyFileEnv)) + return LoadMasterKeyFileOrUnavailable(keyFileEnv.Trim()); + + var defaultKeyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName); + if (File.Exists(defaultKeyPath)) + return LoadMasterKeyFileOrUnavailable(defaultKeyPath); + + var keyEnv = Environment.GetEnvironmentVariable(MasterKeyEnvVar); + if (!string.IsNullOrWhiteSpace(keyEnv)) + return LoadMasterKeyBase64OrUnavailable(keyEnv.Trim()); + + var osStore = OsSecretStore.Create(ApplicationName); + if (osStore.IsAvailable) + return osStore; + + return TryCreateLastResortPortableMasterKeyStore(config); + } + /// <summary>True when the OS secret store can hold Libation's encryption master key.</summary> public static bool IsOsSecretStoreAvailable(out string? unavailableReason) { @@ -64,6 +105,153 @@ public static bool IsOsSecretStoreAvailable(out string? unavailableReason) return store.IsAvailable; } + /// <summary>True when any resolved secret store (portable or OS) can supply an encryption master key.</summary> + public static bool IsEncryptionKeyAvailable(Configuration config, out string? unavailableReason) + { + ArgumentNullException.ThrowIfNull(config); + var store = ResolveSecretStore(config); + unavailableReason = store.IsAvailable ? null : store.UnavailableReason; + return store.IsAvailable; + } + + /// <summary>Sidecar notice written beside an auto-minted last-resort master key.</summary> + public const string LastResortMasterKeyNoticeFileName = "libation-master.key.NOTICE.txt"; + + /// <summary> + /// Headless / Docker compatibility path when encryption is enabled but there is no OS secret store + /// and the user did not supply a master key. + /// Prefer setting <see cref="Configuration.TokenStorageMethod"/> to plaintext, or supplying an exported + /// <see cref="DefaultMasterKeyFileName"/> / env key, instead of relying on this path. + /// Creates <see cref="DefaultMasterKeyFileName"/> under Libation files if missing, then loads it. + /// </summary> + internal static IOsSecretStore TryCreateLastResortPortableMasterKeyStore(Configuration config) + { + ArgumentNullException.ThrowIfNull(config); + + var keyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName); + try + { + if (!File.Exists(keyPath)) + { + MintRawMasterKeyFile(keyPath); + AnnounceLastResortPortableMasterKey(keyPath); + } + + return LoadMasterKeyFileOrUnavailable(keyPath); + } + catch (Exception ex) + { + return new UnavailablePortableSecretStore( + "Last-resort portable master key", + $"Could not create or load last-resort portable master key ({keyPath}): {SafeMessage(ex)}"); + } + } + + /// <summary> + /// Warn that a last-resort portable master key was auto-created. Not the recommended setup. + /// </summary> + internal static void AnnounceLastResortPortableMasterKey(string keyPath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(keyPath); + + var fullKeyPath = Path.GetFullPath(keyPath); + var noticePath = Path.Combine( + Path.GetDirectoryName(fullKeyPath) ?? ".", + LastResortMasterKeyNoticeFileName); + + var message = + "LAST-RESORT: Token encryption is enabled, but no OS secret store is available and no master key was supplied. " + + $"Libation created a portable master key at '{fullKeyPath}'. " + + "This is a compatibility fallback, not the recommended setup. " + + "Prefer: set TokenStorageMethod to Plaintext in Settings, " + + $"OR supply your own key (export from desktop and place '{DefaultMasterKeyFileName}' next to AccountsSettings.json, " + + $"or set {MasterKeyFileEnvVar} / {MasterKeyEnvVar}). " + + "Treat the key file like a password. It will not decrypt tokens encrypted under a different machine's key."; + + try + { + AtomicFileWriter.WriteAllText(noticePath, message + Environment.NewLine); + } + catch (Exception ex) + { + Serilog.Log.Logger.Warning(ex, "Could not write last-resort master key notice file at {NoticePath}", noticePath); + } + + Serilog.Log.Logger.Warning(message); + Console.Error.WriteLine(message); + } + + /// <summary>Write a new raw 32-byte master key file (same format as <c>export-master-key</c>).</summary> + private static void MintRawMasterKeyFile(string keyPath) + { + var key = new byte[AesGcmSecretProtector.KeySizeBytes]; + RandomNumberGenerator.Fill(key); + try + { + var directory = Path.GetDirectoryName(Path.GetFullPath(keyPath)); + if (!string.IsNullOrEmpty(directory)) + Directory.CreateDirectory(directory); + + AtomicFileWriter.WriteAllBytes(keyPath, key); + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + private static IOsSecretStore LoadMasterKeyFileOrUnavailable(string path) + { + var store = new MemoryOsSecretStore(); + try + { + MasterKeyPortability.ImportFromFile(store, path); + return store; + } + catch (Exception ex) + { + return new UnavailablePortableSecretStore( + "Portable master key file", + $"Portable master key file is unusable ({path}): {SafeMessage(ex)}"); + } + } + + private static IOsSecretStore LoadMasterKeyBase64OrUnavailable(string base64) + { + byte[] key; + try + { + key = Convert.FromBase64String(base64); + } + catch (FormatException ex) + { + return new UnavailablePortableSecretStore( + "Portable master key env", + $"{MasterKeyEnvVar} is not valid Base64: {SafeMessage(ex)}"); + } + + try + { + if (key.Length != AesGcmSecretProtector.KeySizeBytes) + { + return new UnavailablePortableSecretStore( + "Portable master key env", + $"{MasterKeyEnvVar} must decode to {AesGcmSecretProtector.KeySizeBytes} bytes."); + } + + var store = new MemoryOsSecretStore(); + store.Set(AesGcmSecretProtector.DefaultMasterKeyName, key); + return store; + } + finally + { + CryptographicOperations.ZeroMemory(key); + } + } + + private static string SafeMessage(Exception ex) + => string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message; + private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e) { if (e.PropertyName != nameof(Configuration.TokenStorageMethod)) @@ -73,4 +261,26 @@ private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e ConfigureFrom(config); } + + private sealed class UnavailablePortableSecretStore : IOsSecretStore + { + public UnavailablePortableSecretStore(string name, string reason) + { + Name = name; + UnavailableReason = reason; + } + + public string Name { get; } + public bool IsAvailable => false; + public string? UnavailableReason { get; } + + public void Set(string key, ReadOnlySpan<byte> value) + => throw new OsSecretStoreUnavailableException(Name, UnavailableReason!); + + public bool TryGet(string key, out byte[] value) + => throw new OsSecretStoreUnavailableException(Name, UnavailableReason!); + + public void Delete(string key) + => throw new OsSecretStoreUnavailableException(Name, UnavailableReason!); + } } diff --git a/Source/AudibleUtilities/MarketplaceProbe.cs b/Source/AudibleUtilities/MarketplaceProbe.cs new file mode 100644 index 000000000..07c3f14b0 --- /dev/null +++ b/Source/AudibleUtilities/MarketplaceProbe.cs @@ -0,0 +1,151 @@ +using AudibleApi; +using Dinah.Core; +using System; +using System.Collections.Generic; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; + +namespace AudibleUtilities; + +public enum MarketplaceProbeOutcome +{ + /// <summary>Already scanned: this account's own marketplace, or one it has already been given.</summary> + AlreadyScanned, + /// <summary>Scanned by a different account row for the same login - the pre-existing way to hold two marketplaces.</summary> + ScannedByAnotherAccount, + /// <summary>The marketplace answered, and holds titles.</summary> + TitlesFound, + /// <summary>The marketplace answered, and holds nothing.</summary> + Empty, + /// <summary>The marketplace could not be asked. Says nothing either way about what is there.</summary> + Failed +} + +/// <param name="TitleCount">Titles the marketplace reports, or null when it was not asked or did not answer.</param> +/// <param name="ClaimedBy">For <see cref="MarketplaceProbeOutcome.ScannedByAnotherAccount"/>, the account already scanning it.</param> +public record MarketplaceProbeResult( + Locale Locale, + MarketplaceProbeOutcome Outcome, + int? TitleCount = null, + string? ClaimedBy = null, + string? Error = null) +{ + /// <summary>Whether adding this marketplace to the account is something the user can choose to do.</summary> + public bool CanAdd => Outcome is MarketplaceProbeOutcome.TitlesFound or MarketplaceProbeOutcome.Empty; +} + +/// <summary> +/// <para> +/// Asks each Audible marketplace whether this login holds anything there. +/// </para> +/// <para> +/// A title bought while an Amazon address was briefly set to another country stays in that country's library for +/// good, and a scan of the account's own marketplace will never see it - no error, no warning, the titles are +/// simply absent. One device registration is honored by every marketplace, so the only thing standing between +/// those titles and a scan is knowing which marketplace to look in. That is what this answers. +/// </para> +/// <para> +/// One request per marketplace, in sequence: enough to get a count, and no more traffic or concurrency than a +/// user who clicked a button should generate. Nothing here runs on its own - the accounts dialog asks for it. +/// </para> +/// </summary> +public static class MarketplaceProbe +{ + /// <summary>Space between requests. A deliberate trickle rather than a fan-out.</summary> + public static TimeSpan RequestSpacing { get; set; } = TimeSpan.FromMilliseconds(250); + + /// <summary> + /// The marketplaces worth asking about for this account. Pre-Amazon locales and modern ones are separate + /// worlds with their own logins, so only the account's own kind is probed; asking about the rest would double + /// the traffic to learn nothing. + /// </summary> + public static IReadOnlyList<Locale> CandidateLocales(Account account) + { + var withUsername = account?.Locale?.WithUsername ?? false; + + return Localization.Locales + .Where(l => l.WithUsername == withUsername) + .OrderBy(l => l.Name) + .ToList(); + } + + /// <summary> + /// Probe every candidate marketplace, yielding each result as it arrives so a dialog can fill in a row at a + /// time. Never throws for a single marketplace: one that cannot be reached is reported as such and the rest + /// carry on. + /// </summary> + public static async IAsyncEnumerable<MarketplaceProbeResult> ProbeAsync( + Account account, + AccountsSettings accountsSettings, + [System.Runtime.CompilerServices.EnumeratorCancellation] CancellationToken cancellationToken = default) + { + ArgumentValidator.EnsureNotNull(account, nameof(account)); + ArgumentValidator.EnsureNotNull(accountsSettings, nameof(accountsSettings)); + + var first = true; + + foreach (var locale in CandidateLocales(account)) + { + cancellationToken.ThrowIfCancellationRequested(); + + if (account.HasMarketplace(locale.Name)) + { + yield return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.AlreadyScanned); + continue; + } + + if (accountsSettings.GetAccountClaimingMarketplace(account.AccountId, locale.Name, excluding: account) is { } other) + { + yield return new MarketplaceProbeResult( + locale, + MarketplaceProbeOutcome.ScannedByAnotherAccount, + ClaimedBy: AccountCredentialStatus.FormatAccountLabel(other)); + continue; + } + + if (!first) + await Task.Delay(RequestSpacing, cancellationToken); + first = false; + + yield return await probeOneAsync(account, locale); + } + } + + private static async Task<MarketplaceProbeResult> probeOneAsync(Account account, Locale locale) + { + try + { + // no interactive login: the whole premise is that this account's existing tokens already work here + var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: false, storeLocale: locale); + + var count = await apiExtended.Api.GetItemsCountAsync( + new LibraryOptions { PurchasedAfter = new DateTime(1970, 1, 1) }); + + Serilog.Log.Logger.Information( + "Marketplace probe: {LocaleName} reported {TitleCount} titles. {@DebugInfo}", + locale.Name, + count, + new { Account = account.MaskedLogEntry }); + + // -1 means Audible answered without the count header. it answered, so the marketplace is reachable; + // treat it as reachable-but-unknown rather than claiming there is nothing there + return count switch + { + > 0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.TitlesFound, count), + 0 => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty, 0), + _ => new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Empty) + }; + } + catch (Exception ex) + { + Serilog.Log.Logger.Information( + ex, + "Marketplace probe: {LocaleName} could not be checked. {@DebugInfo}", + locale.Name, + new { Account = account.MaskedLogEntry }); + + return new MarketplaceProbeResult(locale, MarketplaceProbeOutcome.Failed, Error: ex.Message); + } + } +} diff --git a/Source/AudibleUtilities/MasterKeyExport.cs b/Source/AudibleUtilities/MasterKeyExport.cs new file mode 100644 index 000000000..9a2a6f3ad --- /dev/null +++ b/Source/AudibleUtilities/MasterKeyExport.cs @@ -0,0 +1,39 @@ +using Dinah.Core.Security; + +namespace AudibleUtilities; + +/// <summary> +/// Export Libation's OS-bound AES-GCM master key for portable use (e.g. Docker). +/// Never creates a new key; the desktop OS store must already hold one. +/// </summary> +public static class MasterKeyExport +{ + /// <summary> + /// Write the existing Libation master key as raw bytes to <paramref name="filePath"/>. + /// </summary> + /// <exception cref="InvalidOperationException">OS secret store unavailable or master key missing.</exception> + public static void ExportToFile(string filePath) + { + ArgumentException.ThrowIfNullOrWhiteSpace(filePath); + + if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason)) + { + throw new InvalidOperationException( + "Cannot export the encryption master key because the OS secret store is unavailable." + + (string.IsNullOrWhiteSpace(unavailableReason) ? "" : " " + unavailableReason)); + } + + var store = OsSecretStore.Create(IdentityTokenStorageWiring.ApplicationName); + try + { + MasterKeyPortability.ExportToFile(store, filePath); + } + catch (SecretProtectionException ex) + { + throw new InvalidOperationException( + "Cannot export the encryption master key because it was not found. " + + "Encrypt tokens on this machine at least once (Settings -> Important, store tokens encrypted) so a key exists, then try again.", + ex); + } + } +} diff --git a/Source/AudibleUtilities/Mkb79Auth.cs b/Source/AudibleUtilities/Mkb79Auth.cs index 87df41f87..e58e60412 100644 --- a/Source/AudibleUtilities/Mkb79Auth.cs +++ b/Source/AudibleUtilities/Mkb79Auth.cs @@ -2,6 +2,7 @@ using AudibleApi.Authorization; using AudibleApi.Cryptography; using Dinah.Core; +using Dinah.Core.Security; using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; @@ -208,7 +209,7 @@ public async Task<Account> ToAccountAsync() var authorize = new Authorize(Locale); var newToken = await authorize.RefreshAccessTokenAsync(refreshToken); - AccessToken = newToken.TokenValue; + AccessToken = newToken.Reveal(); AccessTokenExpires = newToken.Expires; var api = new Api(this); @@ -226,7 +227,7 @@ public async Task<Account> ToAccountAsync() adpToken, newToken, refreshToken, - WebsiteCookies?.Select(c => new KeyValuePair<string, string?>(c.Key, c.Value)), + WebsiteCookies?.Select(c => new KeyValuePair<string, SecretString>(c.Key, c.Value)), DeviceSerialNumber, DeviceType, AmazonAccountId, @@ -236,12 +237,18 @@ public async Task<Account> ToAccountAsync() return account; } + /// <summary> + /// The exported file names one marketplace - the one this account is registered with - because that is all + /// the format holds: a single <c>locale_code</c> alongside a single device registration. audible-cli switches + /// marketplaces on its own from those same tokens, so nothing is lost to it. Any additional marketplaces + /// Libation reads for this account are its own bookkeeping and have no slot here. + /// </summary> public static Mkb79Auth FromAccount(Account account) => new() { - AccessToken = account.IdentityTokens?.ExistingAccessToken.TokenValue, - ActivationBytes = string.IsNullOrEmpty(account.DecryptKey) ? null : account.DecryptKey, - AdpToken = account.IdentityTokens?.AdpToken?.Value, + AccessToken = account.IdentityTokens?.ExistingAccessToken.Reveal(), + ActivationBytes = account.DecryptKey.HasValue ? account.DecryptKey.Reveal() : null, + AdpToken = account.IdentityTokens?.AdpToken?.Reveal(), CustomerInfo = new CustomerInfo { AccountPool = "Amazon", @@ -256,13 +263,14 @@ public static Mkb79Auth FromAccount(Account account) DeviceSerialNumber = account.IdentityTokens?.DeviceSerialNumber, DeviceType = account.IdentityTokens?.DeviceType, }, - DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Value, + DevicePrivateKey = account.IdentityTokens?.PrivateKey?.Reveal(), AccessTokenExpires = account.IdentityTokens?.ExistingAccessToken.Expires ?? default, LocaleCode = account.Locale?.CountryCode, WithUsername = account.Locale?.WithUsername ?? false, - RefreshToken = account.IdentityTokens?.RefreshToken?.Value, - StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie, - WebsiteCookies = new(account.IdentityTokens?.Cookies ?? []), + RefreshToken = account.IdentityTokens?.RefreshToken?.Reveal(), + StoreAuthenticationCookie = account.IdentityTokens?.StoreAuthenticationCookie.Reveal(), + WebsiteCookies = new(account.IdentityTokens?.Cookies + .Select(c => new KeyValuePair<string, string?>(c.Key, c.Value.Reveal())) ?? []), }; } diff --git a/Source/AudibleUtilities/Mkb79AuthImporter.cs b/Source/AudibleUtilities/Mkb79AuthImporter.cs index 82b21ccb5..a4227dbaf 100644 --- a/Source/AudibleUtilities/Mkb79AuthImporter.cs +++ b/Source/AudibleUtilities/Mkb79AuthImporter.cs @@ -1,4 +1,4 @@ -using System.Linq; +using System; using System.Threading.Tasks; namespace AudibleUtilities; @@ -10,10 +10,37 @@ public enum Mkb79ImportOutcome InvalidFile, } -public sealed record Mkb79ImportResult(Mkb79ImportOutcome Outcome, Account? Account = null, string? Message = null); +/// <param name="ClaimedBy"> +/// For <see cref="Mkb79ImportOutcome.DuplicateAccount"/>, the account already scanning that marketplace. It may +/// be one registered with it, or one carrying it as an additional marketplace. +/// </param> +public sealed record Mkb79ImportResult( + Mkb79ImportOutcome Outcome, + Account? Account = null, + string? Message = null, + Account? ClaimedBy = null); public static class Mkb79AuthImporter { + /// <summary> + /// Why a duplicate import was refused, in the same words everywhere it is refused. Naming the account that + /// already reads the marketplace matters now that it need not be a row registered with it - it may be one + /// reading it as an additional marketplace, which is not obvious from the accounts grid. + /// </summary> + public static string DuplicateMessage(Mkb79ImportResult result) + { + var locale = result.Account?.Locale?.Name ?? "[unknown]"; + + if (result.ClaimedBy is { } claimedBy && claimedBy.Locale?.Name != locale) + return $"The '{locale}' marketplace is already scanned by the account " + + $"{AccountCredentialStatus.FormatAccountLabel(claimedBy)}, as an additional marketplace. " + + "Nothing was imported."; + + return "An account with that account id and country already exists." + + $"{Environment.NewLine}Account ID: {result.Account?.AccountId}" + + $"{Environment.NewLine}Country: {locale}"; + } + /// <summary> /// Deserialize mkb79/audible-cli JSON, refresh tokens, and add the account if not already present. /// </summary> @@ -32,11 +59,12 @@ public static async Task<Mkb79ImportResult> ImportFromJsonTextAsync(string jsonT using var persister = AudibleApiStorage.GetAccountsSettingsPersister(); - if (persister.AccountsSettings.Accounts.Any(a => - a.AccountId == account.AccountId && a.IdentityTokens?.Locale.Name == account.Locale?.Name)) - { - return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account); - } + // An mkb79 file names one marketplace, and a marketplace can only be scanned by one account. Ask about + // every claim on it, not just registrations: an existing account may already be reading this marketplace + // as an additional one, in which case importing would scan it twice. + var claimedBy = persister.AccountsSettings.GetAccountClaimingMarketplace(account.AccountId, account.Locale?.Name); + if (claimedBy is not null) + return new Mkb79ImportResult(Mkb79ImportOutcome.DuplicateAccount, account, ClaimedBy: claimedBy); persister.AccountsSettings.Add(account); return new Mkb79ImportResult(Mkb79ImportOutcome.Success, account); diff --git a/Source/DataLayer.Postgres/DataLayer.Postgres.csproj b/Source/DataLayer.Postgres/DataLayer.Postgres.csproj index 1248a584e..f71f965c0 100644 --- a/Source/DataLayer.Postgres/DataLayer.Postgres.csproj +++ b/Source/DataLayer.Postgres/DataLayer.Postgres.csproj @@ -9,11 +9,11 @@ <OutputType>Library</OutputType> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> @@ -35,4 +35,8 @@ <Folder Include="Migrations\" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + </Project> diff --git a/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.Designer.cs new file mode 100644 index 000000000..445746a2f --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.Designer.cs @@ -0,0 +1,539 @@ +// <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("20260814170831_AddDownloadHistory")] + partial class AddDownloadHistory + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ContentType") + .HasColumnType("integer"); + + b.Property<DateTime?>("DatePublished") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Description") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("PictureId") + .HasColumnType("text"); + + b.Property<string>("PictureLarge") + .HasColumnType("text"); + + b.Property<string>("Subtitle") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId")); + + b.Property<string>("AudibleProductId") + .HasColumnType("text"); + + b.Property<long>("Bytes") + .HasColumnType("bigint"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property<int>("BookId") + .HasColumnType("integer"); + + b.Property<bool>("AbsentFromLastScan") + .HasColumnType("boolean"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("DateAdded") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime?>("IncludedUntil") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + 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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + 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 + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.cs b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.cs new file mode 100644 index 000000000..14e14f885 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260814170831_AddDownloadHistory.cs @@ -0,0 +1,43 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + /// <inheritdoc /> + public partial class AddDownloadHistory : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DownloadHistory", + columns: table => new + { + DownloadHistoryId = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + CompletedAtUtcTicks = table.Column<long>(type: "bigint", nullable: false), + AudibleProductId = table.Column<string>(type: "text", nullable: true), + IsAudiblePlus = table.Column<bool>(type: "boolean", nullable: false), + Bytes = table.Column<long>(type: "bigint", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId); + }); + + migrationBuilder.CreateIndex( + name: "IX_DownloadHistory_CompletedAtUtcTicks", + table: "DownloadHistory", + column: "CompletedAtUtcTicks"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DownloadHistory"); + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.Designer.cs new file mode 100644 index 000000000..0eaaf6d6e --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.Designer.cs @@ -0,0 +1,578 @@ +// <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("20260816160446_AddDownloadAttemptFailures")] + partial class AddDownloadAttemptFailures + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.7") + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ContentType") + .HasColumnType("integer"); + + b.Property<DateTime?>("DatePublished") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Description") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("PictureId") + .HasColumnType("text"); + + b.Property<string>("PictureLarge") + .HasColumnType("text"); + + b.Property<string>("Subtitle") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b => + { + b.Property<int>("DownloadAttemptFailureId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId")); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("AudibleProductId") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property<int>("Kind") + .HasColumnType("integer"); + + b.Property<long>("LastFailedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<string>("Reason") + .HasColumnType("text"); + + b.Property<long>("RetryAfterUtcTicks") + .HasColumnType("bigint"); + + b.HasKey("DownloadAttemptFailureId"); + + b.HasIndex("Account", "AudibleProductId") + .IsUnique(); + + b.ToTable("DownloadAttemptFailures"); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId")); + + b.Property<string>("AudibleProductId") + .HasColumnType("text"); + + b.Property<long>("Bytes") + .HasColumnType("bigint"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property<int>("BookId") + .HasColumnType("integer"); + + b.Property<bool>("AbsentFromLastScan") + .HasColumnType("boolean"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("DateAdded") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime?>("IncludedUntil") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + 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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + 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 + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.cs b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.cs new file mode 100644 index 000000000..63e0eabe7 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260816160446_AddDownloadAttemptFailures.cs @@ -0,0 +1,47 @@ +using Microsoft.EntityFrameworkCore.Migrations; +using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + /// <inheritdoc /> + public partial class AddDownloadAttemptFailures : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DownloadAttemptFailures", + columns: table => new + { + DownloadAttemptFailureId = table.Column<int>(type: "integer", nullable: false) + .Annotation("Npgsql:ValueGenerationStrategy", NpgsqlValueGenerationStrategy.IdentityByDefaultColumn), + AudibleProductId = table.Column<string>(type: "text", nullable: false), + Account = table.Column<string>(type: "text", nullable: false), + Kind = table.Column<int>(type: "integer", nullable: false), + ConsecutiveFailures = table.Column<int>(type: "integer", nullable: false), + LastFailedAtUtcTicks = table.Column<long>(type: "bigint", nullable: false), + RetryAfterUtcTicks = table.Column<long>(type: "bigint", nullable: false), + Reason = table.Column<string>(type: "text", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId); + }); + + migrationBuilder.CreateIndex( + name: "IX_DownloadAttemptFailures_Account_AudibleProductId", + table: "DownloadAttemptFailures", + columns: new[] { "Account", "AudibleProductId" }, + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DownloadAttemptFailures"); + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260826123718_AddBookCopyright.Designer.cs b/Source/DataLayer.Postgres/Migrations/20260826123718_AddBookCopyright.Designer.cs new file mode 100644 index 000000000..60861a0b9 --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260826123718_AddBookCopyright.Designer.cs @@ -0,0 +1,581 @@ +// <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("20260826123718_AddBookCopyright")] + partial class AddBookCopyright + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder + .HasAnnotation("ProductVersion", "10.0.11") + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ContentType") + .HasColumnType("integer"); + + b.Property<string>("Copyright") + .HasColumnType("text"); + + b.Property<DateTime?>("DatePublished") + .HasColumnType("timestamp without time zone"); + + b.Property<string>("Description") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("PictureId") + .HasColumnType("text"); + + b.Property<string>("PictureLarge") + .HasColumnType("text"); + + b.Property<string>("Subtitle") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Title") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("Name") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("text"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b => + { + b.Property<int>("DownloadAttemptFailureId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId")); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("AudibleProductId") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property<int>("Kind") + .HasColumnType("integer"); + + b.Property<long>("LastFailedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<string>("Reason") + .HasColumnType("text"); + + b.Property<long>("RetryAfterUtcTicks") + .HasColumnType("bigint"); + + b.HasKey("DownloadAttemptFailureId"); + + b.HasIndex("Account", "AudibleProductId") + .IsUnique(); + + b.ToTable("DownloadAttemptFailures"); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId")); + + b.Property<string>("AudibleProductId") + .HasColumnType("text"); + + b.Property<long>("Bytes") + .HasColumnType("bigint"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property<int>("BookId") + .HasColumnType("integer"); + + b.Property<bool>("AbsentFromLastScan") + .HasColumnType("boolean"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property<DateTime>("DateAdded") + .HasColumnType("timestamp without time zone"); + + b.Property<DateTime?>("IncludedUntil") + .HasColumnType("timestamp without time zone"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + 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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + 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 + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/20260826123718_AddBookCopyright.cs b/Source/DataLayer.Postgres/Migrations/20260826123718_AddBookCopyright.cs new file mode 100644 index 000000000..b40f6da1c --- /dev/null +++ b/Source/DataLayer.Postgres/Migrations/20260826123718_AddBookCopyright.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Postgres.Migrations +{ + /// <inheritdoc /> + public partial class AddBookCopyright : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "Copyright", + table: "Books", + type: "text", + nullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Copyright", + table: "Books"); + } + } +} diff --git a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs index 553524af2..80afe10fb 100644 --- a/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer.Postgres/Migrations/LibationContextModelSnapshot.cs @@ -17,7 +17,7 @@ protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 modelBuilder - .HasAnnotation("ProductVersion", "10.0.2") + .HasAnnotation("ProductVersion", "10.0.11") .HasAnnotation("Relational:MaxIdentifierLength", 63); NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder); @@ -52,6 +52,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<int>("ContentType") .HasColumnType("integer"); + b.Property<string>("Copyright") + .HasColumnType("text"); + b.Property<DateTime?>("DatePublished") .HasColumnType("timestamp without time zone"); @@ -201,6 +204,72 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b => + { + b.Property<int>("DownloadAttemptFailureId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadAttemptFailureId")); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("text"); + + b.Property<string>("AudibleProductId") + .IsRequired() + .HasColumnType("text"); + + b.Property<int>("ConsecutiveFailures") + .HasColumnType("integer"); + + b.Property<int>("Kind") + .HasColumnType("integer"); + + b.Property<long>("LastFailedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<string>("Reason") + .HasColumnType("text"); + + b.Property<long>("RetryAfterUtcTicks") + .HasColumnType("bigint"); + + b.HasKey("DownloadAttemptFailureId"); + + b.HasIndex("Account", "AudibleProductId") + .IsUnique(); + + b.ToTable("DownloadAttemptFailures"); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("integer"); + + NpgsqlPropertyBuilderExtensions.UseIdentityByDefaultColumn(b.Property<int>("DownloadHistoryId")); + + b.Property<string>("AudibleProductId") + .HasColumnType("text"); + + b.Property<long>("Bytes") + .HasColumnType("bigint"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("bigint"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("boolean"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + modelBuilder.Entity("DataLayer.LibraryBook", b => { b.Property<int>("BookId") diff --git a/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj b/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj index 277f265b2..5002820e0 100644 --- a/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj +++ b/Source/DataLayer.Sqlite/DataLayer.Sqlite.csproj @@ -9,11 +9,11 @@ <OutputType>Library</OutputType> </PropertyGroup> <ItemGroup> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> @@ -31,4 +31,8 @@ <ProjectReference Include="..\DataLayer\DataLayer.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + </Project> diff --git a/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.Designer.cs new file mode 100644 index 000000000..bd84c5350 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.Designer.cs @@ -0,0 +1,520 @@ +// <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("20260814170839_AddDownloadHistory")] + partial class AddDownloadHistory + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + 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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("ContentType") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("DatePublished") + .HasColumnType("TEXT"); + + b.Property<string>("Description") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PictureId") + .HasColumnType("TEXT"); + + b.Property<string>("PictureLarge") + .HasColumnType("TEXT"); + + b.Property<string>("Subtitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property<long>("Bytes") + .HasColumnType("INTEGER"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property<int>("BookId") + .HasColumnType("INTEGER"); + + b.Property<bool>("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("IncludedUntil") + .HasColumnType("TEXT"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + 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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + 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 + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.cs b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.cs new file mode 100644 index 000000000..18de3fc7c --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260814170839_AddDownloadHistory.cs @@ -0,0 +1,42 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// <inheritdoc /> + public partial class AddDownloadHistory : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DownloadHistory", + columns: table => new + { + DownloadHistoryId = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + CompletedAtUtcTicks = table.Column<long>(type: "INTEGER", nullable: false), + AudibleProductId = table.Column<string>(type: "TEXT", nullable: true), + IsAudiblePlus = table.Column<bool>(type: "INTEGER", nullable: false), + Bytes = table.Column<long>(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_DownloadHistory", x => x.DownloadHistoryId); + }); + + migrationBuilder.CreateIndex( + name: "IX_DownloadHistory_CompletedAtUtcTicks", + table: "DownloadHistory", + column: "CompletedAtUtcTicks"); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DownloadHistory"); + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.Designer.cs new file mode 100644 index 000000000..35a9fda55 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.Designer.cs @@ -0,0 +1,557 @@ +// <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("20260816160439_AddDownloadAttemptFailures")] + partial class AddDownloadAttemptFailures + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.7"); + + 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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("ContentType") + .HasColumnType("INTEGER"); + + b.Property<DateTime?>("DatePublished") + .HasColumnType("TEXT"); + + b.Property<string>("Description") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PictureId") + .HasColumnType("TEXT"); + + b.Property<string>("PictureLarge") + .HasColumnType("TEXT"); + + b.Property<string>("Subtitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b => + { + b.Property<int>("DownloadAttemptFailureId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AudibleProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("ConsecutiveFailures") + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<long>("LastFailedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<string>("Reason") + .HasColumnType("TEXT"); + + b.Property<long>("RetryAfterUtcTicks") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadAttemptFailureId"); + + b.HasIndex("Account", "AudibleProductId") + .IsUnique(); + + b.ToTable("DownloadAttemptFailures"); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property<long>("Bytes") + .HasColumnType("INTEGER"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property<int>("BookId") + .HasColumnType("INTEGER"); + + b.Property<bool>("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("IncludedUntil") + .HasColumnType("TEXT"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + 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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + 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 + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.cs b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.cs new file mode 100644 index 000000000..69eca2ba0 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260816160439_AddDownloadAttemptFailures.cs @@ -0,0 +1,46 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// <inheritdoc /> + public partial class AddDownloadAttemptFailures : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "DownloadAttemptFailures", + columns: table => new + { + DownloadAttemptFailureId = table.Column<int>(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + AudibleProductId = table.Column<string>(type: "TEXT", nullable: false), + Account = table.Column<string>(type: "TEXT", nullable: false), + Kind = table.Column<int>(type: "INTEGER", nullable: false), + ConsecutiveFailures = table.Column<int>(type: "INTEGER", nullable: false), + LastFailedAtUtcTicks = table.Column<long>(type: "INTEGER", nullable: false), + RetryAfterUtcTicks = table.Column<long>(type: "INTEGER", nullable: false), + Reason = table.Column<string>(type: "TEXT", nullable: true) + }, + constraints: table => + { + table.PrimaryKey("PK_DownloadAttemptFailures", x => x.DownloadAttemptFailureId); + }); + + migrationBuilder.CreateIndex( + name: "IX_DownloadAttemptFailures_Account_AudibleProductId", + table: "DownloadAttemptFailures", + columns: new[] { "Account", "AudibleProductId" }, + unique: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "DownloadAttemptFailures"); + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/20260826123725_AddBookCopyright.Designer.cs b/Source/DataLayer.Sqlite/Migrations/20260826123725_AddBookCopyright.Designer.cs new file mode 100644 index 000000000..c3c9e6305 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260826123725_AddBookCopyright.Designer.cs @@ -0,0 +1,560 @@ +// <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("20260826123725_AddBookCopyright")] + partial class AddBookCopyright + { + /// <inheritdoc /> + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); + + 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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("ContentType") + .HasColumnType("INTEGER"); + + b.Property<string>("Copyright") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("DatePublished") + .HasColumnType("TEXT"); + + b.Property<string>("Description") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("PictureId") + .HasColumnType("TEXT"); + + b.Property<string>("PictureLarge") + .HasColumnType("TEXT"); + + b.Property<string>("Subtitle") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("Title") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("Name") + .IsRequired() + .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") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("ContributorId"); + + b.HasIndex("Name"); + + b.ToTable("Contributors"); + + b.HasData( + new + { + ContributorId = -1, + Name = "" + }); + }); + + modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b => + { + b.Property<int>("DownloadAttemptFailureId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AudibleProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("ConsecutiveFailures") + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<long>("LastFailedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<string>("Reason") + .HasColumnType("TEXT"); + + b.Property<long>("RetryAfterUtcTicks") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadAttemptFailureId"); + + b.HasIndex("Account", "AudibleProductId") + .IsUnique(); + + b.ToTable("DownloadAttemptFailures"); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property<long>("Bytes") + .HasColumnType("INTEGER"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + + modelBuilder.Entity("DataLayer.LibraryBook", b => + { + b.Property<int>("BookId") + .HasColumnType("INTEGER"); + + b.Property<bool>("AbsentFromLastScan") + .HasColumnType("INTEGER"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<DateTime>("DateAdded") + .HasColumnType("TEXT"); + + b.Property<DateTime?>("IncludedUntil") + .HasColumnType("TEXT"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + 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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired() + .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") + .IsRequired(); + }); + + b.Navigation("Rating") + .IsRequired(); + + b.Navigation("Supplements"); + + b.Navigation("UserDefinedItem") + .IsRequired(); + }); + + 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 + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/20260826123725_AddBookCopyright.cs b/Source/DataLayer.Sqlite/Migrations/20260826123725_AddBookCopyright.cs new file mode 100644 index 000000000..b63f2b918 --- /dev/null +++ b/Source/DataLayer.Sqlite/Migrations/20260826123725_AddBookCopyright.cs @@ -0,0 +1,28 @@ +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +namespace DataLayer.Migrations +{ + /// <inheritdoc /> + public partial class AddBookCopyright : Migration + { + /// <inheritdoc /> + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.AddColumn<string>( + name: "Copyright", + table: "Books", + type: "TEXT", + nullable: true); + } + + /// <inheritdoc /> + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropColumn( + name: "Copyright", + table: "Books"); + } + } +} diff --git a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs index 8404fda1a..2b2316e7d 100644 --- a/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs +++ b/Source/DataLayer.Sqlite/Migrations/LibationContextModelSnapshot.cs @@ -15,7 +15,7 @@ partial class LibationContextModelSnapshot : ModelSnapshot protected override void BuildModel(ModelBuilder modelBuilder) { #pragma warning disable 612, 618 - modelBuilder.HasAnnotation("ProductVersion", "10.0.2"); + modelBuilder.HasAnnotation("ProductVersion", "10.0.11"); modelBuilder.Entity("CategoryCategoryLadder", b => { @@ -45,6 +45,9 @@ protected override void BuildModel(ModelBuilder modelBuilder) b.Property<int>("ContentType") .HasColumnType("INTEGER"); + b.Property<string>("Copyright") + .HasColumnType("TEXT"); + b.Property<DateTime?>("DatePublished") .HasColumnType("TEXT"); @@ -188,6 +191,68 @@ protected override void BuildModel(ModelBuilder modelBuilder) }); }); + modelBuilder.Entity("DataLayer.DownloadAttemptFailure", b => + { + b.Property<int>("DownloadAttemptFailureId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("Account") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<string>("AudibleProductId") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property<int>("ConsecutiveFailures") + .HasColumnType("INTEGER"); + + b.Property<int>("Kind") + .HasColumnType("INTEGER"); + + b.Property<long>("LastFailedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<string>("Reason") + .HasColumnType("TEXT"); + + b.Property<long>("RetryAfterUtcTicks") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadAttemptFailureId"); + + b.HasIndex("Account", "AudibleProductId") + .IsUnique(); + + b.ToTable("DownloadAttemptFailures"); + }); + + modelBuilder.Entity("DataLayer.DownloadHistory", b => + { + b.Property<int>("DownloadHistoryId") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property<string>("AudibleProductId") + .HasColumnType("TEXT"); + + b.Property<long>("Bytes") + .HasColumnType("INTEGER"); + + b.Property<long>("CompletedAtUtcTicks") + .HasColumnType("INTEGER"); + + b.Property<bool>("IsAudiblePlus") + .HasColumnType("INTEGER"); + + b.HasKey("DownloadHistoryId"); + + b.HasIndex("CompletedAtUtcTicks"); + + b.ToTable("DownloadHistory"); + }); + modelBuilder.Entity("DataLayer.LibraryBook", b => { b.Property<int>("BookId") diff --git a/Source/DataLayer/Configurations/DownloadAttemptFailureConfig.cs b/Source/DataLayer/Configurations/DownloadAttemptFailureConfig.cs new file mode 100644 index 000000000..b4ceb1001 --- /dev/null +++ b/Source/DataLayer/Configurations/DownloadAttemptFailureConfig.cs @@ -0,0 +1,16 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations; + +internal class DownloadAttemptFailureConfig : IEntityTypeConfiguration<DownloadAttemptFailure> +{ + public void Configure(EntityTypeBuilder<DownloadAttemptFailure> entity) + { + entity.HasKey(f => f.DownloadAttemptFailureId); + + // One row per title per account, upserted on each failure: the point is to remember the latest + // verdict, not to accumulate a history. + entity.HasIndex(f => new { f.Account, f.AudibleProductId }).IsUnique(); + } +} diff --git a/Source/DataLayer/Configurations/DownloadHistoryConfig.cs b/Source/DataLayer/Configurations/DownloadHistoryConfig.cs new file mode 100644 index 000000000..d09792c10 --- /dev/null +++ b/Source/DataLayer/Configurations/DownloadHistoryConfig.cs @@ -0,0 +1,15 @@ +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Metadata.Builders; + +namespace DataLayer.Configurations; + +internal class DownloadHistoryConfig : IEntityTypeConfiguration<DownloadHistory> +{ + public void Configure(EntityTypeBuilder<DownloadHistory> entity) + { + entity.HasKey(dh => dh.DownloadHistoryId); + + // Every read is a range query over the rolling window, and every write prunes by age. + entity.HasIndex(dh => dh.CompletedAtUtcTicks); + } +} diff --git a/Source/DataLayer/DataLayer.csproj b/Source/DataLayer/DataLayer.csproj index a39c918b4..81f5bcfc9 100644 --- a/Source/DataLayer/DataLayer.csproj +++ b/Source/DataLayer/DataLayer.csproj @@ -11,15 +11,15 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Dinah.Core" Version="10.2.0.1" /> - <PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.0.1" /> + <PackageReference Include="Dinah.Core" Version="11.0.0.1" /> + <PackageReference Include="Dinah.EntityFrameworkCore" Version="11.0.0.1" /> <PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" /> - <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.11" /> + <PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets> </PackageReference> - <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.7"> + <PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.11"> <PrivateAssets>all</PrivateAssets> <IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets> </PackageReference> @@ -35,6 +35,10 @@ </None> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <DebugType>embedded</DebugType> </PropertyGroup> diff --git a/Source/DataLayer/EfClasses/Book.cs b/Source/DataLayer/EfClasses/Book.cs index 52801dbd9..084e6a40f 100644 --- a/Source/DataLayer/EfClasses/Book.cs +++ b/Source/DataLayer/EfClasses/Book.cs @@ -52,6 +52,9 @@ public class Book public bool IsSpatial { get; private set; } public DateTime? DatePublished { get; private set; } public string? Language { get; private set; } + /// <summary>The publisher's copyright line. Only returned by the API's product_details response + /// group, and null for plenty of titles even then.</summary> + public string? Copyright { get; private set; } // is owned, not optional 1:1 public UserDefinedItem UserDefinedItem { get; private set; } @@ -119,6 +122,13 @@ public void UpdateTitle(string? title, string? subtitle) public void UpdateLengthInMinutes(int lengthInMinutes) => LengthInMinutes = lengthInMinutes; + public void UpdateDescription(string? description) + { + // don't overwrite with default values + if (!string.IsNullOrWhiteSpace(description)) + Description = description.Trim(); + } + #region contributors, authors, narrators internal HashSet<BookContributor> ContributorsLink { get; private set; } @@ -232,31 +242,67 @@ public void UpsertSeries(Series series, string? order, DbContext? context = null private readonly HashSet<Supplement>? _supplements; public IEnumerable<Supplement> Supplements => _supplements?.ToList() ?? []; - public void AddSupplementDownloadUrl(string url) + /// <summary> + /// Records where Audible says this book's supplement can be found, replacing anything recorded before. + /// <para> + /// Audible reports one supplement per title, so this replaces rather than accumulates. It used to add, and + /// guarded against duplicates by comparing the incoming url to itself - which came to the same thing only + /// because the always-true comparison meant "this book already has a supplement", so a url that changed was + /// silently ignored. + /// </para> + /// </summary> + public void SetSupplementDownloadUrl(string url) { // supplements are owned by Book, so no need to Load(): // Are automatically loaded, and can only be tracked by a DbContext alongside their owner. ArgumentValidator.EnsureNotNullOrWhiteSpace(url, nameof(url)); - if (_supplements?.Any(s => url.EqualsInsensitive(url)) is true) + if (_supplements is null) + return; + + if (_supplements.Count == 1 && _supplements.First().Url.EqualsInsensitive(url)) return; - _supplements?.Add(new Supplement(this, url)); + _supplements.Clear(); + _supplements.Add(new Supplement(this, url)); + + // Only when the book had no supplement at all. A url Audible has changed says nothing about whether the + // file behind it is already downloaded. UserDefinedItem.PdfStatus ??= LiberatedStatus.NotLiberated; } + + /// <summary> + /// Forgets that this book has a supplement, for when Audible says it has none after all. + /// <para> + /// The status goes back to null rather than to NotLiberated, because null is what "this title has no PDF" + /// is recorded as everywhere else: it is the state of a book that never had one, and it is what stops the + /// grid from showing a PDF marker and the counts from expecting one. + /// </para> + /// </summary> + /// <returns>Whether anything was removed.</returns> + public bool RemoveSupplements() + { + if (_supplements is null || _supplements.Count == 0) + return false; + + _supplements.Clear(); + UserDefinedItem.PdfStatus = null; + return true; + } #endregion public void UpdateProductRating(float overallRating, float performanceRating, float storyRating) => Rating.Update(overallRating, performanceRating, storyRating); - public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language) + public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string? language, string? copyright = null) { // don't overwrite with default values IsAbridged |= isAbridged; IsSpatial = isSpatial ?? IsSpatial; DatePublished = datePublished ?? DatePublished; Language = language?.Trim().FirstCharToUpper() ?? Language; + Copyright = copyright?.Trim() ?? Copyright; } public override string ToString() => $"[{AudibleProductId}] {TitleWithSubtitle}"; diff --git a/Source/DataLayer/EfClasses/Contributor.cs b/Source/DataLayer/EfClasses/Contributor.cs index baa01fe69..bd8836e87 100644 --- a/Source/DataLayer/EfClasses/Contributor.cs +++ b/Source/DataLayer/EfClasses/Contributor.cs @@ -16,11 +16,12 @@ public class Contributor // id.s // ---- - // https://www.audible.com/author/Neil-Gaiman/B000AQ01G2 == https://www.audible.com/author/B000AQ01G2 + // the name in the path is decorative; the id is what resolves it. Both of these reach the same page: + // https://www.audible.com/author/Robert-Bevan/B0EXAMPLE1 == https://www.audible.com/author/B0EXAMPLE1 // goes to summary page - // at bottom "See all titles by Neil Gaiman" goes to https://www.audible.com/search?searchAuthor=Neil+Gaiman + // at bottom "See all titles by Robert Bevan" goes to https://www.audible.com/search?searchAuthor=Robert+Bevan // some authors have no id. simply goes to https://www.audible.com/search?searchAuthor=Rufus+Fears - // all narrators have no id: https://www.audible.com/search?searchNarrator=Neil+Gaiman + // all narrators have no id: https://www.audible.com/search?searchNarrator=Robert+Bevan internal int ContributorId { get; private set; } public string Name { get; private set; } diff --git a/Source/DataLayer/EfClasses/DownloadAttemptFailure.cs b/Source/DataLayer/EfClasses/DownloadAttemptFailure.cs new file mode 100644 index 000000000..a9d7ec774 --- /dev/null +++ b/Source/DataLayer/EfClasses/DownloadAttemptFailure.cs @@ -0,0 +1,98 @@ +using System; + +namespace DataLayer; + +/// <summary> +/// Why a download attempt failed, coarse enough to choose how long to wait before trying again. +/// Persisted as an int; do not renumber. +/// </summary> +public enum DownloadFailureKind +{ + /// <summary> + /// Audible refused a content license and named an eligibility reason: the title is not owned, is not in + /// the Plus catalog, or the account is not entitled to it. Changes only when the account or the catalog + /// changes, so this is worth waiting a long time on. + /// </summary> + LicenseDenied = 0, + + /// <summary> + /// Audible accepted the request but has no downloadable asset, as for a preorder that has not been + /// released. Expected to start working by itself once the title is published. + /// </summary> + AssetUnavailable = 1, + + /// <summary> + /// Looks like a service interruption or throttling rather than a decision about this title. Retried soon. + /// </summary> + ServiceInterruption = 2, +} + +/// <summary> +/// The most recent failed attempt to download one title, so that a title Audible has just refused is not +/// requested again on every run. One row per (account, title): the same ASIN can be refused on one account +/// and downloadable on another. +/// <para> +/// Nothing here is permanent. <see cref="RetryAfterUtcTicks"/> always names a time, so a title held back +/// because of an outage, throttling or an unreleased preorder starts being attempted again on its own. +/// </para> +/// <para> +/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker, +/// LibationFiles is a throwaway directory inside the container and only the database is on a volume, so a +/// file-based record would forget every failure on each container start - exactly the case this fixes. +/// </para> +/// </summary> +public class DownloadAttemptFailure +{ + internal int DownloadAttemptFailureId { get; private set; } + + public string AudibleProductId { get; private set; } + + /// <summary>The <see cref="LibraryBook.Account"/> the attempt was made with.</summary> + public string Account { get; private set; } + + public DownloadFailureKind Kind { get; private set; } + + /// <summary>Failures in a row without an intervening success. Drives how long the next wait is.</summary> + public int ConsecutiveFailures { get; private set; } + + /// <summary> + /// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL. + /// Local time is for display only. + /// </summary> + public long LastFailedAtUtcTicks { get; private set; } + + /// <summary>When this title becomes eligible for another automatic attempt, in UTC ticks.</summary> + public long RetryAfterUtcTicks { get; private set; } + + /// <summary>One line from Audible, kept so the user can be told why without re-requesting a license.</summary> + public string? Reason { get; private set; } + + public DateTimeOffset LastFailedAt => new(LastFailedAtUtcTicks, TimeSpan.Zero); + public DateTimeOffset RetryAfter => new(RetryAfterUtcTicks, TimeSpan.Zero); + + private DownloadAttemptFailure() + { + // for EF + AudibleProductId = null!; + Account = null!; + } + + public DownloadAttemptFailure(string audibleProductId, string account, DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason) + { + AudibleProductId = audibleProductId; + Account = account; + Record(kind, consecutiveFailures, lastFailedAt, retryAfter, reason); + } + + public void Record(DownloadFailureKind kind, int consecutiveFailures, DateTimeOffset lastFailedAt, DateTimeOffset retryAfter, string? reason) + { + Kind = kind; + ConsecutiveFailures = consecutiveFailures; + LastFailedAtUtcTicks = lastFailedAt.UtcTicks; + RetryAfterUtcTicks = retryAfter.UtcTicks; + Reason = reason; + } + + public override string ToString() + => $"{AudibleProductId} {Kind} x{ConsecutiveFailures}, retry after {RetryAfter.ToLocalTime()}"; +} diff --git a/Source/DataLayer/EfClasses/DownloadHistory.cs b/Source/DataLayer/EfClasses/DownloadHistory.cs new file mode 100644 index 000000000..f64f2e231 --- /dev/null +++ b/Source/DataLayer/EfClasses/DownloadHistory.cs @@ -0,0 +1,45 @@ +using System; + +namespace DataLayer; + +/// <summary> +/// One successful audiobook download, recorded so the opt-in daily download limit survives restarts. +/// Recorded for every download regardless of whether a limit is configured: a user who turns the limit +/// on after a heavy session gets a limit that reflects what actually happened. +/// <para> +/// The database is deliberately the home for this instead of a file under LibationFiles: in Docker, +/// LibationFiles is a throwaway directory inside the container and only the database is on a volume. +/// </para> +/// </summary> +public class DownloadHistory +{ + internal int DownloadHistoryId { get; private set; } + + /// <summary> + /// UTC ticks rather than a DateTime so range queries mean the same thing on SQLite and PostgreSQL, and so a + /// window that spans a DST change stays exactly 24 hours. Local time is for display only. + /// </summary> + public long CompletedAtUtcTicks { get; private set; } + + public string? AudibleProductId { get; private set; } + + public bool IsAudiblePlus { get; private set; } + + /// <summary>Size on disk of the files written to the Books directory for this title.</summary> + public long Bytes { get; private set; } + + public DateTimeOffset CompletedAt => new(CompletedAtUtcTicks, TimeSpan.Zero); + + private DownloadHistory() { } + + public DownloadHistory(DateTimeOffset completedAt, string? audibleProductId, bool isAudiblePlus, long bytes) + { + CompletedAtUtcTicks = completedAt.UtcTicks; + AudibleProductId = audibleProductId; + IsAudiblePlus = isAudiblePlus; + Bytes = bytes; + } + + public override string ToString() + => $"{AudibleProductId} {CompletedAt.ToLocalTime()} {(IsAudiblePlus ? "Plus" : "owned")} {Bytes} bytes"; +} diff --git a/Source/DataLayer/LibationContext.cs b/Source/DataLayer/LibationContext.cs index 26d99e7c9..2d88f2469 100644 --- a/Source/DataLayer/LibationContext.cs +++ b/Source/DataLayer/LibationContext.cs @@ -26,6 +26,8 @@ public class LibationContext : DbContext, INotifyDisposed public DbSet<Series> Series { get; private set; } public DbSet<Category> Categories { get; private set; } public DbSet<CategoryLadder> CategoryLadders { get; private set; } + public DbSet<DownloadHistory> DownloadHistory { get; private set; } + public DbSet<DownloadAttemptFailure> DownloadAttemptFailures { get; private set; } public event EventHandler? ObjectDisposed; public override void Dispose() @@ -56,6 +58,8 @@ protected override void OnModelCreating(ModelBuilder modelBuilder) modelBuilder.ApplyConfiguration(new CategoryConfig()); modelBuilder.ApplyConfiguration(new CategoryLadderConfig()); modelBuilder.ApplyConfiguration(new BookCategoryConfig()); + modelBuilder.ApplyConfiguration(new DownloadHistoryConfig()); + modelBuilder.ApplyConfiguration(new DownloadAttemptFailureConfig()); // views are now supported via "keyless entity types" (instead of "entity types" or the prev "query types"): // https://docs.microsoft.com/en-us/ef/core/modeling/keyless-entity-types diff --git a/Source/DataLayer/MockLibraryBook.cs b/Source/DataLayer/MockLibraryBook.cs index e37f91b59..444498b8b 100644 --- a/Source/DataLayer/MockLibraryBook.cs +++ b/Source/DataLayer/MockLibraryBook.cs @@ -89,6 +89,7 @@ public static MockLibraryBook CreateBook( bool isAbridged = false, bool isSpatial = false, string language = "English", + string? copyright = null, LiberatedStatus bookStatus = LiberatedStatus.Liberated, LiberatedStatus? pdfStatus = null, AudioFormat? lastDlFormat = null, @@ -106,12 +107,14 @@ [new Contributor(firstNarrator, CalculateAsin(firstNarrator))], localeName); lastDlFormat ??= new AudioFormat(Codec.AAC_LC, 128, 44100, 2); - lastDlVersion ??= new Version(13, 0); + // Three fields, like a real Libation version: Extensions.ToVersionString formats to at least three, + // so a two-field default throws as soon as anything renders a naming template for a mock book. + lastDlVersion ??= new Version(13, 0, 0); book.UserDefinedItem.SetLastDownloaded(lastDlVersion, lastDlFormat, "1"); book.UserDefinedItem.PdfStatus = pdfStatus; book.UserDefinedItem.BookStatus = bookStatus; - book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language); + book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language, copyright); return new MockLibraryBook( book, diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 88f310f3e..7d90fff0a 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -69,6 +69,17 @@ public List<LibraryBook> GetDeletedLibraryBooks() .getLibrary() .ToList(); + /// <summary> + /// How many books a full search re-index writes: the same rows as + /// <see cref="GetLibrary_Flat_NoTracking"/> without the podcast parents, which are stripped before + /// indexing. A row count only; no entities are loaded, so this is cheap enough to check the index against. + /// </summary> + public int GetIndexableBookCount() + => context + .LibraryBooks + .AsNoTracking() + .Count(lb => !lb.IsDeleted && lb.Book.ContentType != ContentType.Parent); + /// <summary>Counts <see cref="LibraryBook"/> rows by <see cref="LibraryBook.IsDeleted"/> (no related entities loaded).</summary> public (int NotInTrash, int InTrash) GetLibraryBookCountsByTrashFlag() { @@ -108,6 +119,16 @@ public IEnumerable<LibraryBook> FindOrphanedEpisodes() .ParentedEpisodes() .Select(ge => ge.Book.AudibleProductId), ge => ge.Book.AudibleProductId); + /// <summary> + /// Books displayed as top-level rows: ordinary products plus episodes whose series parent + /// is not in the database. Showing orphaned episodes here keeps a successfully imported + /// title discoverable instead of silently hiding it from both grids. + /// </summary> + public IEnumerable<LibraryBook> StandaloneBooks() + => libraryBooks + .Where(lb => lb.Book.IsProduct()) + .Concat(libraryBooks.FindOrphanedEpisodes()); + public IEnumerable<LibraryBook> FindChildren(LibraryBook parent) => libraryBooks.Where(lb => lb.Book.IsEpisodeChild() && lb.HasSeriesId(parent.Book.AudibleProductId)); diff --git a/Source/DtoImporterService/BookImporter.cs b/Source/DtoImporterService/BookImporter.cs index b1966de87..e253e2f7a 100644 --- a/Source/DtoImporterService/BookImporter.cs +++ b/Source/DtoImporterService/BookImporter.cs @@ -124,7 +124,7 @@ var narrators new AudibleProductId(item.ProductId), item.Title, item.Subtitle, - item.Description, + item.PlainTextDescription(), item.LengthInMinutes, contentType, authors, @@ -157,12 +157,54 @@ var narrators book.ReplacePublisher(publisher); } - if (item.PdfUrl is not null) - book.AddSupplementDownloadUrl(item.PdfUrl.ToString()); + syncSupplement(item, book); return book; } + /// <summary> + /// Brings a book's stored description into line with what this scan says about it. + /// <para> + /// The description used to be stored exactly as Audible sends it, which is HTML. It is flattened on the + /// way in now, so every book imported before that change is still holding markup and needs rewriting - + /// hence updating a description at all, which import never used to do. + /// </para> + /// <para> + /// A scan that reports no description leaves the stored one alone. Episodes are imported from the + /// catalog, and "no summary" there can just as easily mean the response group was not asked for. + /// </para> + /// </summary> + internal static void syncDescription(Item item, Book book) + => book.UpdateDescription(item.PlainTextDescription()); + + /// <summary> + /// Brings a book's supplement into line with what this scan says about it. + /// <para> + /// Only new books used to get this far, so a title that gained a PDF after it was first imported never got + /// one, and a title that lost its PDF went on claiming one - and, until issue #1973, went on being asked + /// for on every run. + /// </para> + /// <para> + /// A supplement is dropped only when Audible says outright that no supplement url is available. A missing + /// url says nothing on its own: episodes are imported from the catalog, which is never asked for + /// <c>pdf_url</c>, so there "no url" means "not asked" rather than "none exists". A PDF already downloaded + /// is left alone either way, because the file is on disk and the library should go on saying so. + /// </para> + /// </summary> + internal static void syncSupplement(Item item, Book book) + { + if (item.PdfUrl is not null) + book.SetSupplementDownloadUrl(item.PdfUrl.ToString()); + else if (item.IsPdfUrlAvailable is false + && book.UserDefinedItem.PdfStatus is not LiberatedStatus.Liberated + && book.RemoveSupplements()) + { + Serilog.Log.Logger.Information( + "Audible no longer offers a supplement for {audibleProductId}, so Libation has stopped expecting one.", + book.AudibleProductId); + } + } + private void updateBook(ImportItem importItem, Book book) { var item = importItem.DtoItem; @@ -179,6 +221,8 @@ private void updateBook(ImportItem importItem, Book book) // Update the book titles, since formatting can change book.UpdateTitle(item.Title, item.Subtitle); + syncDescription(item, book); + // set/update book-specific info which may have changed if (item.PictureId is not null) book.PictureId = item.PictureId; @@ -193,7 +237,11 @@ private void updateBook(ImportItem importItem, Book book) // updateBook must update language on books which were imported before the migration which added language. // 2025-07-30 // updateBook must update isSpatial on books which were imported before the migration which added isSpatial. - book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language); + // 2026-08-26 + // updateBook must update copyright on books which were imported before the migration which added copyright. + book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language, item.CopyrightString()); + + syncSupplement(item, book); book.UpdateProductRating( (float)(item.Rating?.OverallDistribution?.AverageRating ?? 0), diff --git a/Source/DtoImporterService/DtoImporterService.csproj b/Source/DtoImporterService/DtoImporterService.csproj index dd96a6bf1..bbde7f251 100644 --- a/Source/DtoImporterService/DtoImporterService.csproj +++ b/Source/DtoImporterService/DtoImporterService.csproj @@ -18,4 +18,8 @@ <ProjectReference Include="..\DataLayer\DataLayer.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + </Project> diff --git a/Source/DtoImporterService/_InternalsVisible.cs b/Source/DtoImporterService/_InternalsVisible.cs new file mode 100644 index 000000000..4d47d1608 --- /dev/null +++ b/Source/DtoImporterService/_InternalsVisible.cs @@ -0,0 +1,4 @@ +// ApplicationServices.Tests rather than a DtoImporterService.Tests of its own: this project has no test +// assembly, and ApplicationServices references it, so its tests can already see the importers. A literal +// because the dependency runs the other way, leaving nothing here to take the name from. +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo("ApplicationServices.Tests")] diff --git a/Source/FileLiberator/AudioFileStorageExt.cs b/Source/FileLiberator/AudioFileStorageExt.cs index 965cff4b5..9d08f9458 100644 --- a/Source/FileLiberator/AudioFileStorageExt.cs +++ b/Source/FileLiberator/AudioFileStorageExt.cs @@ -39,14 +39,15 @@ public static string GetDestinationDirectory(this AudioFileStorage _, LibraryBoo } /// <summary> - /// PDF: audio file does not exist + /// A file name from the file template, directly under the Books directory rather than in the book's own + /// folder. Only the "save a copy of the cover art" dialogs use this, and only for the name they suggest. /// </summary> public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false) => AudibleFileStorage.BooksDirectory is { } books ? Templates.File.GetFilename(libraryBook.ToDto(), books, extension, null, returnFirstExisting) : throw new InvalidOperationException("Books directory is not set."); /// <summary> - /// PDF: audio file already exists + /// A file name from the file template, in a directory the caller has already chosen. /// </summary> public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties? partProperties = null, bool returnFirstExisting = false) => partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index 4e68aa290..0bd013d5d 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -7,8 +7,10 @@ using Dinah.Core.Net.Http; using FileManager; using LibationFileManager; +using LibationFileManager.Templates; using System; using System.Collections.Generic; +using System.Globalization; using System.IO; using System.Linq; using System.Threading; @@ -16,18 +18,21 @@ namespace FileLiberator; -public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook> +public class DownloadDecryptBook : AudioDecodable, IProcessable<DownloadDecryptBook>, ILicensedDownload { public override string Name => "Download & Decrypt"; private CancellationTokenSource? cancellationTokenSource; private AudiobookDownloadBase? abDownloader; - /// <summary> - /// Optional override to supply license info directly instead of querying the api based on Configuration options - /// </summary> + /// <inheritdoc/> public DownloadOptions.LicenseInfo? LicenseInfo { get; set; } + /// <inheritdoc/> + public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; } + public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.AudioExists; + protected override bool RecordsAttemptFailures => true; + public override async Task CancelAsync() { if (abDownloader is not null) await abDownloader.CancelAsync(); @@ -39,6 +44,7 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) OnBegin(libraryBook); cancellationTokenSource = new CancellationTokenSource(); var cancellationToken = cancellationTokenSource.Token; + ObtainedLicense = null; try { @@ -52,6 +58,11 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) //Processable instances are reusable, so don't set LicenseInfo //override from within a DownloadDecryptBook instance. var license = LicenseInfo ?? await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration, cancellationToken); + + // Published before the download begins so the supplement step gets it even from a run that fails + // later on: what it needs is the license, and this one is already granted. + ObtainedLicense = license; + using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration, license); var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); @@ -113,6 +124,7 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) Serilog.Log.Verbose("Updating liberated status for {@Book}", libraryBook.LogFriendly()); await libraryBook.UpdateBookStatusAsync(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion); + RecordDownloadForDailyLimit(libraryBook, result.ResultFiles); Serilog.Log.Verbose("Setting directory time for {@Book}", libraryBook.LogFriendly()); SetDirectoryTime(libraryBook, finalStorageDir); Serilog.Log.Verbose("Deleting cache files for {@Book}", libraryBook.LogFriendly()); @@ -237,31 +249,69 @@ converter.DownloadOptions is not DownloadOptions options || #endregion - tags.Title ??= options.LibraryBookDto.TitleWithSubtitle; + FillMissingTags(tags, options.LibraryBook.Book, options.LibraryBookDto, options.ContentMetadata.ContentReference, options.DrmType); + } + + /// <summary>Audible's own format for <c>rldt</c>, which Libation matches when it supplies the tag itself.</summary> + private const string ReleaseDateFormat = "dd-MMM-yyyy"; + + /// <summary> + /// Audible's ADRM (.aaxc) downloads arrive with most of these tags already written; its Widevine + /// (DASH) downloads arrive with almost none of them, so the library's own data has to stand in. + /// </summary> + internal static void FillMissingTags(Mpeg4Lib.MetadataItems tags, Book book, LibraryBookDto dto, ContentReference contentReference, DrmType drmType) + { + tags.Title ??= dto.TitleWithSubtitle; tags.Album ??= tags.Title; - tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name)); + tags.Artist ??= string.Join("; ", book.Authors.Select(a => a.Name)); tags.AlbumArtists ??= tags.Artist; - tags.Genres = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames()); - tags.ProductID ??= options.ContentMetadata.ContentReference.Sku; - tags.Comment ??= options.LibraryBook.Book.Description; + tags.Genres = string.Join(", ", book.LowestCategoryNames()); + tags.ProductID ??= contentReference.Sku; + tags.Comment ??= book.Description; tags.LongDescription ??= tags.Comment; - tags.Publisher ??= options.LibraryBook.Book.Publisher; - tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name)); - tags.Asin = options.LibraryBook.Book.AudibleProductId; - tags.Acr = options.ContentMetadata.ContentReference.Acr; - tags.Version = options.ContentMetadata.ContentReference.Version; - if (options.LibraryBook.Book.DatePublished is DateTime pubDate) + //Only the .aaxc files carry a copyright line of their own. AaxcDownloadConvertBase's fixup + //normalizes "(P)" to "℗" after this runs, so the catalog's wording lands the same way. + tags.Copyright ??= book.Copyright; + tags.Publisher ??= book.Publisher; + tags.Narrator ??= string.Join("; ", book.Narrators.Select(n => n.Name)); + tags.Asin = book.AudibleProductId; + tags.Acr = contentReference.Acr; + tags.Version = contentReference.Version; + if (book.DatePublished is DateTime pubDate) { tags.Year ??= pubDate.Year.ToString(); - tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy"); + tags.ReleaseDate ??= pubDate.ToString(ReleaseDateFormat); + + //Audible's .aaxc files frequently carry 01-Jan-2000 where the real release date belongs. + //The file's own tag is otherwise the better source - it is what Audible shipped with this + //particular recording - so it still wins everywhere except for that one placeholder value. + // + //Only the exact sentinel is overridden, and only when the catalog disagrees with it, so a + //title genuinely released on 01-Jan-2000 comes out the same either way. Two broader rules + //were considered and rejected: always preferring the catalog date would discard Audible's + //date on re-releases the catalog dates wrong, and treating any year below some floor as + //bogus has no defensible floor, since real recordings predate 2000. + if (IsPlaceholderReleaseDate(tags.ReleaseDate) && pubDate.Date != PlaceholderReleaseDate) + { + tags.ReleaseDate = pubDate.ToString(ReleaseDateFormat); + //A file carrying the placeholder rldt carries the matching placeholder year. + if (tags.Year == PlaceholderReleaseDate.Year.ToString()) + tags.Year = pubDate.Year.ToString(); + } } const string tagDomain = "org.libation"; tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ACR", tags.Acr); - tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", options.DrmType.ToString()); - tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", options.LibraryBook.Book.Locale); + tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", drmType.ToString()); + tags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", book.Locale); } + private static readonly DateTime PlaceholderReleaseDate = new(2000, 1, 1); + + internal static bool IsPlaceholderReleaseDate(string? releaseDate) + => DateTime.TryParse(releaseDate, CultureInfo.InvariantCulture, DateTimeStyles.None, out var parsed) + && parsed.Date == PlaceholderReleaseDate; + private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e) { if (Configuration.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader) @@ -310,6 +360,24 @@ var title #endregion #region Post-success routines + /// <summary> + /// Records the finished download for the daily download limit. Always recorded, even when no limit is + /// configured, so that turning the limit on later reflects downloads already performed. + /// </summary> + private static void RecordDownloadForDailyLimit(LibraryBook libraryBook, List<TempFile> movedFiles) + { + try + { + // Files have already been moved, so these paths are their final locations in the Books directory. + var bytes = movedFiles.Sum(f => File.Exists(f.FilePath) ? new FileInfo(f.FilePath).Length : 0); + DownloadHistoryStore.Record(libraryBook.Book.AudibleProductId, libraryBook.IsAudiblePlus, bytes); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Failed to measure a completed download for the daily download limit. The download itself succeeded. {@Book}", libraryBook.LogFriendly()); + } + } + /// <summary>Read the audio format from the audio file's metadata.</summary> public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile) { @@ -504,6 +572,24 @@ private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinatio try { + // Fetched before the destination is touched. A storefront that no longer lists the title answers + // with an empty product, and the file already on disk is then the better of the two. + var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); + + if (item?.SourceJson is not { } sourceJson) + { + Serilog.Log.Logger.Error("Failed to retrieve metadata from server for {@Book}.", options.LibraryBook.LogFriendly()); + return; + } + + if (CatalogProductIsEmpty(sourceJson)) + { + Serilog.Log.Logger.Warning( + "Audible's catalog holds no details for {@Book}, so its metadata file was left as it was. This happens when the title is no longer listed in the storefront of the account that owns it.", + options.LibraryBook.LogFriendly()); + return; + } + metadataPath = AudibleFileStorage.Audio.GetCustomDirFilename( options.LibraryBook, @@ -514,13 +600,6 @@ private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinatio if (File.Exists(metadataPath)) FileUtility.SaferDelete(metadataPath); - var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS); - - if (item?.SourceJson is not { } sourceJson) - { - Serilog.Log.Logger.Error("Failed to retrieve metadata from server for {@Book}.", options.LibraryBook.LogFriendly()); - return; - } sourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo)); sourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference)); @@ -537,6 +616,24 @@ private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinatio throw; } } + + /// <summary> + /// Whether a catalog product carries no details at all. + /// <para> + /// A storefront that no longer lists a title still answers a request for it: HTTP 200, total_results 1, + /// and a product holding an asin and a handful of always-returned flags. Nothing between the request and + /// the file notices, so a re-download of such a title used to replace a metadata file written while the + /// title was still listed - the only copy of that data - with the placeholder. Reported in issue #1947, + /// where a Canada-only title produced + /// <c>{"asin":"...","asset_details":[],"is_preview_enabled":false,"is_vvab":false,"rating":{...zeros...}}</c>. + /// </para> + /// <para> + /// Keyed off the title because every response group that returns anything descriptive returns one, and + /// the placeholder's own fields are Audible's to change. + /// </para> + /// </summary> + internal static bool CatalogProductIsEmpty(Newtonsoft.Json.Linq.JObject sourceJson) + => string.IsNullOrWhiteSpace(sourceJson.Value<string>("title")); #endregion #region Macros diff --git a/Source/FileLiberator/DownloadFailureClassifier.cs b/Source/FileLiberator/DownloadFailureClassifier.cs new file mode 100644 index 000000000..e79f3f065 --- /dev/null +++ b/Source/FileLiberator/DownloadFailureClassifier.cs @@ -0,0 +1,92 @@ +using AudibleApi; +using AudibleApi.Common; +using DataLayer; +using System; +using System.Collections.Generic; +using System.Diagnostics.CodeAnalysis; +using System.Linq; + +namespace FileLiberator; + +/// <summary>What Libation understood about a failed download attempt, and how long to wait because of it.</summary> +public sealed record DownloadFailureDiagnosis(DownloadFailureKind Kind, string Reason); + +/// <summary> +/// Recognises the download failures that mean "asking again right now will fail the same way": Audible +/// refusing a license, and Audible having no audio to deliver. +/// <para> +/// Only failures recognised here are recorded and waited on. Everything else - a dropped connection, a +/// decrypt error, a full disk - keeps the long-standing behaviour of being retried on the next run, because +/// there is no reason to believe the next attempt fails for the same reason. +/// </para> +/// </summary> +public static class DownloadFailureClassifier +{ + /// <summary> + /// Substring in Audible's Sable error when no audio asset exists for the title, which is what an + /// unreleased preorder looks like. Shared with <see cref="WidevineRecommendation.SableAcrNullMarker"/>, + /// which pairs it with an error code to spot a much narrower case. + /// </summary> + private const string NoAudioAssetMarker = "acr:null"; + + public static bool TryClassify(Exception ex, [NotNullWhen(true)] out DownloadFailureDiagnosis? diagnosis) + { + diagnosis = Classify(ex); + return diagnosis is not null; + } + + public static DownloadFailureDiagnosis? Classify(Exception? ex) + => ex switch + { + null => null, + ContentLicenseDeniedException denied => ClassifyLicenseDenial(denied), + ApiErrorException api => ClassifyApiError(api), + // A rethrown Widevine failure arrives wrapped by whichever step gave up on it. + _ => Classify(ex.InnerException) + }; + + /// <summary> + /// Audible attaches a rejection reason per validation type it ran. <c>GenericError</c> is Audible + /// declining to say why, which in practice means an outage rather than a decision about the title; + /// the GUI already reads it that way when it chooses which guidance to offer. Explicit + /// <c>CustomerThrottled</c> is a license denial the UI names separately; backoff still treats it as + /// a settled refusal. Anything else names an eligibility problem with the account or the title, + /// which will not change within the hour. Saying nothing at all is also treated as an outage: a + /// refusal with no stated reason is not a settled one. + /// </summary> + private static DownloadFailureDiagnosis ClassifyLicenseDenial(ContentLicenseDeniedException ex) + { + LicenseDenialReason?[] reasons = [ex.Ownership, ex.AYCL, ex.Membership, ex.Client]; + + var stated = reasons.Where(r => !string.IsNullOrWhiteSpace(r?.RejectionReason)).ToArray(); + var looksLikeOutage + = stated.Length == 0 + || stated.Any(r => r!.RejectionReason is RejectionReason.GenericError); + + return new DownloadFailureDiagnosis( + looksLikeOutage ? DownloadFailureKind.ServiceInterruption : DownloadFailureKind.LicenseDenied, + BuildLicenseDenialReason(reasons) ?? ex.Message); + } + + /// <summary>The most specific message Audible gave, prefixed with which check it failed.</summary> + private static string? BuildLicenseDenialReason(IEnumerable<LicenseDenialReason?> reasons) + => reasons + .Where(r => !string.IsNullOrWhiteSpace(r?.Message)) + .Select(r => r!.ValidationType is { Length: > 0 } type ? $"{type}: {r.Message}" : r.Message) + .FirstOrDefault(); + + /// <summary> + /// A license request that fails with no content reference (<c>acr:null</c>) means Audible has nothing to + /// deliver for this title yet, which is what a preorder that has not been released looks like. + /// </summary> + private static DownloadFailureDiagnosis? ClassifyApiError(ApiErrorException ex) + { + if (ex.RequestUri?.Contains("/licenserequest", StringComparison.OrdinalIgnoreCase) is not true + || ex.JsonMessage?.Contains(NoAudioAssetMarker, StringComparison.Ordinal) is not true) + return null; + + return new DownloadFailureDiagnosis( + DownloadFailureKind.AssetUnavailable, + "Audible returned no audio for this title. An unreleased preorder looks like this; so does a title Audible has not finished preparing."); + } +} diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 508470ead..c74ce2ad4 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -25,10 +25,17 @@ public partial class DownloadOptions public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) { var license = await ChooseContent(api, libraryBook, config, token); + + // Only the audiobook download needs this. A granted license always carries it, and a supplement is + // fetched from the same license without ever looking at it, so the requirement belongs here rather + // than in LicenseInfo. + if (license.ContentMetadata is not ContentMetadata contentMetadata) + throw new InvalidDataException($"Content license contains no content metadata for asin: [{libraryBook.Book.AudibleProductId}]"); + Serilog.Log.Logger.Debug("Content License {@License}", new { license.DrmType, - license.ContentMetadata.ContentReference + contentMetadata.ContentReference }); token.ThrowIfCancellationRequested(); @@ -40,8 +47,8 @@ public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBo var metadata = await api.GetContentMetadataAsync( libraryBook.Book.AudibleProductId, license.DrmType, - license.ContentMetadata.ContentReference.Acr, - license.ContentMetadata.ContentReference.FileVersion); + contentMetadata.ContentReference.Acr, + contentMetadata.ContentReference.FileVersion); if (metadata is null) { @@ -49,19 +56,19 @@ public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBo { libraryBook.Book.AudibleProductId, license.DrmType, - license.ContentMetadata.ContentReference.Acr, - license.ContentMetadata.ContentReference.FileVersion + contentMetadata.ContentReference.Acr, + contentMetadata.ContentReference.FileVersion }); } - else if (metadata.ContentReference != license.ContentMetadata.ContentReference) + else if (metadata.ContentReference != contentMetadata.ContentReference) { Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ", license.DrmType, metadata.ContentReference, - license.ContentMetadata.ContentReference); + contentMetadata.ContentReference); } else - license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; + contentMetadata.ChapterInfo = metadata.ChapterInfo; token.ThrowIfCancellationRequested(); return license; @@ -70,25 +77,29 @@ public static async Task<LicenseInfo> GetDownloadLicenseAsync(Api api, LibraryBo public class LicenseInfo { public DrmType DrmType { get; set; } - public ContentMetadata ContentMetadata { get; set; } + public ContentMetadata? ContentMetadata { get; set; } public KeyData[]? DecryptionKeys { get; set; } + /// <summary> + /// Where Audible says this title's supplement can be fetched from, or null when it says the title has + /// none. The license request asks for it (<c>response_groups</c> includes <c>pdf_url</c>), so carrying + /// it here is what lets one license serve both the audiobook and the PDF. + /// </summary> + public string? PdfUrl { get; set; } + [JsonConstructor] - private LicenseInfo() - { - ContentMetadata = null!; - } + private LicenseInfo() { } public static LicenseInfo Create(ContentLicense license, IEnumerable<KeyData>? keys = null) { ArgumentNullException.ThrowIfNull(license, nameof(license)); - ArgumentNullException.ThrowIfNull(license.ContentMetadata, nameof(license.ContentMetadata)); return new LicenseInfo { DrmType = license.DrmType, ContentMetadata = license.ContentMetadata, - DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher) + DecryptionKeys = keys?.ToArray() ?? ToKeys(license.Voucher), + PdfUrl = license.PdfUrl }; } @@ -96,6 +107,23 @@ public static LicenseInfo Create(ContentLicense license, IEnumerable<KeyData>? k => voucher?.Key is null ? null : [new KeyData(voucher.Key, voucher.Iv)]; } + /// <summary> + /// Requests a plain content license, without the Widevine negotiation and the chapter-metadata correction + /// that <see cref="GetDownloadLicenseAsync"/> layers on for the audiobook download. + /// <para> + /// A supplement needs nothing from a license but its <see cref="LicenseInfo.PdfUrl"/>, and asking for the + /// same license the audiobook download asks for is what makes one license usable by both steps. + /// </para> + /// </summary> + public static async Task<LicenseInfo> GetSupplementLicenseAsync(Api api, LibraryBook libraryBook, Configuration config) + { + var license = await api.GetDownloadLicenseAsync(libraryBook.Book.AudibleProductId, ChooseQuality(config)); + return LicenseInfo.Create(license); + } + + private static DownloadQuality ChooseQuality(Configuration config) + => config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; + private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) { Serilog.Log.Logger.Information("Download Settings {@Settings}", new @@ -107,7 +135,7 @@ private static async Task<LicenseInfo> ChooseContent(Api api, LibraryBook librar config.SpatialAudioCodec }); - var dlQuality = config.FileDownloadQuality == Configuration.DownloadQuality.Normal ? DownloadQuality.Normal : DownloadQuality.High; + var dlQuality = ChooseQuality(config); bool canUseWidevine = api.SupportsWidevine(); if (!config.UseWidevine || !canUseWidevine || await Cdm.GetCdmAsync() is not Cdm cdm) @@ -181,9 +209,12 @@ var contentLic /// </summary> public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { + if (licInfo.ContentMetadata?.ChapterInfo is not ChapterInfo chapterInfo) + throw new InvalidDataException($"Content license contains no chapter info for asin: [{libraryBook.Book.AudibleProductId}]"); + var titleConcat = config.CombineNestedChapterTitles ? ": " : null; var chapters - = flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat) + = flattenChapters(chapterInfo.Chapters, titleConcat) .OrderBy(c => c.StartOffsetMs) .ToList(); @@ -191,7 +222,7 @@ var chapters combineCredits(chapters); if (config.StripAudibleBrandAudio) - stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs); + stripBranding(chapters, chapterInfo.BrandIntroDurationMs, chapterInfo.BrandOutroDurationMs); if (config.SplitFilesByChapter) combineShortChapters(chapters, config.MinimumFileDuration * 1000); @@ -199,7 +230,7 @@ var chapters var dlOptions = new DownloadOptions(config, libraryBook, licInfo) { ChapterInfo = new Mpeg4Lib.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)), - RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs), + RuntimeLength = TimeSpan.FromMilliseconds(chapterInfo.RuntimeLengthMs), }; //Build AAXClean.ChapterInfo diff --git a/Source/FileLiberator/DownloadOptions.cs b/Source/FileLiberator/DownloadOptions.cs index 208a4fae4..561084535 100644 --- a/Source/FileLiberator/DownloadOptions.cs +++ b/Source/FileLiberator/DownloadOptions.cs @@ -56,13 +56,16 @@ private DownloadOptions(Configuration config, LibraryBook libraryBook, LicenseIn ArgumentValidator.EnsureNotNull(licInfo, nameof(licInfo)); - if (licInfo.ContentMetadata.ContentUrl?.OfflineUrl is not string licUrl) + if (licInfo.ContentMetadata is not AudibleApi.Common.ContentMetadata contentMetadata) + throw new InvalidDataException("Content license doesn't contain content metadata"); + + if (contentMetadata.ContentUrl?.OfflineUrl is not string licUrl) throw new InvalidDataException("Content license doesn't contain an offline Url"); DownloadUrl = licUrl; DecryptionKeys = licInfo.DecryptionKeys; DrmType = licInfo.DrmType; - ContentMetadata = licInfo.ContentMetadata; + ContentMetadata = contentMetadata; InputType = licInfo.DrmType is AudibleApi.Common.DrmType.Widevine ? AAXClean.FileType.Dash : licInfo.DrmType is AudibleApi.Common.DrmType.Adrm && licInfo.DecryptionKeys?.Length == 1 && licInfo.DecryptionKeys[0].KeyPart1.Length == 4 && licInfo.DecryptionKeys[0].KeyPart2 is null ? AAXClean.FileType.Aax diff --git a/Source/FileLiberator/DownloadPdf.cs b/Source/FileLiberator/DownloadPdf.cs index 81f7df9b3..ccb03a6bc 100644 --- a/Source/FileLiberator/DownloadPdf.cs +++ b/Source/FileLiberator/DownloadPdf.cs @@ -1,7 +1,9 @@ using ApplicationServices; using DataLayer; +using Dinah.Core; using Dinah.Core.ErrorHandling; using Dinah.Core.Net.Http; +using FileManager; using LibationFileManager; using System; using System.IO; @@ -11,34 +13,80 @@ namespace FileLiberator; -public class DownloadPdf : Processable, IProcessable<DownloadPdf> +public class DownloadPdf : Processable, IProcessable<DownloadPdf>, ILicensedDownload { public override string Name => "Download Pdf"; + + /// <summary> + /// A supplement is delivered through a content license, the same request the audiobook download makes, so + /// a refusal of one is a refusal of the other and belongs in the record that keeps a scheduled run from + /// asking again. + /// </summary> + protected override bool RecordsAttemptFailures => true; + + /// <inheritdoc/> + public DownloadOptions.LicenseInfo? LicenseInfo { get; set; } + + /// <inheritdoc/> + public DownloadOptions.LicenseInfo? ObtainedLicense { get; private set; } + + /// <summary> + /// A title Audible has a supplement for, whose supplement is not downloaded and was not written off. The + /// status check matches <see cref="DataLayer.LibraryBookQueries.NeedsPdfDownload"/>, and treats + /// <see cref="LiberatedStatus.Error"/> as "don't retry" exactly as the audiobook download does through + /// <c>AudioExists</c>. + /// </summary> public override bool Validate(LibraryBook libraryBook) => !string.IsNullOrWhiteSpace(getdownloadUrl(libraryBook)) - && !libraryBook.Book.PdfExists; + && libraryBook.Book.UserDefinedItem.PdfStatus is LiberatedStatus.NotLiberated; public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) { OnBegin(libraryBook); + string? createdDirectory = null; + ObtainedLicense = null; try { - var proposedDownloadFilePath = getProposedDownloadFilePath(libraryBook); - var actualDownloadedFilePath = await downloadPdfAsync(libraryBook, proposedDownloadFilePath); - var result = verifyDownload(actualDownloadedFilePath); + // A license this run already holds is used as it is. Requesting another would repeat the request + // the audiobook download just made, which is what filled the log in issue #1973. + var carriedLicense = LicenseInfo; + var license = carriedLicense ?? await requestLicenseAsync(libraryBook); + ObtainedLicense = license; + + if (license.PdfUrl is not string downloadUrl) + return await noSupplementAvailableAsync(libraryBook); - if (result.IsSuccess) + var proposedDownloadFilePath = GetProposedDownloadFilePath(libraryBook); + createdDirectory = createDirectoryFor(proposedDownloadFilePath); + + var result = await downloadAndVerifyAsync(libraryBook, downloadUrl, proposedDownloadFilePath); + + // Audible's download links are signed and expire, and a carried license was granted before an + // audiobook download that may have taken hours. One fresh license is worth trying before the + // title is reported as failed; a license requested moments ago would only fail the same way. + if (!result.IsSuccess && carriedLicense is not null) { - SetFileTime(libraryBook, actualDownloadedFilePath); - if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir) - SetDirectoryTime(libraryBook, outputDir); + Serilog.Log.Logger.Information( + "Requesting a new license to fetch the supplement of {libraryBook}: the one from its audiobook download did not work.", + libraryBook.LogFriendly()); + + license = await requestLicenseAsync(libraryBook); + ObtainedLicense = license; + + if (license.PdfUrl is not string freshDownloadUrl) + return await noSupplementAvailableAsync(libraryBook); + + result = await downloadAndVerifyAsync(libraryBook, freshDownloadUrl, proposedDownloadFilePath); } + await libraryBook.UpdatePdfStatusAsync(result.IsSuccess ? LiberatedStatus.Liberated : LiberatedStatus.NotLiberated); return result; } - catch (Exception ex) + // Refusals are left to reach ProcessSingleAsync, which records them for every step alike. Everything + // else is still swallowed here, which is what stopped a missing PDF from taking the app down with it. + catch (Exception ex) when (DownloadFailureClassifier.Classify(ex) is null) { Serilog.Log.Logger.Error(ex, "Error downloading PDF"); @@ -49,45 +97,185 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) } finally { + removeIfLeftEmpty(createdDirectory); OnCompleted(libraryBook); } } - private static string getProposedDownloadFilePath(LibraryBook libraryBook) + private async Task<DownloadOptions.LicenseInfo> requestLicenseAsync(LibraryBook libraryBook) + { + var api = await libraryBook.GetApiAsync(); + return await DownloadOptions.GetSupplementLicenseAsync(api, libraryBook, Configuration); + } + + /// <summary> + /// Audible granted a license and it carried no supplement link, which is Audible saying this title has no + /// PDF to give. Written off the way the audiobook download writes off a title it should not attempt again: + /// <see cref="LiberatedStatus.Error"/> means "don't retry" for a supplement exactly as it does for audio, + /// and the same controls - a forced or named <c>liberate</c> run, Mark PDF as Download Pending - set it back. + /// <para> + /// Reported as a completed step and not as a failure. Nothing went wrong and nothing is left to attempt, and + /// a failure here would put the queue's Abort / Retry / Ignore question to the user about a book whose audio + /// is fine. + /// </para> + /// </summary> + private async Task<StatusHandler> noSupplementAvailableAsync(LibraryBook libraryBook) + { + const string explanation = "Audible has no PDF for this title, although the library listing says it has one. " + + "Its PDF will not be asked for again; mark the PDF 'Download Pending' to try once more."; + + Serilog.Log.Logger.Information( + "Audible granted a license for {libraryBook} that carried no supplement link, so it has no PDF to download. {explanation}", + libraryBook.LogFriendly(), + explanation); + + OnStatusUpdate(explanation); + await libraryBook.UpdatePdfStatusAsync(LiberatedStatus.Error); + + return new StatusHandler(); + } + + /// <summary> + /// Fetches the supplement and keeps it only if what came back is one. A transport failure is reported as a + /// failed status rather than thrown, so that a license worth retrying still gets retried. + /// </summary> + private async Task<StatusHandler> downloadAndVerifyAsync(LibraryBook libraryBook, string downloadUrl, string proposedDownloadFilePath) + { + string actualDownloadedFilePath; + try + { + actualDownloadedFilePath = await downloadPdfAsync(downloadUrl, proposedDownloadFilePath); + } + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Error downloading PDF"); + return new StatusHandler { $"Error downloading PDF. See log for details. Error summary: {ex.Message}" }; + } + + var result = verifyDownload(actualDownloadedFilePath); + + if (result.IsSuccess) + { + OnFileCreated(libraryBook, actualDownloadedFilePath); + SetFileTime(libraryBook, actualDownloadedFilePath); + if (Path.GetDirectoryName(actualDownloadedFilePath) is string outputDir) + SetDirectoryTime(libraryBook, outputDir); + } + else + { + // Keeping it would leave a file in the library that is not the supplement, under a name that + // says it is, and would stop the folder cleanup in ProcessAsync from running. + FileUtility.SaferDelete(actualDownloadedFilePath); + } + + return result; + } + + /// <summary>The directory this run had to create, or null when it was already there.</summary> + private static string? createDirectoryFor(string filePath) + { + if (Path.GetDirectoryName(filePath) is not string directory || Directory.Exists(directory)) + return null; + + Directory.CreateDirectory(directory); + return directory; + } + + /// <summary> + /// A PDF-only download is the one case that has to make the book's folder before it has anything to put + /// in it. Without this, every failed download would leave an empty folder in the library. + /// </summary> + private static void removeIfLeftEmpty(string? directory) + { + try + { + if (directory is not null && Directory.Exists(directory) && !Directory.EnumerateFileSystemEntries(directory).Any()) + Directory.Delete(directory); + } + catch (Exception ex) + { + Serilog.Log.Logger.Debug(ex, "Could not remove the empty folder left by a failed PDF download: {directory}", directory); + } + } + + /// <summary> + /// Beside the book's audio files, in the folder the naming templates put that book in. The directory may + /// not exist yet; see <see cref="createDirectoryFor"/>. + /// <para> + /// The audio file is looked up first so a PDF joins the files already on disk even if they were named by + /// an older template or moved by hand. That lookup matches on the product id appearing in the path, so it + /// finds nothing for a library whose folder and file templates omit <c><id></c>, and nothing for a + /// book marked downloaded whose files are not on this machine. Falling back to the folder template rather + /// than to the Books directory itself keeps those PDFs with their book instead of loose in the library + /// root, where they also risk colliding with each other. + /// </para> + /// </summary> + internal string GetProposedDownloadFilePath(LibraryBook libraryBook) { var extension = Path.GetExtension(getdownloadUrl(libraryBook)) ?? ".pdf"; - // if audio file exists, get it's dir. else return base Book dir - var existingPath = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId)); - if (existingPath is not null) - return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, existingPath, extension); + var destinationDir + = Path.GetDirectoryName(AudibleFileStorage.Audio.GetPath(libraryBook.Book.AudibleProductId)) + ?? AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration); - return AudibleFileStorage.Audio.GetBooksDirectoryFilename(libraryBook, extension); + return AudibleFileStorage.Audio.GetCustomDirFilename(libraryBook, destinationDir, extension); } private static string? getdownloadUrl(LibraryBook libraryBook) => libraryBook?.Book?.Supplements?.FirstOrDefault()?.Url; - private async Task<string> downloadPdfAsync(LibraryBook libraryBook, string proposedDownloadFilePath) + private async Task<string> downloadPdfAsync(string downloadUrl, string proposedDownloadFilePath) { - var api = await libraryBook.GetApiAsync(); - var downloadUrl = await api.GetPdfDownloadLinkAsync(libraryBook.Book.AudibleProductId); - var progress = new Progress<DownloadProgress>(OnStreamingProgressChanged); var client = new HttpClient(); var actualDownloadedFilePath = await client.DownloadFileAsync(downloadUrl, proposedDownloadFilePath, progress); - OnFileCreated(libraryBook, actualDownloadedFilePath); OnStatusUpdate(actualDownloadedFilePath); return actualDownloadedFilePath; } - private static StatusHandler verifyDownload(string actualDownloadedFilePath) - => !File.Exists(actualDownloadedFilePath) - ? new StatusHandler { "Downloaded PDF cannot be found" } - : new StatusHandler(); + /// <summary> + /// That the file exists says only that the server sent a body, and an Audible error is a 200 with a JSON + /// body like any other response. Dinah's downloader renames by Content-Disposition, so such a body lands + /// in the book's folder under whatever Audible called it and the title is recorded as having its PDF. + /// <para> + /// A supplement is whatever its URL says it is, usually a PDF and occasionally an archive, so only a file + /// named .pdf is held to the PDF header. Everything else is checked for the one thing no supplement of + /// any kind starts with: the opening character of a JSON or markup document. + /// </para> + /// </summary> + internal static StatusHandler verifyDownload(string actualDownloadedFilePath) + { + if (!File.Exists(actualDownloadedFilePath)) + return new StatusHandler { "Downloaded PDF cannot be found" }; + + var firstBytes = readFirstBytes(actualDownloadedFilePath); + + if (firstBytes.Length == 0) + return new StatusHandler { "Downloaded PDF is empty" }; + + if (Path.GetExtension(actualDownloadedFilePath).EqualsInsensitive(".pdf") + && !firstBytes.AsSpan().StartsWith("%PDF"u8)) + return new StatusHandler { "What Audible returned is not a PDF. Nothing was saved; the PDF is still marked as not downloaded." }; + + if (firstBytes[0] is (byte)'{' or (byte)'[' or (byte)'<') + return new StatusHandler { "Audible returned a document instead of the supplement. Nothing was saved; the PDF is still marked as not downloaded." }; + + return new StatusHandler(); + } + + /// <summary>Enough to recognise a file signature, and past a byte-order mark if the body carries one.</summary> + private static byte[] readFirstBytes(string path) + { + var buffer = new byte[8]; + + using var stream = File.OpenRead(path); + var read = buffer[..stream.ReadAtLeast(buffer, buffer.Length, throwOnEndOfStream: false)]; + + return read.AsSpan().StartsWith("\uFEFF"u8) ? read[3..] : read; + } public static DownloadPdf Create(Configuration config) => new() { Configuration = config }; private DownloadPdf() { } diff --git a/Source/FileLiberator/FileLiberator.csproj b/Source/FileLiberator/FileLiberator.csproj index 667bd9b4d..038d93a2b 100644 --- a/Source/FileLiberator/FileLiberator.csproj +++ b/Source/FileLiberator/FileLiberator.csproj @@ -26,4 +26,8 @@ </Compile> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + </Project> diff --git a/Source/FileLiberator/ILicensedDownload.cs b/Source/FileLiberator/ILicensedDownload.cs new file mode 100644 index 000000000..d1cbb1ed2 --- /dev/null +++ b/Source/FileLiberator/ILicensedDownload.cs @@ -0,0 +1,25 @@ +namespace FileLiberator; + +/// <summary> +/// A step that downloads something Audible delivers through a content license: the audiobook, and the +/// supplement whose link the same license carries. +/// <para> +/// One license serves every such step for a title. The request is identical - <c>response_groups</c> asks for +/// <c>pdf_url</c> alongside the content reference - so a run holding a license must hand it on rather than ask +/// for another, which would be a second refusal to record for a title already refused once. +/// </para> +/// </summary> +public interface ILicensedDownload +{ + /// <summary> + /// Optional override to supply license info directly instead of querying the api based on Configuration options + /// </summary> + DownloadOptions.LicenseInfo? LicenseInfo { get; set; } + + /// <summary> + /// The license this step ended up using, whether supplied or requested, so the next step for the same title + /// can reuse it. Null until a license has been obtained, and reset at the start of every attempt: a + /// Processable instance is reused across books, so a license left behind belongs to a different title. + /// </summary> + DownloadOptions.LicenseInfo? ObtainedLicense { get; } +} diff --git a/Source/FileLiberator/Processable.cs b/Source/FileLiberator/Processable.cs index 813029ab7..a7d0e35cc 100644 --- a/Source/FileLiberator/Processable.cs +++ b/Source/FileLiberator/Processable.cs @@ -1,4 +1,5 @@ -using DataLayer; +using ApplicationServices; +using DataLayer; using Dinah.Core; using Dinah.Core.ErrorHandling; using Dinah.Core.Net.Http; @@ -42,6 +43,15 @@ protected Processable() { } /// <returns>True == success</returns> public abstract Task<StatusHandler> ProcessAsync(LibraryBook libraryBook); + /// <summary> + /// Whether a refusal from Audible during this step should be remembered, so a scheduled run stops asking + /// for the same license every time. True for the steps that request a content license - the audiobook + /// download and the supplement download, which make the same request - because that request is the one + /// Audible refuses and the one the record gates. A step that only works on files already on disk has + /// nothing to record. + /// </summary> + protected virtual bool RecordsAttemptFailures => false; + // when used in foreach: stateful. deferred execution public IEnumerable<LibraryBook> GetValidLibraryBooks(IEnumerable<LibraryBook> library) => library.Where(libraryBook => @@ -62,15 +72,40 @@ public async Task<StatusHandler> ProcessSingleAsync(LibraryBook libraryBook, boo Account = libraryBook.Account?.ToMask() ?? "[empty]" }); - var status - = (await ProcessAsync(libraryBook)) - ?? new StatusHandler { "Processable should never return a null status" }; + StatusHandler status; + try + { + status + = (await ProcessAsync(libraryBook)) + ?? new StatusHandler { "Processable should never return a null status" }; + } + catch (Exception ex) + { + RecordAttemptFailure(libraryBook, ex); + throw; + } GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true); + if (status.IsSuccess && RecordsAttemptFailures) + DownloadAttemptFailureStore.Clear(libraryBook); + return status; } + /// <summary> + /// Recorded here rather than in each host so the CLI, the GUI queue and anything added later all remember + /// a refusal the same way. Failures Libation cannot attribute to Audible are left unrecorded and keep + /// being retried on the next run. + /// </summary> + private void RecordAttemptFailure(LibraryBook libraryBook, Exception ex) + { + if (!RecordsAttemptFailures || !DownloadFailureClassifier.TryClassify(ex, out var diagnosis)) + return; + + DownloadAttemptFailureStore.Record(libraryBook, diagnosis.Kind, diagnosis.Reason); + } + public async Task<StatusHandler> TryProcessAsync(LibraryBook libraryBook) => Validate(libraryBook) ? await ProcessAsync(libraryBook) diff --git a/Source/FileLiberator/UploadToAudiobookshelf.cs b/Source/FileLiberator/UploadToAudiobookshelf.cs index d2483568c..2dadaab93 100644 --- a/Source/FileLiberator/UploadToAudiobookshelf.cs +++ b/Source/FileLiberator/UploadToAudiobookshelf.cs @@ -15,6 +15,27 @@ public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudioboo { public override string Name => "Upload to Audiobookshelf"; + public enum UploadOutcome { Uploaded, AlreadyExists, NoFilesFound, Failed } + + public sealed class UploadOutcomeEventArgs(UploadOutcome outcome, string message) : EventArgs + { + public UploadOutcome Outcome { get; } = outcome; + public string Message { get; } = message; + } + + /// <summary> + /// Raised exactly once per processed book, classifying what happened and why. + /// <para/> + /// Upload failures are reported here rather than through the returned <see cref="StatusHandler"/>. + /// A non-success StatusHandler marks the book as a bad book: the GUI process queue breaks its step + /// loop and raises the Abort/Retry/Ignore dialog. Uploading is a courtesy step layered onto + /// liberation and must never fail the book, so <see cref="ProcessAsync"/> always reports success. + /// </summary> + public event EventHandler<UploadOutcomeEventArgs>? OutcomeDetermined; + + private void OnOutcomeDetermined(UploadOutcome outcome, string message) + => OutcomeDetermined?.Invoke(this, new UploadOutcomeEventArgs(outcome, message)); + public override bool Validate(LibraryBook libraryBook) { if (!Configuration.AudiobookshelfEnabled) @@ -26,7 +47,9 @@ public override bool Validate(LibraryBook libraryBook) || string.IsNullOrWhiteSpace(Configuration.AudiobookshelfFolderId)) return false; - return libraryBook.Book.AudioExists; + // Deliberately narrower than Book.AudioExists, which also accepts LiberatedStatus.Error. + // An errored liberation may have left partial files; uploading those is worse than skipping. + return libraryBook.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated; } public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) @@ -37,7 +60,10 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) var files = GetFilesToUpload(libraryBook); if (files.Count == 0) { - OnStatusUpdate("No audio files found to upload"); + const string message = "No audio files found on disk to upload"; + OnStatusUpdate(message); + Serilog.Log.Logger.Warning("No audio files found on disk to upload for {Book}", libraryBook.LogFriendly()); + OnOutcomeDetermined(UploadOutcome.NoFilesFound, message); return new StatusHandler(); } @@ -59,20 +85,26 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) if (result == AudiobookshelfApiService.UploadResult.Success) { - OnStatusUpdate("Upload to Audiobookshelf completed successfully"); + const string message = "Upload to Audiobookshelf completed successfully"; + OnStatusUpdate(message); + OnOutcomeDetermined(UploadOutcome.Uploaded, message); return new StatusHandler(); } else if (result == AudiobookshelfApiService.UploadResult.AlreadyExists) { - OnStatusUpdate("Book already exists on Audiobookshelf; skipping upload"); + const string message = "Book already exists on Audiobookshelf; skipping upload"; + OnStatusUpdate(message); Serilog.Log.Logger.Information("Book already exists on Audiobookshelf, skipping upload: {Book}", libraryBook.LogFriendly()); + OnOutcomeDetermined(UploadOutcome.AlreadyExists, message); return new StatusHandler(); } else { - OnStatusUpdate("Upload to Audiobookshelf failed; book remains liberated on disk"); + const string message = "Upload to Audiobookshelf failed; book remains liberated on disk"; + OnStatusUpdate(message); Serilog.Log.Logger.Error("Audiobookshelf upload failed for {Book}, but continuing as soft-failure", libraryBook.LogFriendly()); // Soft-fail: log the error but do not mark the book as failed + OnOutcomeDetermined(UploadOutcome.Failed, message + ". See log for details."); return new StatusHandler(); } } @@ -81,6 +113,7 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf; continuing as soft-failure", libraryBook.LogFriendly()); OnStatusUpdate($"Audiobookshelf upload error: {ex.Message}"); // Soft-fail: log the error but do not mark the book as failed + OnOutcomeDetermined(UploadOutcome.Failed, $"Audiobookshelf upload error: {ex.Message}"); return new StatusHandler(); } finally @@ -89,40 +122,71 @@ public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) } } - private static List<string> GetFilesToUpload(LibraryBook libraryBook) + /// <summary> + /// Resolves a book's audio files by both the path cache and a live scan of the Books directory. + /// <para/> + /// Must not use <see cref="FilePathCache"/> alone. A book can be liberated - and so pass + /// <see cref="Validate"/>, which reads a database status - while having no cache entry at all. + /// </summary> + internal static List<string> GetAudioFilesOnDisk(string productId) + => AudibleFileStorage.Audio.GetPaths(productId) + .Select(p => (string)p) + .Where(File.Exists) + .ToList(); + + /// <summary> + /// Composes the final upload payload from one preferred audio format, in deterministic order, + /// followed by cover art at most once. + /// </summary> + internal static List<string> BuildUploadFileList(IEnumerable<string> audioPaths, string? coverPath) { - var files = new List<string>(); - var productId = libraryBook.Book.AudibleProductId; - - // Get audio files from cache - var audioFiles = FilePathCache.GetFiles(productId) - .Where(f => f.fileType == FileType.Audio && File.Exists(f.path)) - .Select(f => (string)f.path) - .Distinct() + var audioFiles = audioPaths + .Where(p => !string.IsNullOrWhiteSpace(p)) + .Distinct(StringComparer.Ordinal) .ToList(); - files.AddRange(audioFiles); + var m4bFiles = audioFiles + .Where(p => p.EndsWith(".m4b", StringComparison.OrdinalIgnoreCase)) + .ToList(); + var preferredAudioFiles = m4bFiles.Count > 0 + ? m4bFiles + : audioFiles.Any(p => p.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)) + ? audioFiles.Where(p => p.EndsWith(".mp3", StringComparison.OrdinalIgnoreCase)).ToList() + : audioFiles; + + var files = preferredAudioFiles + .OrderBy(p => p, StringComparer.Ordinal) + .Where(p => !string.Equals(p, coverPath, StringComparison.Ordinal)) + .ToList(); - // Use Libation's known cover art output path (same logic as DownloadDecryptBook.DownloadCoverArt) - if (audioFiles.FirstOrDefault() is { } firstAudioFile) - { - var dir = Path.GetDirectoryName(firstAudioFile); - if (dir is not null) - { - var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename( - libraryBook, - dir, - ".jpg", - returnFirstExisting: false); - - if (File.Exists(coverPath)) - files.Add(coverPath); - } - } + if (!string.IsNullOrWhiteSpace(coverPath)) + files.Add(coverPath); return files; } + internal static List<string> GetFilesToUpload(LibraryBook libraryBook) + { + var audioFiles = GetAudioFilesOnDisk(libraryBook.Book.AudibleProductId); + + return BuildUploadFileList(audioFiles, GetCoverArtPath(libraryBook, audioFiles.FirstOrDefault())); + } + + /// <summary>Libation's known cover art output path. Same logic as DownloadDecryptBook.DownloadCoverArt.</summary> + private static string? GetCoverArtPath(LibraryBook libraryBook, string? firstAudioFile) + { + if (firstAudioFile is null || Path.GetDirectoryName(firstAudioFile) is not string dir) + return null; + + var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename( + libraryBook, + dir, + ".jpg", + returnFirstExisting: false); + + return File.Exists(coverPath) ? coverPath : null; + } + public static UploadToAudiobookshelf Create(Configuration config) => new() { Configuration = config }; private UploadToAudiobookshelf() { } } diff --git a/Source/FileLiberator/UtilityExtensions.cs b/Source/FileLiberator/UtilityExtensions.cs index 5c23f477e..701702fbd 100644 --- a/Source/FileLiberator/UtilityExtensions.cs +++ b/Source/FileLiberator/UtilityExtensions.cs @@ -1,4 +1,5 @@ -using AudibleUtilities; +using AudibleApi; +using AudibleUtilities; using DataLayer; using Dinah.Core; using LibationFileManager; @@ -21,18 +22,23 @@ public static (string id, string title, string locale, string account) LogFriend account: libraryBook.Account.ToMask() ); - public static Func<Account, Task<ApiExtended>>? ApiExtendedFunc { get; set; } - public static async Task<AudibleApi.Api> GetApiAsync(this LibraryBook libraryBook) { using var accounts = AudibleApiStorage.GetAccountsSettingsPersister(); var account = accounts.AccountsSettings.GetAccount(libraryBook.Account, libraryBook.Book.Locale) ?? throw new InvalidCredentialException($"No account found for '{libraryBook.Account}' and locale '{libraryBook.Book.Locale}'"); - var apiExtended = await ApiExtended.CreateAsync(account); + var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin: true, storeLocale: libraryBook.StoreLocale()); return apiExtended.Api; } + /// <summary> + /// Marketplace a download or license request must speak to: the one the book was scanned from, which is not + /// always the account's home store. See issue #2020. + /// </summary> + internal static Locale StoreLocale(this LibraryBook libraryBook) + => Localization.Get(libraryBook.Book.Locale); + public static bool SupportsWidevine(this AudibleApi.Api api) { //TODO: Expose Api's identity maintainer directly instead of using reflection. diff --git a/Source/FileLiberator/_InternalsVisible.cs b/Source/FileLiberator/_InternalsVisible.cs new file mode 100644 index 000000000..f81522af3 --- /dev/null +++ b/Source/FileLiberator/_InternalsVisible.cs @@ -0,0 +1 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileLiberator) + ".Tests")] diff --git a/Source/FileManager/BackgroundFileSystem.cs b/Source/FileManager/BackgroundFileSystem.cs index 81fe8ab44..f66a5ce58 100644 --- a/Source/FileManager/BackgroundFileSystem.cs +++ b/Source/FileManager/BackgroundFileSystem.cs @@ -69,24 +69,48 @@ private void Init() fsCache.AddRange(SafestEnumerateFiles(RootDirectory)); } - directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>(); - fileSystemWatcher = new FileSystemWatcher(RootDirectory) + try { - IncludeSubdirectories = true, - EnableRaisingEvents = true - }; - fileSystemWatcher.Created += FileSystemWatcher_Changed; - fileSystemWatcher.Deleted += FileSystemWatcher_Changed; - fileSystemWatcher.Renamed += FileSystemWatcher_Changed; - fileSystemWatcher.Error += FileSystemWatcher_Error; + directoryChangesEvents = new BlockingCollection<FileSystemEventArgs>(); + fileSystemWatcher = new FileSystemWatcher(RootDirectory) + { + IncludeSubdirectories = true, + EnableRaisingEvents = true + }; + fileSystemWatcher.Created += FileSystemWatcher_Changed; + fileSystemWatcher.Deleted += FileSystemWatcher_Changed; + fileSystemWatcher.Renamed += FileSystemWatcher_Changed; + fileSystemWatcher.Error += FileSystemWatcher_Error; - backgroundScanner = new Task(BackgroundScanner); - backgroundScanner.Start(); + backgroundScanner = new Task(BackgroundScanner); + backgroundScanner.Start(); + } + // Watching a directory fails for the same reasons reading one does, and a removable drive can be pulled + // between the listing above and this. Constructing this type happens inside the static initializer that + // builds the Books file cache, and the runtime caches a failed initializer for the life of the process: + // one throw here would be rethrown at every later reader of the Books directory. Give up the live + // updates instead. Dropping RootDirectory has the owner rebuild this once the directory works again. + catch (Exception ex) + { + Serilog.Log.Logger.Error(ex, "Could not watch a directory for changes, so its file cache was abandoned: {@DebugText}", new { path = (string?)RootDirectory }); + Stop(); + RootDirectory = null; + } } private void Stop() { - //Stop raising events - fileSystemWatcher?.Dispose(); + //Stop raising events. Detach first so a handler cannot run after this returns, and clear the field so a + //second Stop() is harmless. + var watcher = fileSystemWatcher; + fileSystemWatcher = null; + if (watcher is not null) + { + watcher.Created -= FileSystemWatcher_Changed; + watcher.Deleted -= FileSystemWatcher_Changed; + watcher.Renamed -= FileSystemWatcher_Changed; + watcher.Error -= FileSystemWatcher_Error; + watcher.Dispose(); + } try { @@ -97,10 +121,24 @@ private void Stop() catch (ObjectDisposedException) { } //Wait for background scanner to terminate before reinitializing. - backgroundScanner?.Wait(); + try + { + backgroundScanner?.Wait(); + } + // A scanner that died still has to be waited on, but its failure is not this caller's to raise: Stop() is + // reached from Refresh() and from Dispose(), neither of which has anything to do with whatever went wrong, + // and both of which are about to replace the scanner anyway. + catch (AggregateException ex) + { + Serilog.Log.Logger.Error(ex, "The file cache's background scanner had already stopped on an error."); + } - //Dispose of directoryChangesEvents after backgroundScanner exists. - directoryChangesEvents?.Dispose(); + //Dispose of directoryChangesEvents after backgroundScanner exists. Clear the field first so a late event + //has nothing to add to. CompleteAdding has to have happened while it was still set, or the scanner would + //still be blocked waiting for it. + var events = directoryChangesEvents; + directoryChangesEvents = null; + events?.Dispose(); lock (fsCacheLocker) fsCache.Clear(); @@ -113,7 +151,18 @@ private void FileSystemWatcher_Error(object sender, ErrorEventArgs e) private void FileSystemWatcher_Changed(object sender, FileSystemEventArgs e) { - directoryChangesEvents?.Add(e); + try + { + directoryChangesEvents?.Add(e); + } + // Stop() completes and disposes the collection, but events the OS had already buffered still arrive after + // that. On Windows they arrive on a native completion callback, where an exception does not fail a call - + // it takes the process down. Dropping them is correct: whoever called Stop() is either reinitializing, + // which rebuilds the cache from disk, or disposing. + // + // Covers both ways the collection refuses: completed for additions, and disposed, whose + // ObjectDisposedException derives from this. + catch (InvalidOperationException) { } } #region Background Thread @@ -121,8 +170,19 @@ private void BackgroundScanner() { while (directoryChangesEvents?.TryTake(out var change, -1) is true) { - lock (fsCacheLocker) - UpdateLocalCache(change); + try + { + lock (fsCacheLocker) + UpdateLocalCache(change); + } + // One path this cannot read must not end the scan. An exception here used to be terminal twice over: + // it stopped the cache tracking anything further, and it was stored on the task, so the next Stop() + // rethrew it as an AggregateException at whoever had called Refresh() - a caller with nothing to do + // with the file that went missing. + catch (Exception ex) + { + Serilog.Log.Logger.Debug(ex, "Could not apply a file system change to the file cache: {@DebugText}", new { change.ChangeType, change.FullPath }); + } } } @@ -156,14 +216,36 @@ private void AddPath(LongPath path) { path = path.LongPathName; //Temporary files created when updating the db will disappear before their attributes can be read. - if (Path.GetFileName(path).Contains("LibationContext.db") || !File.Exists(path) && !Directory.Exists(path)) + if (Path.GetFileName(path).Contains("LibationContext.db")) return; - if (File.GetAttributes(path).HasFlag(FileAttributes.Directory)) + + // Whether it exists and what it is were two questions, and the answer to the first could stop being true + // before the second was asked: a download's temp file, or a folder the user has just moved or deleted, is + // gone by the time its attributes are read. One question instead, and its failure means the same thing + // the existence check meant - there is nothing here to add. + if (TryGetAttributes(path) is not FileAttributes attributes) + return; + + if (attributes.HasFlag(FileAttributes.Directory)) AddUniqueFiles(SafestEnumerateFiles(path)); else AddUniqueFile(path); } + /// <returns>What the path is, or null when it cannot be read and so has nothing to add.</returns> + internal static FileAttributes? TryGetAttributes(LongPath path) + { + try + { + return File.GetAttributes(path); + } + catch (Exception ex) when (ex is IOException or UnauthorizedAccessException or ArgumentException or NotSupportedException) + { + Serilog.Log.Logger.Debug(ex, "Nothing to add to the file cache for {@DebugText}", new { path = (string)path }); + return null; + } + } + private IEnumerable<LongPath> SafestEnumerateFiles(string path) { try diff --git a/Source/FileManager/FileManager.csproj b/Source/FileManager/FileManager.csproj index 148076a9d..72e7387a2 100644 --- a/Source/FileManager/FileManager.csproj +++ b/Source/FileManager/FileManager.csproj @@ -6,10 +6,14 @@ </PropertyGroup> <ItemGroup> - <PackageReference Include="Dinah.Core" Version="10.2.0.1" /> + <PackageReference Include="Dinah.Core" Version="11.0.0.1" /> <PackageReference Include="Polly" Version="8.6.6" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <DebugType>embedded</DebugType> </PropertyGroup> diff --git a/Source/FileManager/FileSystemTest.cs b/Source/FileManager/FileSystemTest.cs index 846c547bb..d4acef2ac 100644 --- a/Source/FileManager/FileSystemTest.cs +++ b/Source/FileManager/FileSystemTest.cs @@ -57,15 +57,8 @@ private static bool CanWriteFile(LongPath filename) Serilog.Log.Logger.Debug("Testing ability to write filename: {filename}", filename); File.WriteAllBytes(filename, []); Serilog.Log.Logger.Debug("Deleting test file after successful write: {filename}", filename); - try - { - FileUtility.SaferDelete(filename); - } - catch (Exception ex) - { - //An error deleting the file doesn't constitute a write failure. - Serilog.Log.Logger.Debug(ex, "Error deleting test file: {filename}", filename); - } + // An error deleting the file doesn't constitute a write failure. + FileUtility.TrySaferDelete(filename); return true; } catch (Exception ex) diff --git a/Source/FileManager/FileUtility.cs b/Source/FileManager/FileUtility.cs index 413f749a9..6972b27ba 100644 --- a/Source/FileManager/FileUtility.cs +++ b/Source/FileManager/FileUtility.cs @@ -181,26 +181,49 @@ public static LongPath SaferMoveToValidPath( /// <summary>Delete file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary> public static void SaferDelete(LongPath source) - => retryPolicy.Execute(() => + => retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: true)); + + /// <summary> + /// Delete file, best-effort. No error when source does not exist. Retries up to 3 times and + /// returns false instead of throwing when the file cannot be deleted. + /// </summary> + public static bool TrySaferDelete(LongPath source) + { + try { - try - { - if (!File.Exists(source)) - { - Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source }); - return; - } + retryPolicy.Execute(() => deleteFile(source, logAttemptFailure: false)); + return true; + } + catch (Exception ex) + { + // Unlike SaferDelete, log only once after retries are exhausted. Best-effort cleanup + // callers should not produce an error entry for every retry or need their own wrapper. + Serilog.Log.Logger.Warning(ex, "Unable to delete file after retries: {@DebugText}", new { source }); + return false; + } + } - Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source }); - File.Delete(source); - Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source }); - } - catch (Exception e) + private static void deleteFile(LongPath source, bool logAttemptFailure) + { + try + { + if (!File.Exists(source)) { - Serilog.Log.Logger.Error(e, "Failed to delete file: {@DebugText}", new { source }); - throw; + Serilog.Log.Logger.Debug("No file to delete: {@DebugText}", new { source }); + return; } - }); + + Serilog.Log.Logger.Debug("Attempt to delete file: {@DebugText}", new { source }); + File.Delete(source); + Serilog.Log.Logger.Information("File successfully deleted: {@DebugText}", new { source }); + } + catch (Exception ex) + { + if (logAttemptFailure) + Serilog.Log.Logger.Error(ex, "Failed to delete file: {@DebugText}", new { source }); + throw; + } + } /// <summary>Move file. No error when source does not exist. Retry up to 3 times before throwing exception.</summary> public static void SaferMove(LongPath source, LongPath destination) @@ -240,8 +263,10 @@ public static void SaferMove(LongPath source, LongPath destination) /// <param name="rootPath">Starting directory</param> /// <param name="patternMatch">Filename pattern match</param> /// <param name="searchOption">Search subdirectories or only top level directory for files</param> + /// <param name="onIncomplete">Called with the reason when the walk ends early, so a caller that must not + /// mistake a truncated list for an empty directory can tell the difference.</param> /// <returns>List of files</returns> - public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly) + public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string searchPattern = "*", SearchOption searchOption = SearchOption.TopDirectoryOnly, Action<Exception>? onIncomplete = null) { var enumOptions = new EnumerationOptions { @@ -250,7 +275,101 @@ public static IEnumerable<LongPath> SaferEnumerateFiles(LongPath path, string se ReturnSpecialDirectories = false, MatchType = MatchType.Simple }; - return Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p); + return IterateSafely( + () => Directory.EnumerateFiles(path.Path, searchPattern, enumOptions).Select(p => (LongPath)p), + path, + onIncomplete); + } + + /// <summary> + /// Walks a file system sequence so that a directory which stops being readable partway through ends the walk + /// instead of throwing at whoever is consuming it. + /// <para> + /// <see cref="EnumerationOptions.IgnoreInaccessible"/> only forgives permissions. A disconnected or failing + /// volume raises an I/O error from the enumerator itself, and because enumeration is lazy that error is + /// raised wherever the sequence is finally walked - which is past any try/catch the caller wrapped around + /// the call that produced it. Libation lost a whole session that way: a Books folder on a USB drive that + /// started returning I/O errors took down the file cache, the type initializer that builds it, and with it + /// every subsequent launch. See issue #1984. + /// </para> + /// </summary> + internal static IEnumerable<LongPath> IterateSafely(Func<IEnumerable<LongPath>> getSequence, LongPath path, Action<Exception>? onIncomplete = null) + { + IEnumerator<LongPath> enumerator; + try + { + //Opening the directory is itself a read, and fails the same way. + enumerator = getSequence().GetEnumerator(); + } + catch (Exception ex) when (IsUnreadable(ex)) + { + ReportIncomplete(ex, path, onIncomplete); + yield break; + } + + try + { + while (true) + { + LongPath current; + try + { + if (!enumerator.MoveNext()) + break; + current = enumerator.Current; + } + catch (Exception ex) when (IsUnreadable(ex)) + { + //Whatever has already been read is still good and still worth returning. + ReportIncomplete(ex, path, onIncomplete); + break; + } + + yield return current; + } + } + finally + { + enumerator.Dispose(); + } + } + + private static bool IsUnreadable(Exception ex) + => ex is IOException or UnauthorizedAccessException or System.Security.SecurityException; + + private static void ReportIncomplete(Exception ex, LongPath path, Action<Exception>? onIncomplete) + { + try + { + //A directory that has simply gone is routine: temp folders are created and cleaned up under a scan + //all the time. A directory that is there and cannot be read is worth seeing in a bug report. + if (ex is DirectoryNotFoundException) + Serilog.Log.Logger.Debug(ex, "Stopped listing files in a directory that is no longer there: {@DebugText}", new { path = (string)path }); + else + Serilog.Log.Logger.Warning(ex, "Could not finish listing files. The results are incomplete: {@DebugText}", new { path = (string)path }); + } + catch { /* logging must not be the thing that breaks a file listing */ } + + onIncomplete?.Invoke(ex); + } + + /// <summary> + /// Whether a directory can actually be read, as opposed to merely existing. A removable drive that has been + /// pulled, and a failing one, can both still answer that they are a directory while every read of them fails. + /// </summary> + public static bool CanEnumerate(LongPath path) + { + var readable = true; + + //The first entry is enough. A volume that cannot be read fails on the first attempt, and this must not + //pay for walking a whole library to answer the question. + foreach (var _ in IterateSafely( + () => Directory.EnumerateFileSystemEntries(path.Path).Select(p => (LongPath)p), + path, + _ => readable = false)) + break; + + return readable; } /// <summary> diff --git a/Source/FileManager/IJsonBackedDictionary.cs b/Source/FileManager/IJsonBackedDictionary.cs index 89510fa33..2cb67ac71 100644 --- a/Source/FileManager/IJsonBackedDictionary.cs +++ b/Source/FileManager/IJsonBackedDictionary.cs @@ -1,6 +1,6 @@ using Newtonsoft.Json.Linq; using System; -using System.Linq; +using System.Collections.Generic; namespace FileManager; @@ -13,6 +13,7 @@ public interface IJsonBackedDictionary object? GetObject(string propertyName); void SetString(string propertyName, string? newValue); void SetNonString(string propertyName, object? newValue); + void SetNonStrings(IReadOnlyList<KeyValuePair<string, object?>> values); bool RemoveProperty(string propertyName); bool SetWithJsonPath(string jsonPath, string propertyName, string? newValue, bool suppressLogging = false); string? GetStringFromJsonPath(string jsonPath); @@ -20,21 +21,68 @@ public interface IJsonBackedDictionary string? GetStringFromJsonPath(string jsonPath, string propertyName) => GetStringFromJsonPath($"{jsonPath}.{propertyName}"); - static T? UpCast<T>(object obj) + static T? UpCast<T>(object obj, string? propertyName = null) { if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj; if (obj is JObject jObject) return jObject.ToObject<T>(); if (obj is JValue jValue) { if (typeof(T).IsAssignableTo(typeof(Enum))) - { - return - Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal) - ? (T)enumVal - : Enum.GetValues(typeof(T)).Cast<T>().First(); - } + return ParseEnum<T>(jValue, propertyName ?? typeof(T).Name); + return jValue.Value<T>(); } + if (obj is JToken jToken) + return jToken.ToObject<T>(); throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}"); } + + private static T ParseEnum<T>(JValue jValue, string propertyPath) + { + var enumType = typeof(T); + + if (TryGetEnumFromNumber(jValue, enumType, propertyPath, out var fromNumber)) + return (T)fromNumber!; + + var text = jValue.Type == JTokenType.String + ? jValue.Value<string>() + : jValue.Value?.ToString(); + + if (text is not null + && Enum.TryParse(enumType, text, ignoreCase: true, out var parsed) + && parsed is not null + && Enum.IsDefined(enumType, parsed)) + { + return (T)parsed; + } + + throw InvalidConfigurationValueException.ForEnum(propertyPath, text ?? jValue.ToString(), enumType); + } + + private static bool TryGetEnumFromNumber(JValue jValue, Type enumType, string propertyPath, out object? value) + { + value = null; + if (jValue.Type is not (JTokenType.Integer or JTokenType.Float)) + return false; + + object? raw = jValue.Value; + if (raw is null) + return false; + + object converted; + try + { + converted = Convert.ChangeType(raw, Enum.GetUnderlyingType(enumType)); + } + catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException) + { + throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType); + } + + if (!Enum.IsDefined(enumType, converted)) + throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType); + + value = Enum.ToObject(enumType, converted); + return true; + } } diff --git a/Source/FileManager/InvalidConfigurationValueException.cs b/Source/FileManager/InvalidConfigurationValueException.cs new file mode 100644 index 000000000..1128071e1 --- /dev/null +++ b/Source/FileManager/InvalidConfigurationValueException.cs @@ -0,0 +1,39 @@ +using System; + +namespace FileManager; + +/// <summary> +/// Settings.json (or other JSON-backed config) contained a value that cannot be mapped to the expected type. +/// </summary> +public sealed class InvalidConfigurationValueException : Exception +{ + public string PropertyPath { get; } + public string? InvalidValue { get; } + public Type? ExpectedType { get; } + + public InvalidConfigurationValueException(string propertyPath, string? invalidValue, Type? expectedType, string message) + : base(message) + { + PropertyPath = propertyPath; + InvalidValue = invalidValue; + ExpectedType = expectedType; + } + + public static InvalidConfigurationValueException ForEnum(string propertyPath, string? invalidValue, Type enumType) + { + var allowed = string.Join(", ", Enum.GetNames(enumType)); + var display = FormatValue(invalidValue); + var message = + $"Invalid value for '{propertyPath}': {display}. " + + $"Expected one of: {allowed}."; + return new InvalidConfigurationValueException(propertyPath, invalidValue, enumType, message); + } + + public static InvalidConfigurationValueException ForPath(string propertyPath, string? invalidValue, string message) + => new(propertyPath, invalidValue, expectedType: null, message); + + public static string FormatValue(string? value) + => value is null ? "[null]" + : value.Length == 0 ? "[empty]" + : $"\"{value}\""; +} diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index be31a4843..a4203c938 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -1,9 +1,11 @@ -using Newtonsoft.Json; +using Dinah.Core.IO; +using Newtonsoft.Json; using Newtonsoft.Json.Linq; using System; using System.Collections.Generic; using System.Diagnostics.CodeAnalysis; using System.IO; +using System.Threading; namespace FileManager; @@ -16,6 +18,15 @@ public class PersistentDictionary : IJsonBackedDictionary private Dictionary<string, string?> stringCache { get; } = new(); private Dictionary<string, object?> objectCache { get; } = new(); + // Configuration.Instance is a process-wide singleton whose properties are read and written from + // the UI thread, BackgroundWorker callbacks and download workers simultaneously. Every cache and + // file access below must be serialized: unsynchronized Dictionary writes corrupt the cache, and + // unsynchronized file access lets a reader observe a half-written file. + // This lock cannot reach a second process (the GUI and the CLI share Settings.json), which is why + // every write goes through AtomicFileWriter: an outside reader sees either the old file or the new + // one, never a truncated one. + private Lock locker { get; } = new(); + public PersistentDictionary(string filepath, bool isReadOnly = false) { Filepath = filepath; @@ -36,32 +47,48 @@ public PersistentDictionary(string filepath, bool isReadOnly = false) [return: NotNullIfNotNull(nameof(defaultValue))] public string? GetString(string propertyName, string? defaultValue = null) { - if (!stringCache.ContainsKey(propertyName)) + lock (locker) { - var jObject = readFile(); - if (jObject.ContainsKey(propertyName)) - stringCache[propertyName] = jObject[propertyName]?.Value<string>(); - else - stringCache[propertyName] = defaultValue; - } + if (!stringCache.ContainsKey(propertyName)) + { + var jObject = readFile(); + if (jObject.ContainsKey(propertyName)) + stringCache[propertyName] = jObject[propertyName]?.Value<string>(); + else + stringCache[propertyName] = defaultValue; + } - return stringCache[propertyName]; + return stringCache[propertyName]; + } } [return: NotNullIfNotNull(nameof(defaultValue))] public T? GetNonString<T>(string propertyName, T? defaultValue = default) { - var obj = GetObject(propertyName); - - if (obj is null) + object? obj; + lock (locker) { - objectCache[propertyName] = defaultValue; - return defaultValue; + obj = getObject(propertyName); + + if (obj is null) + { + objectCache[propertyName] = defaultValue; + return defaultValue; + } } - return IJsonBackedDictionary.UpCast<T>(obj); + + // UpCast can throw InvalidConfigurationValueException. Do it outside the lock: it neither + // reads nor writes the cache, and callers turn the exception into a user-facing error. + return IJsonBackedDictionary.UpCast<T>(obj, propertyName); } public object? GetObject(string propertyName) + { + lock (locker) + return getObject(propertyName); + } + + private object? getObject(string propertyName) { if (!objectCache.ContainsKey(propertyName)) { @@ -76,47 +103,113 @@ public PersistentDictionary(string filepath, bool isReadOnly = false) public string? GetStringFromJsonPath(string jsonPath) { - if (!stringCache.ContainsKey(jsonPath)) + lock (locker) { - try + if (!stringCache.ContainsKey(jsonPath)) { - var jObject = readFile(); - var token = jObject.SelectToken(jsonPath); - if (token is null) + try + { + var jObject = readFile(); + var token = jObject.SelectToken(jsonPath); + if (token is null) + return null; + stringCache[jsonPath] = token.Value<string>(); + } + catch + { return null; - stringCache[jsonPath] = token.Value<string>(); - } - catch - { - return null; + } } - } - return stringCache[jsonPath]; + return stringCache[jsonPath]; + } } - public bool Exists(string propertyName) => readFile().ContainsKey(propertyName); + public bool Exists(string propertyName) + { + lock (locker) + return readFile().ContainsKey(propertyName); + } - private object locker { get; } = new object(); public void SetString(string propertyName, string? newValue) { - // only do this check in string cache, NOT object cache - if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue) - return; + bool written; + lock (locker) + { + // only do this check in string cache, NOT object cache + if (stringCache.ContainsKey(propertyName) && stringCache[propertyName] == newValue) + return; + + // set cache + stringCache[propertyName] = newValue; - // set cache - stringCache[propertyName] = newValue; + written = writeFile(propertyName, newValue); + } - writeFile(propertyName, newValue); + if (written) + logConfigChanged(propertyName, newValue); } public void SetNonString(string propertyName, object? newValue) { - // set cache - objectCache[propertyName] = newValue; + bool written; + JToken parsedNewValue; + lock (locker) + { + // set cache + objectCache[propertyName] = newValue; + + parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue)); + written = writeFile(propertyName, parsedNewValue); + } - var parsedNewValue = JToken.Parse(JsonConvert.SerializeObject(newValue)); - writeFile(propertyName, parsedNewValue); + if (written) + logConfigChanged(propertyName, parsedNewValue.ToString()); + } + + /// <summary> + /// Persists a related set of non-string settings with one settings-file replacement. + /// Callers and concurrent processes therefore observe either the complete old set or + /// the complete new set, never a partially written group. + /// </summary> + public void SetNonStrings(IReadOnlyList<KeyValuePair<string, object?>> values) + { + ArgumentNullException.ThrowIfNull(values); + if (values.Count == 0 || IsReadOnly) + return; + + var parsedValues = new (string PropertyName, object? Value, JToken JsonValue)[values.Count]; + for (var i = 0; i < values.Count; i++) + { + var value = values[i]; + parsedValues[i] = ( + value.Key, + value.Value, + JToken.Parse(JsonConvert.SerializeObject(value.Value))); + } + + List<(string PropertyName, string JsonValue)> changedValues = []; + lock (locker) + { + var jObject = readFile(); + foreach (var value in parsedValues) + { + if (!JToken.DeepEquals(jObject[value.PropertyName], value.JsonValue)) + changedValues.Add((value.PropertyName, value.JsonValue.ToString())); + jObject[value.PropertyName] = value.JsonValue; + } + + if (changedValues.Count > 0) + writeFileContents(JsonConvert.SerializeObject(jObject, Formatting.Indented)); + + // Do not let an unsuccessful disk write make the in-process cache claim that + // the transaction committed. Cache publication follows the atomic replacement. + foreach (var value in parsedValues) + objectCache[value.PropertyName] = value.Value; + } + + foreach (var value in changedValues) + logConfigChanged(value.PropertyName, value.JsonValue); } public bool RemoveProperty(string propertyName) @@ -138,7 +231,7 @@ public bool RemoveProperty(string propertyName) var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); - File.WriteAllText(Filepath, endContents); + writeFileContents(endContents); success = true; } Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName); @@ -148,30 +241,32 @@ public bool RemoveProperty(string propertyName) return success; } - private void writeFile(string propertyName, JToken? newValue) + /// <summary>Caller must hold <see cref="locker"/>.</summary> + /// <returns>The file was rewritten</returns> + private bool writeFile(string propertyName, JToken? newValue) { if (IsReadOnly) - return; + return false; // write new setting to file - lock (locker) - { - var jObject = readFile(); - var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); + var jObject = readFile(); + var startContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); - jObject[propertyName] = newValue; - var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); + jObject[propertyName] = newValue; + var endContents = JsonConvert.SerializeObject(jObject, Formatting.Indented); - if (startContents == endContents) - return; + if (startContents == endContents) + return false; - File.WriteAllText(Filepath, endContents); - } + writeFileContents(endContents); + return true; + } + private static void logConfigChanged(string propertyName, string? newValue) + { try { - var str = formatValueForLog(newValue?.ToString()); - Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = str }); + Serilog.Log.Logger.Information("Config changed. {@DebugInfo}", new { propertyName, newValue = formatValueForLog(newValue) }); } catch { } } @@ -185,19 +280,17 @@ public bool SetWithJsonPath(string jsonPath, string propertyName, string? newVal var path = $"{jsonPath}.{propertyName}"; - { - // only do this check in string cache, NOT object cache - if (stringCache.ContainsKey(path) && stringCache[path] == newValue) - return false; - - // set cache - stringCache[path] = newValue; - } - try { lock (locker) { + // only do this check in string cache, NOT object cache + if (stringCache.ContainsKey(path) && stringCache[path] == newValue) + return false; + + // set cache + stringCache[path] = newValue; + var jObject = readFile(); var token = jObject.SelectToken(jsonPath); if (token is null || token[propertyName] is null) @@ -208,7 +301,7 @@ public bool SetWithJsonPath(string jsonPath, string propertyName, string? newVal return false; token[propertyName] = newValue; - File.WriteAllText(Filepath, JsonConvert.SerializeObject(jObject, Formatting.Indented)); + writeFileContents(JsonConvert.SerializeObject(jObject, Formatting.Indented)); } } catch (Exception exDebug) @@ -237,6 +330,42 @@ private static string formatValueForLog(string? value) : value.Length > 100 ? $"[Length={value.Length}] {value[0..50]}...{value[^50..^0]}" : value; + /// <summary> + /// Replaces the settings file in one step, so a concurrent reader - including one in another + /// Libation process - sees either the whole old file or the whole new one. Mirrors how + /// <see cref="Dinah.Core.IO.JsonFilePersister{T}"/> saves AccountsSettings.json. + /// <para/> + /// Windows refuses to rename over a file while someone else holds a handle to it, and the CLI, + /// another GUI instance or a virus scanner can all hold Settings.json for a moment, so retry + /// before giving up and letting the caller see the failure. + /// </summary> + private void writeFileContents(string contents) + { + const int attempts = 5; + for (var attempt = 1; ; attempt++) + { + try + { + AtomicFileWriter.WriteAllText(Filepath, contents, validateJsonTempFile); + return; + } + catch (Exception ex) when (attempt < attempts && ex is IOException or UnauthorizedAccessException) + { + Thread.Sleep(20 * attempt); + } + } + } + + /// <summary>Throws before the temp file replaces the real one, leaving the real one untouched.</summary> + private static void validateJsonTempFile(string tempPath) + { + var contents = File.ReadAllText(tempPath); + if (string.IsNullOrWhiteSpace(contents)) + throw new JsonSerializationException($"Refusing to write an empty settings file to {tempPath}"); + JToken.Parse(contents); + } + + /// <summary>Caller must hold <see cref="locker"/>.</summary> private JObject readFile() { if (!File.Exists(Filepath)) @@ -270,8 +399,12 @@ private JObject readFile() private void createNewFile() { - File.WriteAllText(Filepath, "{}"); + writeFileContents("{}"); } - public JObject GetJObject() => readFile(); + public JObject GetJObject() + { + lock (locker) + return readFile(); + } } diff --git a/Source/FileManager/_InternalsVisible.cs b/Source/FileManager/_InternalsVisible.cs new file mode 100644 index 000000000..47db7673c --- /dev/null +++ b/Source/FileManager/_InternalsVisible.cs @@ -0,0 +1 @@ +[assembly: System.Runtime.CompilerServices.InternalsVisibleTo(nameof(FileManager) + ".Tests")] diff --git a/Source/HangoverAvalonia/HangoverAvalonia.csproj b/Source/HangoverAvalonia/HangoverAvalonia.csproj index 075ee45a9..dd9e794a6 100644 --- a/Source/HangoverAvalonia/HangoverAvalonia.csproj +++ b/Source/HangoverAvalonia/HangoverAvalonia.csproj @@ -79,6 +79,9 @@ <ItemGroup> <ProjectReference Include="..\HangoverBase\HangoverBase.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> <Target Name="SpicNSpan" AfterTargets="Clean"> diff --git a/Source/HangoverAvalonia/ViewModels/MainVM.cs b/Source/HangoverAvalonia/ViewModels/MainVM.cs index 4df87d052..bc83863c5 100644 --- a/Source/HangoverAvalonia/ViewModels/MainVM.cs +++ b/Source/HangoverAvalonia/ViewModels/MainVM.cs @@ -5,7 +5,7 @@ namespace HangoverAvalonia.ViewModels; public partial class MainVM : ViewModelBase { - public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; } + public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; } public MainVM() { diff --git a/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs b/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs index 9cc5adcc7..75dba7e2b 100644 --- a/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs +++ b/Source/HangoverAvalonia/ViewModels/TrashBinViewModel.cs @@ -77,7 +77,7 @@ public void UncheckAll() item.IsChecked = false; } - public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; } + public Func<string, Task<bool>> ConfirmDbMutationAsync { get; set; } public async Task RestoreCheckedAsync() { diff --git a/Source/HangoverBase/HangoverBase.csproj b/Source/HangoverBase/HangoverBase.csproj index babdaabdd..2c369e687 100644 --- a/Source/HangoverBase/HangoverBase.csproj +++ b/Source/HangoverBase/HangoverBase.csproj @@ -10,6 +10,10 @@ <ProjectReference Include="..\FileLiberator\FileLiberator.csproj" /> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + <PropertyGroup Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'"> <DebugType>embedded</DebugType> </PropertyGroup> diff --git a/Source/HangoverWinForms/HangoverWinForms.csproj b/Source/HangoverWinForms/HangoverWinForms.csproj index fa3fd523f..72419247e 100644 --- a/Source/HangoverWinForms/HangoverWinForms.csproj +++ b/Source/HangoverWinForms/HangoverWinForms.csproj @@ -53,6 +53,10 @@ </Compile> </ItemGroup> + <ItemGroup> + <PackageReference Update="Microsoft.SourceLink.GitHub" Version="10.0.400" /> + </ItemGroup> + <Target Name="SpicNSpan" AfterTargets="Clean"> <!-- Remove obj folder --> <RemoveDir Directories="$(BaseIntermediateOutputPath)" /> diff --git a/Source/Libation.slnx b/Source/Libation.slnx index b7edb5eb4..6a74f73f2 100644 --- a/Source/Libation.slnx +++ b/Source/Libation.slnx @@ -66,8 +66,10 @@ <Project Path="_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj" /> <Project Path="_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj" /> <Project Path="_Tests/FileManager.Tests/FileManager.Tests.csproj" /> + <Project Path="_Tests/LibationCli.Tests/LibationCli.Tests.csproj" /> <Project Path="_Tests/LibationFileManager.Tests/LibationFileManager.Tests.csproj" /> <Project Path="_Tests/LibationSearchEngine.Tests/LibationSearchEngine.Tests.csproj" /> <Project Path="_Tests/LibationUiBase.Tests/LibationUiBase.Tests.csproj" /> + <Project Path="_Tests/LibationAvalonia.Tests/LibationAvalonia.Tests.csproj" /> </Folder> </Solution> diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index e2105c995..cab3c33ca 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -9,6 +9,9 @@ <Application.Resources> <ResourceDictionary> + <ResourceDictionary.MergedDictionaries> + <ResourceInclude Source="avares://Libation/DesignSystem/Assets/AssetResources.axaml" /> + </ResourceDictionary.MergedDictionaries> <ControlTheme x:Key="{x:Type TextBlock}" TargetType="TextBlock"> <Setter Property="Foreground" Value="{DynamicResource TextControlForeground}" /> <Setter Property="Background" Value="Transparent" /> @@ -31,9 +34,6 @@ <SolidColorBrush x:Key="HyperlinkVisited" Color="Purple" /> <SolidColorBrush x:Key="CancelRed" Color="FireBrick" /> <SolidColorBrush x:Key="IconFill" Color="#231F20" /> - <SolidColorBrush x:Key="StoplightRed" Color="#F06060" /> - <SolidColorBrush x:Key="StoplightYellow" Color="#F0E160" /> - <SolidColorBrush x:Key="StoplightGreen" Color="#70FA70" /> </ResourceDictionary> <ResourceDictionary x:Key="Dark"> <SolidColorBrush x:Key="SeriesEntryGridBackgroundBrush" Opacity="0.3" Color="#bed2fa" /> @@ -44,9 +44,6 @@ <SolidColorBrush x:Key="HyperlinkVisited" Color="Orchid" /> <SolidColorBrush x:Key="CancelRed" Color="#802727" /> <SolidColorBrush x:Key="IconFill" Color="#DCE0DF" /> - <SolidColorBrush x:Key="StoplightRed" Color="#7d1f1f" /> - <SolidColorBrush x:Key="StoplightYellow" Color="#7d7d1f" /> - <SolidColorBrush x:Key="StoplightGreen" Color="#1f7d1f" /> </ResourceDictionary> </ResourceDictionary.ThemeDictionaries> </ResourceDictionary> @@ -62,6 +59,7 @@ <ColorPaletteResources x:Key="Dark" /> </FluentTheme.Palettes> </FluentTheme> + <StyleInclude Source="avares://Libation/DesignSystem/Styles/All.axaml" /> <Style Selector="TextBox[IsReadOnly=true]"> <Setter Property="Background" Value="{DynamicResource SystemChromeDisabledHighColor}" /> @@ -74,7 +72,7 @@ <Setter Property="Foreground" Value="{DynamicResource HyperlinkNew}"/> <Setter Property="ForegroundVisited" Value="{DynamicResource HyperlinkVisited}"/> </Style> - <Style Selector="Button"> + <Style Selector="Button:not(.contemporary)"> <Setter Property="VerticalContentAlignment" Value="Center"/> <Style Selector="^"> <Setter Property="Foreground" Value="{DynamicResource SystemChromeAltLowColor}" /> @@ -132,4 +130,4 @@ </NativeMenu> </NativeMenu.Menu> -</Application> \ No newline at end of file +</Application> diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index 787da6720..c44e08c19 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -8,8 +8,8 @@ using Avalonia.Platform; using Avalonia.Styling; using Avalonia.Threading; -using Dinah.Core; using LibationAvalonia.Dialogs; +using LibationAvalonia.DesignSystem; using LibationAvalonia.Themes; using LibationAvalonia.Views; using LibationFileManager; @@ -26,7 +26,20 @@ namespace LibationAvalonia; public class App : Application { public static Task<List<DataLayer.LibraryBook>>? LibraryTask { get; set; } + + /// <summary>Set by <see cref="Program"/> when another Libation instance already holds this folder's lock.</summary> + public static bool IsAnotherInstanceRunning { get; set; } + + /// <summary> + /// Set by <see cref="Program"/> when startup rolled back an incomplete in-app upgrade. The restored + /// files on disk no longer match the assemblies this process loaded, so it shows this and quits. + /// </summary> + public static StartupRecoveryNotice? StartupRecoveryNotice { get; set; } + + /// <summary>Set when the user accepted the offer to start Libation again. Read by <see cref="Program"/> after shutdown.</summary> + public static bool RestartRequested { get; private set; } public static ChardonnayTheme? DefaultThemeColors { get; private set; } + public static ExperienceManager? ExperienceManager { get; private set; } public static MainWindow? MainWindow { get; private set; } public static Uri AssetUriBase { get; } = new("avares://Libation/Assets/"); public static new Application Current => Application.Current ?? throw new InvalidOperationException("The Avalonia app hasn't started yet."); @@ -47,8 +60,27 @@ public override void OnFrameworkInitializationCompleted() MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition); - if (InstallUpgradeManager.TakeStartupRecoveryAlert() is { } recovery) - _ = MessageBox.Show(null, recovery.Body, recovery.Title, MessageBoxButtons.OK, MessageBoxIcon.Warning); + // The install folder was just rolled back underneath us. See issue #2001. + if (StartupRecoveryNotice is { } recovery) + { + _ = ShowRecoveryThenShutdownAsync(desktop, recovery); + base.OnFrameworkInitializationCompleted(); + return; + } + + // Another instance already owns this folder. No database work has been done in this process, + // so just tell the user and shut down instead of racing on shared files. See issue #1931. + if (IsAnotherInstanceRunning) + { + _ = ShowThenShutdownAsync( + desktop, + "Libation is already running.\r\n\r\n" + + "Please use the Libation window that is already open. Running more than one copy of " + + "Libation against the same folder at the same time can corrupt your library.", + "Libation is already running"); + base.OnFrameworkInitializationCompleted(); + return; + } BadBookActionDialogBase.ShowAsyncImpl = (owner, message, caption) => Dialogs.BadBookActionDialog.ShowAsync(owner as Window, message, caption); @@ -68,6 +100,61 @@ public override void OnFrameworkInitializationCompleted() base.OnFrameworkInitializationCompleted(); } + /// <summary> + /// Reports the rollback and shuts down. When the restored install is worth going back into, the user is + /// asked whether to start Libation again, and <see cref="Program"/> does it after this shuts down. + /// </summary> + private static async Task ShowRecoveryThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, StartupRecoveryNotice recovery) + { + if (!recovery.OfferRestart) + { + await ShowThenShutdownAsync(desktop, recovery.Body, recovery.Title); + return; + } + + try + { + var answer = await MessageBox.Show( + recovery.Body, + recovery.Title, + MessageBoxButtons.YesNo, + MessageBoxIcon.Warning, + MessageBoxDefaultButton.Button1); + + RestartRequested = answer is DialogResult.Yes; + } + catch (Exception ex) + { + // Serilog is unavailable on this path, and shutting down matters more than the question. + StartupLog.Error(ex, "Failed to ask whether to restart after the rollback"); + } + finally + { + desktop.Shutdown(); + } + } + + /// <summary> + /// Shows one modal and then shuts the app down without opening the main window, for the cases where + /// Libation has decided at startup that it must not keep running. + /// </summary> + private static async Task ShowThenShutdownAsync(IClassicDesktopStyleApplicationLifetime desktop, string body, string title) + { + try + { + await MessageBox.Show(body, title, MessageBoxButtons.OK, MessageBoxIcon.Warning); + } + catch (Exception ex) + { + // Serilog is unavailable on the rollback path, and shutting down matters more than the message. + StartupLog.Error(ex, $"Failed to show the startup message '{title}'"); + } + finally + { + desktop.Shutdown(); + } + } + private static async void RunSetupIfNeededAsync(IClassicDesktopStyleApplicationLifetime desktop, Configuration config) { var setup = new LibationSetup(config.LibationFiles) @@ -120,51 +207,73 @@ public static void RunMigrations(Configuration config) private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) { - Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged; - Current.ActualThemeVariantChanged += OnActualThemeVariantChanged; - OnActualThemeVariantChanged(Current, EventArgs.Empty); + ExperienceManager = new(Current, Configuration.Instance); + ExperienceManager.Initialize(); MainWindow mainWindow = new(); desktop.MainWindow = MainWindow = mainWindow; mainWindow.Loaded += MainWindow_Loaded; - mainWindow.Closed += (_, _) => desktop.Shutdown(); + mainWindow.Closed += (_, _) => + { + ExperienceManager?.Dispose(); + desktop.Shutdown(); + }; mainWindow.RestoreSizeAndLocation(Configuration.Instance); + mainWindow.PrepareCaptureWindowBeforeShow(); mainWindow.Show(); } - [PropertyChangeFilter(nameof(ThemeVariant))] - private static void ThemeVariant_PropertyChanged(object sender, PropertyChangedEventArgsEx e) - => OpenAndApplyTheme(e.NewValue as Configuration.Theme? ?? Configuration.Theme.System); - - private static void OnActualThemeVariantChanged(object? sender, EventArgs e) - => OpenAndApplyTheme(Configuration.Instance.ThemeVariant); - - private static void OpenAndApplyTheme(Configuration.Theme themeVariant) - { - using ChardonnayThemePersister? themePersister = ChardonnayThemePersister.Create(); - themePersister?.Target.ApplyTheme(themeVariant); - } - private static async void MainWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e) { - if (LibraryTask is not null && MainWindow is not null) + // This is an async void handler: any exception that escapes it becomes an unhandled + // exception and crashes Libation. Every path below is caught so that a failure to load + // or bind the library degrades to an empty, still-usable grid instead. See issue #1931. + if (LibraryTask is null || MainWindow is not { } mainWindow) + return; + + try + { + List<DataLayer.LibraryBook> library = await LibraryTask; + await Dispatcher.UIThread.InvokeAsync(() => mainWindow.OnLibraryLoadedAsync(library)); + } + catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex)) + { + Serilog.Log.Logger.Error(ex, "Failed to load library at startup"); + FatalStartupMessage failure = StartupAssemblyBootstrap.GetStartupFailureMessage(ex)!; + await MessageBox.Show( + mainWindow, + failure.Body, + failure.Title, + MessageBoxButtons.OK, + MessageBoxIcon.Error); + await showEmptyLibraryAsync(); + } + catch (Exception ex) + { + // Any other failure loading or binding the library must not take down the whole app. + // Log it, tell the user, and continue with an empty grid so Settings, re-scan, etc. + // remain reachable. + Serilog.Log.Logger.Error(ex, "Failed to load library at startup"); + await MessageBox.Show( + mainWindow, + "Libation could not load your library, so the library view will be empty for this session. " + + "Restarting Libation may resolve it. If this keeps happening, your library database or your " + + "computer's memory or disk may be corrupted.", + "Error loading library", + MessageBoxButtons.OK, + MessageBoxIcon.Error); + await showEmptyLibraryAsync(); + } + + async Task showEmptyLibraryAsync() { try { - List<DataLayer.LibraryBook> library = await LibraryTask; - await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library)); + await Dispatcher.UIThread.InvokeAsync(() => mainWindow.OnLibraryLoadedAsync([])); } - catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex)) + catch (Exception ex) { - Serilog.Log.Logger.Error(ex, "Failed to load library at startup"); - FatalStartupMessage failure = StartupAssemblyBootstrap.GetStartupFailureMessage(ex)!; - await MessageBox.Show( - MainWindow, - failure.Body, - failure.Title, - MessageBoxButtons.OK, - MessageBoxIcon.Error); - await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync([])); + Serilog.Log.Logger.Error(ex, "Failed to show empty library after a library load failure"); } } } diff --git a/Source/LibationAvalonia/Assets/ContemporaryCellar/decanter-cellar.png b/Source/LibationAvalonia/Assets/ContemporaryCellar/decanter-cellar.png new file mode 100644 index 000000000..47eaf345d Binary files /dev/null and b/Source/LibationAvalonia/Assets/ContemporaryCellar/decanter-cellar.png differ diff --git a/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-book-crate.png b/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-book-crate.png new file mode 100644 index 000000000..239bf71a1 Binary files /dev/null and b/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-book-crate.png differ diff --git a/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-decanter.png b/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-decanter.png new file mode 100644 index 000000000..7a98ecc60 Binary files /dev/null and b/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-decanter.png differ diff --git a/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-still-life.png b/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-still-life.png new file mode 100644 index 000000000..093189ea4 Binary files /dev/null and b/Source/LibationAvalonia/Assets/ContemporaryCellar/tasting-room-still-life.png differ diff --git a/Source/LibationAvalonia/Assets/Fonts/OFL.txt b/Source/LibationAvalonia/Assets/Fonts/OFL.txt new file mode 100644 index 000000000..5871e1f3d --- /dev/null +++ b/Source/LibationAvalonia/Assets/Fonts/OFL.txt @@ -0,0 +1,93 @@ +Copyright 2014 - 2023 Adobe (http://www.adobe.com/), with Reserved Font Name ‘Source’. All Rights Reserved. Source is a trademark of Adobe in the United States and/or other countries. + +This Font Software is licensed under the SIL Open Font License, Version 1.1. + +This license is copied below, and is also available with a FAQ at: http://scripts.sil.org/OFL + + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION & CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/Source/LibationAvalonia/Assets/Fonts/SourceSerif4-Regular.otf b/Source/LibationAvalonia/Assets/Fonts/SourceSerif4-Regular.otf new file mode 100644 index 000000000..fc9f84e94 Binary files /dev/null and b/Source/LibationAvalonia/Assets/Fonts/SourceSerif4-Regular.otf differ diff --git a/Source/LibationAvalonia/Assets/Fonts/SourceSerif4-Semibold.otf b/Source/LibationAvalonia/Assets/Fonts/SourceSerif4-Semibold.otf new file mode 100644 index 000000000..5732c3820 Binary files /dev/null and b/Source/LibationAvalonia/Assets/Fonts/SourceSerif4-Semibold.otf differ diff --git a/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml b/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml index ef0fb4f0d..5d639d164 100644 --- a/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml +++ b/Source/LibationAvalonia/Assets/LibationVectorIcons.xaml @@ -11,7 +11,6 @@ <StreamGeometry x:Key="QueuedIcon">M31,0 H49 V100 H31 M58,0 H76 V100 H58 M85,0 H103 V100 H85 M8,85 V122 H129 V85 H117 V109 H20 V85 H8 M0,36 V66 L24,51 M114,36 V66 L138,51</StreamGeometry> <StreamGeometry x:Key="QueueCompletedIcon">M0,0 H100 V100 H0 V0 M2,50 L36,82 L 93,27 L81,15 L36,59 L14,38</StreamGeometry> <StreamGeometry x:Key="QueueErrorIcon">M0,0 H100 V100 H0 V0 M15,71 L29,85 L50,64 L71,85 L85,71 L64,50 L85,29 L71,15 L50,36 L29,15 L15,29 L36,50</StreamGeometry> - <StreamGeometry x:Key="BookErrorIcon">M32,0 a 32,32 0 0 1 0,64 a 32,32 0 0 1 0,-64 m 0,4 a 28,28 0 0 1 0,56 a 28,28 0 0 1 0,-56 m-21,24 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1</StreamGeometry> <RotateTransform x:Key="Rotate45Transform" Angle="45" /> @@ -23,40 +22,6 @@ M 78,6 L81.5,2.5 A 8,8 0 0 1 91.5,2 L98.5,9 A 8,8 0 0 1 97.5,19.5 L94,23 </StreamGeometry> - <StreamGeometry x:Key="CollapseIcon"> - M0,2 A 2,2 0 0 1 2,0 H62 A2,2 0 0 1 64,2 V62 A 2,2 0 0 1 62,64 H 2 A 2,2 0 0 1 0,62 V2 - M 2,2 H62 V62 H2 V2 - M11,28 h42 a 1,1 0 0 1 1,1 v6 a 1,1 0 0 1 -1,1 h-42 a 1,1 0 0 1 -1,-1 v-6 a 1,1 0 0 1 1,-1 - </StreamGeometry> - <StreamGeometry x:Key="VerticalBarIcon">M28,53 v-42 a 1,1 0 0 1 1,-1 h6 a 1,1 0 0 1 1,1 v42 a 1,1 0 0 1 -1,1 h-6 a 1,1 0 0 1 -1,-1</StreamGeometry> - - - <CombinedGeometry x:Key="ExpandIcon" Geometry1="{StaticResource CollapseIcon}" Geometry2="{StaticResource VerticalBarIcon}" /> - - <StreamGeometry x:Key="StoplightBodyIcon"> - M0,12 A 12,12 0 0 1 12,0 H34 A 12,12 0 0 1 46,12 V88 A 12,12 0 0 1 34,100 H12 A 12,12 0 0 1 0,88 V12 - M20,8 H26 A 12,12 0 0 1 26,32 H20 A 12,12 0 0 1 20,8 - M20,38 H26 A 12,12 0 0 1 26,62 H20 A 12,12 0 0 1 20,38 - M20,68 H26 A 12,12 0 0 1 26,92 H20 A 12,12 0 0 1 20,68 - </StreamGeometry> - - <StreamGeometry x:Key="PdfDownloadedIcon"> - M4,38.5 H3 A 3,3 0 0 1 0,35.5 V21.4 A 3,3 0 0 1 3,18.4 H4 V2 A 2,2 0 0 1 6,0 H30.5 L41,12 V18.4 A 3,3 0 0 1 45,21.4 V35.5 A 3,3 0 0 1 42,38.5 H41 V48.5 A 2,2 0 0 1 39,50.5 H6 A 2,2 0 0 1 4,48.5 - M6,38.5 H39 V48.5 H6 V38.5 - M6,18.4 V2 H29 V12 A 1,1 0 0 0 30,13 H39 V18.4 - M 4.3179,36 c 0,0 0.122,-14.969 0.122,-14.969 1.469,-0.194 2.939,-0.388 4.5,-0.362 1.561,0.026 3.214,0.27 4.357,0.944 1.143,0.674 1.775,1.776 2.015,2.959 0.24,1.184 0.087,2.449 -0.5,3.52 -0.587,1.071 -1.607,1.949 -2.816,2.352 -1.209,0.403 -2.607,0.332 -4.005,0.26 0,0 -0.031,5.265 -0.031,5.265 0,0 -3.673,0.122 -3.673,0.122 0,0 0.031,-0.092 0.031,-0.092 - m 3.643,-12.428 c 0,0 0.031,4.286 0.031,4.286 0.735,0.051 1.47,0.102 2.107,-0.056 0.638,-0.158 1.178,-0.526 1.459,-1.122 0.281,-0.597 0.301,-1.423 0.01,-2.005 -0.291,-0.582 -0.893,-0.918 -1.546,-1.061 -0.653,-0.143 -1.357,-0.092 -1.709,-0.066 -0.352,0.026 -0.352,0.026 -0.352,0.026 - m 9.428,12.428 c 2.265,0.245 4.531,0.49 6.674,0.066 2.143,-0.424 4.163,-1.515 5.285,-3.081 1.122,-1.566 1.347,-3.607 1.27,-5.306 -0.076,-1.699 -0.454,-3.056 -1.454,-4.219 -1,-1.163 -2.622,-2.133 -4.704,-2.505 -2.082,-0.373 -4.623,-0.148 -7.164,0.076 0,0 0.092,14.969 0.092,14.969 - m 3.49,-12.398 c 0,0 0,9.673 0,9.673 0.888,0.02 1.776,0.041 2.653,-0.179 0.877,-0.219 1.745,-0.679 2.367,-1.541 0.622,-0.862 1,-2.127 0.98,-3.403 -0.02,-1.275 -0.439,-2.561 -1.193,-3.337 -0.755,-0.776 -1.847,-1.041 -2.704,-1.158 -0.857,-0.117 -1.48,-0.087 -2.102,-0.056 - m 11.908,12.245 v-14.785 h8.969 v2.51 h-5.786 v3.612 h5.388 v2.51 h-5.449 v6.092 - </StreamGeometry> - - <StreamGeometry x:Key="PdfDownArrowIcon"> - M29,44 V58.7498 H35.0491 A 1.5,1.5 0 0 1 36.1342,61.2861 L23.5607,73.8595 A 1.5,1.5 0 0 1 21.4393,73.8595 L8.8658,61.2861 A 1.5,1.5 0 0 1 9.9509,58.7498 H16 V44 A 1.5,1.5 0 0 1 17.5,42.5 H27.5 A 1.5,1.5 0 0 1 29,44 - </StreamGeometry> - - <CombinedGeometry x:Key="PdfNotDownloadedIcon" Geometry1="{StaticResource PdfDownloadedIcon}" Geometry2="{StaticResource PdfDownArrowIcon}" /> - <StreamGeometry x:Key="ImportIcon"> M5.65,4.3 h-2.75 a2.9,2.25 0 0 0 -2.9,2.25 v7.2 a2.9,2.25 0 0 0 2.9,2.25 h10.2 a2.9,2.25 0 0 0 2.9,-2.25 v-7.2 a2.9,2.25 0 0 0 -2.9,-2.25 diff --git a/Source/LibationAvalonia/Assets/libation.ico b/Source/LibationAvalonia/Assets/libation.ico index 88dff50b6..665451c95 100644 Binary files a/Source/LibationAvalonia/Assets/libation.ico and b/Source/LibationAvalonia/Assets/libation.ico differ diff --git a/Source/LibationAvalonia/Controls/ColorPickerExt.cs b/Source/LibationAvalonia/Controls/ColorPickerExt.cs new file mode 100644 index 000000000..bbcce453e --- /dev/null +++ b/Source/LibationAvalonia/Controls/ColorPickerExt.cs @@ -0,0 +1,42 @@ +using Avalonia.Controls; +using Avalonia.Controls.Primitives; +using Avalonia.VisualTree; +using System; +using System.Linq; + +namespace LibationAvalonia.Controls; + +/// <summary> A <see cref="ColorPicker"/> which reports when its drop-down flyout opens and closes. </summary> +public class ColorPickerExt : ColorPicker +{ + protected override Type StyleKeyOverride => typeof(ColorPicker); + + public event EventHandler? FlyoutOpened; + public event EventHandler? FlyoutClosed; + + private FlyoutBase? Flyout; + + protected override void OnApplyTemplate(TemplateAppliedEventArgs e) + { + base.OnApplyTemplate(e); + + if (Flyout is not null) + { + Flyout.Opened -= Flyout_Opened; + Flyout.Closed -= Flyout_Closed; + } + + //The ColorPicker's template is a DropDownButton whose flyout hosts the editor. + Flyout = this.GetVisualDescendants().OfType<DropDownButton>().FirstOrDefault()?.Flyout; + + if (Flyout is not null) + { + Flyout.Opened += Flyout_Opened; + Flyout.Closed += Flyout_Closed; + } + } + + private void Flyout_Opened(object? sender, EventArgs e) => FlyoutOpened?.Invoke(this, e); + + private void Flyout_Closed(object? sender, EventArgs e) => FlyoutClosed?.Invoke(this, e); +} diff --git a/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml b/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml index 15fa1d9c7..b0f674622 100644 --- a/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml +++ b/Source/LibationAvalonia/Controls/Settings/DownloadDecrypt.axaml @@ -8,10 +8,68 @@ x:DataType="vm:DownloadDecryptSettingsVM" x:Class="LibationAvalonia.Controls.Settings.DownloadDecrypt"> - <Grid RowDefinitions="Auto,Auto,Auto,*"> + <Grid RowDefinitions="Auto,Auto,Auto,Auto,*"> <controls:GroupBox Grid.Row="0" Margin="5" + Label="{Binding DailyDownloadLimitGroupboxText}"> + + <StackPanel Margin="0,5"> + + <TextBlock + Margin="0,0,0,8" + TextWrapping="Wrap" + Text="{Binding DailyDownloadLimitDescriptionText}" /> + + <controls:WheelComboBox + HorizontalAlignment="Left" + MinWidth="200" + ToolTip.Tip="{Binding DailyDownloadLimitTip}" + ItemsSource="{Binding DailyDownloadLimitScopes}" + SelectedItem="{Binding DailyDownloadLimit}" /> + + <StackPanel + Margin="0,8,0,0" + Orientation="Horizontal" + IsVisible="{Binding DailyDownloadLimitQuantityVisible}"> + + <TextBlock + VerticalAlignment="Center" + Text="{Binding DailyDownloadLimitQuantityText}" /> + + <NumericUpDown + Classes="SmallNumericUpDown" + Margin="5,0,0,0" + MinWidth="140" + Minimum="1" + Maximum="2147483647" + Increment="1" + FormatString="N0" + ParsingNumberStyle="Integer" + Value="{Binding DailyDownloadLimitQuantity, Mode=TwoWay}" /> + + <controls:WheelComboBox + Margin="10,0,0,0" + MinWidth="90" + ItemsSource="{Binding DailyDownloadLimitUnits}" + SelectedItem="{Binding DailyDownloadLimitUnit}" /> + + </StackPanel> + + <TextBlock + Margin="0,6,0,0" + FontStyle="Italic" + Opacity="0.8" + TextWrapping="Wrap" + IsVisible="{Binding DailyDownloadLimitApproximateNoteVisible}" + Text="{Binding DailyDownloadLimitApproximateNoteText}" /> + + </StackPanel> + </controls:GroupBox> + + <controls:GroupBox + Grid.Row="1" + Margin="5" Label="{Binding BadBookGroupboxText}"> <Grid @@ -66,7 +124,7 @@ <controls:GroupBox Margin="5" - Grid.Row="1" + Grid.Row="2" Label="Custom File Naming"> <Grid @@ -146,7 +204,7 @@ </Grid> </controls:GroupBox> <controls:GroupBox - Grid.Row="2" + Grid.Row="3" Margin="5" Label="Temporary Files Location"> @@ -166,7 +224,7 @@ </controls:GroupBox> <StackPanel - Grid.Row="3" + Grid.Row="4" Orientation="Horizontal"> <CheckBox diff --git a/Source/LibationAvalonia/Controls/Settings/Important.axaml b/Source/LibationAvalonia/Controls/Settings/Important.axaml index 4a968693b..7b6efa57a 100644 --- a/Source/LibationAvalonia/Controls/Settings/Important.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Important.axaml @@ -8,7 +8,7 @@ x:DataType="vm:ImportantSettingsVM" x:Class="LibationAvalonia.Controls.Settings.Important"> - <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*"> + <Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,*"> <controls:GroupBox Grid.Row="0" Margin="5" @@ -69,21 +69,30 @@ </StackPanel> </controls:GroupBox> - <CheckBox + <StackPanel Grid.Row="1" Margin="10,5" - IsEnabled="{Binding !UseWebViewSettingDisabled}" - IsChecked="{Binding UseWebView, Mode=TwoWay}"> - <StackPanel> - <TextBlock Text="{Binding UseWebViewText}" /> - <TextBlock - IsVisible="{Binding UseWebViewSettingDisabled}" - FontStyle="Italic" - Opacity="0.8" - Margin="0,2,0,0" - Text="{Binding UseWebViewSnapMessage}" /> - </StackPanel> - </CheckBox> + Spacing="5"> + <CheckBox + IsEnabled="{Binding !UseWebViewSettingDisabled}" + IsChecked="{Binding UseWebView, Mode=TwoWay}"> + <StackPanel> + <TextBlock Text="{Binding UseWebViewText}" /> + <TextBlock + IsVisible="{Binding UseWebViewSettingDisabled}" + FontStyle="Italic" + Opacity="0.8" + Margin="0,2,0,0" + Text="{Binding UseWebViewSnapMessage}" /> + </StackPanel> + </CheckBox> + + <CheckBox + IsChecked="{Binding CheckForUpgradesAtStartup, Mode=TwoWay}" + ToolTip.Tip="{Binding CheckForUpgradesAtStartupTip}"> + <TextBlock Text="{Binding CheckForUpgradesAtStartupText}" /> + </CheckBox> + </StackPanel> <controls:GroupBox Grid.Row="2" @@ -107,14 +116,24 @@ IsVisible="{Binding OsStoreUnavailableVisible}" Text="{Binding OsStoreUnavailableMessage}" TextWrapping="Wrap" /> - <Button + <StackPanel Margin="0,10,0,0" + Orientation="Horizontal" HorizontalAlignment="Right" - Padding="20,0" - Content="{Binding ConvertButtonText}" - ToolTip.Tip="{Binding ConvertButtonToolTip}" - IsEnabled="{Binding ConvertButtonEnabled}" - Command="{Binding ConvertExistingTokensCommand}" /> + Spacing="10"> + <Button + Padding="20,0" + Content="{Binding ExportButtonText}" + ToolTip.Tip="{Binding ExportButtonToolTip}" + IsEnabled="{Binding ExportButtonEnabled}" + Click="ExportMasterKey_Click" /> + <Button + Padding="20,0" + Content="{Binding ConvertButtonText}" + ToolTip.Tip="{Binding ConvertButtonToolTip}" + IsEnabled="{Binding ConvertButtonEnabled}" + Command="{Binding ConvertExistingTokensCommand}" /> + </StackPanel> </StackPanel> </controls:GroupBox> @@ -198,8 +217,98 @@ </Grid> </controls:GroupBox> - <Grid + <controls:GroupBox Grid.Row="5" + Margin="5" + Label="Contemporary Cellar"> + <StackPanel Spacing="8"> + <CheckBox + Content="Use the Contemporary Cellar interface" + IsChecked="{Binding UseContemporaryShell, Mode=TwoWay}" /> + <TextBlock + Opacity="0.8" + Text="Appearance changes are applied after Save. Turning this off restores the current Libation interface without changing your library or queue." + TextWrapping="Wrap" /> + <Grid + IsEnabled="{Binding UseContemporaryShell}" + ColumnDefinitions="Auto,*" + RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto"> + <TextBlock Margin="0,0,12,8" VerticalAlignment="Center" Text="Appearance" /> + <controls:WheelComboBox + Grid.Column="1" + Margin="0,0,0,8" + MinWidth="220" + HorizontalContentAlignment="Stretch" + AutomationProperties.Name="Contemporary appearance" + ItemsSource="{Binding ExperienceStyles}" + SelectedItem="{Binding SelectedExperienceStyle, Mode=TwoWay}" /> + + <TextBlock Grid.Row="1" Margin="0,0,12,8" VerticalAlignment="Center" Text="Density" /> + <controls:WheelComboBox + Grid.Row="1" + Grid.Column="1" + Margin="0,0,0,8" + HorizontalContentAlignment="Stretch" + AutomationProperties.Name="Contemporary density" + ItemsSource="{Binding DensityModes}" + SelectedItem="{Binding SelectedDensityMode, Mode=TwoWay}" /> + + <TextBlock Grid.Row="2" Margin="0,0,12,8" VerticalAlignment="Center" Text="Decoration" /> + <controls:WheelComboBox + Grid.Row="2" + Grid.Column="1" + Margin="0,0,0,8" + HorizontalContentAlignment="Stretch" + AutomationProperties.Name="Contemporary decoration" + ItemsSource="{Binding DecorationLevels}" + SelectedItem="{Binding SelectedDecorationLevel, Mode=TwoWay}" /> + + <TextBlock Grid.Row="3" Margin="0,0,12,0" VerticalAlignment="Center" Text="Reduced motion" /> + <controls:WheelComboBox + Grid.Row="3" + Grid.Column="1" + Margin="0,0,0,8" + HorizontalContentAlignment="Stretch" + AutomationProperties.Name="Contemporary reduced motion" + ItemsSource="{Binding ReducedMotionPreferences}" + SelectedItem="{Binding SelectedReducedMotionPreference, Mode=TwoWay}" /> + + <TextBlock Grid.Row="4" Margin="0,0,12,8" VerticalAlignment="Center" Text="Library view" /> + <controls:WheelComboBox + Grid.Row="4" + Grid.Column="1" + Margin="0,0,0,8" + HorizontalContentAlignment="Stretch" + AutomationProperties.Name="Default contemporary Library view" + ItemsSource="{Binding LibraryViewModes}" + SelectedItem="{Binding SelectedLibraryViewMode, Mode=TwoWay}" /> + + <TextBlock Grid.Row="5" Margin="0,0,12,0" VerticalAlignment="Center" Text="Navigation rail" /> + <controls:WheelComboBox + Grid.Row="5" + Grid.Column="1" + HorizontalContentAlignment="Stretch" + AutomationProperties.Name="Contemporary navigation rail" + ItemsSource="{Binding NavigationRailPreferences}" + SelectedItem="{Binding SelectedNavigationRailPreference, Mode=TwoWay}" /> + </Grid> + <CheckBox + IsEnabled="{Binding UseContemporaryShell}" + Content="Use system typography (recovery setting)" + IsChecked="{Binding UseSystemTypography, Mode=TwoWay}" /> + <CheckBox + IsEnabled="{Binding UseContemporaryShell}" + Content="Show the Decanter processing dock when space allows" + IsChecked="{Binding ShowDecanterDock, Mode=TwoWay}" /> + <CheckBox + IsEnabled="{Binding UseContemporaryShell}" + Content="Restore Current Flight after restarting Libation" + IsChecked="{Binding PersistFlightBetweenSessions, Mode=TwoWay}" /> + </StackPanel> + </controls:GroupBox> + + <Grid + Grid.Row="6" ColumnDefinitions="Auto,Auto,*" Margin="10" VerticalAlignment="Bottom"> @@ -220,8 +329,10 @@ <Button Grid.Column="2" HorizontalAlignment="Right" + IsEnabled="{Binding CanEditLegacyTheme}" Padding="20,0" VerticalAlignment="Stretch" + ToolTip.Tip="Switch to the current Libation interface before editing legacy Chardonnay colors." Content="Edit Theme Colors" Click="EditThemeColors_Click"/> </Grid> diff --git a/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs b/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs index cd5811fc7..067d74917 100644 --- a/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs +++ b/Source/LibationAvalonia/Controls/Settings/Important.axaml.cs @@ -1,8 +1,11 @@ using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; +using Avalonia.Platform.Storage; +using AudibleUtilities; using LibationAvalonia.Dialogs; using LibationAvalonia.ViewModels.Settings; using LibationFileManager; +using LibationUiBase; using System.Linq; namespace LibationAvalonia.Controls.Settings; @@ -21,8 +24,40 @@ public Important() ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged; } + private async void ExportMasterKey_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e) + { + var owner = this.GetParentWindow(); + if (!await TokenStorageSettingsUi.ConfirmExportMasterKeyAsync(owner)) + return; + + var options = new FilePickerSaveOptions + { + Title = TokenStorageSettingsUi.ExportConfirmCaption, + SuggestedFileName = IdentityTokenStorageWiring.DefaultMasterKeyFileName, + DefaultExtension = "key", + ShowOverwritePrompt = true, + FileTypeChoices = + [ + new("Master key (*.key)") { Patterns = ["*.key"] }, + new("All files (*.*)") { Patterns = ["*"] } + ] + }; + + var selectedFile = (await owner.StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath(); + if (selectedFile is null) + return; + + await TokenStorageSettingsUi.ExportMasterKeyToFileAsync(owner, selectedFile); + } + private void EditThemeColors_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e) { + // The legacy editor snapshots the live Fluent palette. Contemporary profiles + // deliberately replace those live colors, so opening it in that state could + // overwrite the user's preserved Chardonnay overrides. + if (Configuration.Instance.UseContemporaryShell) + return; + if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime) { //Only allow a single instance of the theme picker diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml index e6a75955e..40f7f99d3 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml @@ -5,10 +5,55 @@ mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="650" xmlns:views="clr-namespace:LibationAvalonia.Views" xmlns:controls="clr-namespace:LibationAvalonia.Controls" - x:DataType="controls:ThemePreviewControl" + x:DataType="controls:ThemePreviewControl" x:Class="LibationAvalonia.Controls.ThemePreviewControl"> - <Grid RowDefinitions="Auto,Auto,*"> - <controls:GroupBox> + <ThemeVariantScope Name="ExperienceScope"> + <Grid RowDefinitions="Auto,Auto,Auto,*"> + <Border + Grid.Row="0" + Margin="0,0,0,8" + Padding="{DynamicResource Libation.Density.CardPadding}" + Background="{DynamicResource Libation.Brush.Surface}" + BorderBrush="{DynamicResource Libation.Brush.BorderSubtle}" + BorderThickness="1" + BoxShadow="{DynamicResource Libation.Shadow.Low}" + CornerRadius="{DynamicResource Libation.Radius.Medium}"> + <Grid ColumnDefinitions="*,Auto" RowDefinitions="Auto,Auto,Auto"> + <StackPanel> + <TextBlock + FontFamily="{DynamicResource Libation.Font.Display}" + FontSize="{DynamicResource Libation.FontSize.Section}" + Foreground="{DynamicResource Libation.Brush.TextPrimary}" + Text="Semantic component preview" /> + <TextBlock + FontSize="{DynamicResource Libation.FontSize.BodySmall}" + Foreground="{DynamicResource Libation.Brush.TextSecondary}" + Text="Live controls use the selected profile's local resources." /> + </StackPanel> + <Border + Grid.Column="1" + Padding="10,5" + Background="{DynamicResource Libation.Brush.Success}" + CornerRadius="{DynamicResource Libation.Radius.Small}"> + <TextBlock Foreground="{DynamicResource Libation.Brush.TextOnAccent}" Text="Completed" /> + </Border> + <ProgressBar + Grid.Row="1" + Grid.ColumnSpan="2" + Margin="0,12,0,8" + Background="{DynamicResource Libation.Brush.ProgressTrack}" + Foreground="{DynamicResource Libation.Brush.ProgressFill}" + Value="64" /> + <Button + Grid.Row="2" + HorizontalAlignment="Left" + Padding="16,8" + Background="{DynamicResource Libation.Brush.AccentPrimary}" + Foreground="{DynamicResource Libation.Brush.TextOnAccent}" + Content="Primary action" /> + </Grid> + </Border> + <controls:GroupBox Grid.Row="1"> <WrapPanel> <RadioButton Margin="5,0" Content="This is an option" IsChecked="True" /> <RadioButton Margin="5,0" Content="This is another option" /> @@ -36,7 +81,7 @@ </StackPanel> </WrapPanel> </controls:GroupBox> - <WrapPanel Orientation="Horizontal" Grid.Row="1"> + <WrapPanel Orientation="Horizontal" Grid.Row="2"> <views:ProcessBookControl DataContext="{Binding QueuedBook}" ProcessBookStatus="{Binding Status}" /> <views:ProcessBookControl DataContext="{Binding WorkingBook}" ProcessBookStatus="{Binding Status}" /> <views:ProcessBookControl DataContext="{Binding CompletedBook}" ProcessBookStatus="{Binding Status}" /> @@ -46,8 +91,11 @@ </WrapPanel> <views:ProductsDisplay - Grid.Row="2" + Grid.Row="3" + DisableColumnCustomization="True" + DisableContextMenu="True" DataContext="{Binding ProductsDisplay}" /> </Grid> + </ThemeVariantScope> </UserControl> diff --git a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs index 7e70931d3..ffe4857d0 100644 --- a/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs +++ b/Source/LibationAvalonia/Controls/ThemePreviewControl.axaml.cs @@ -1,6 +1,8 @@ using Avalonia.Controls; +using Avalonia.Styling; using DataLayer; using Dinah.Core.ErrorHandling; +using LibationAvalonia.DesignSystem; using LibationAvalonia.ViewModels; using LibationFileManager; using LibationUiBase.ProcessQueue; @@ -21,6 +23,7 @@ public partial class ThemePreviewControl : UserControl public ProcessBookViewModel CompletedBook { get; } public ProcessBookViewModel CancelledBook { get; } public ProcessBookViewModel FailedBook { get; } + public ExperienceStyle? PreviewStyle { get; private set; } public ThemePreviewControl() { InitializeComponent(); @@ -49,6 +52,27 @@ public ThemePreviewControl() DataContext = this; } + /// <summary> + /// Applies an isolated profile to this preview only. Separate instances can + /// display different profiles side by side without changing App resources. + /// </summary> + public void ApplyExperiencePreview(ExperienceStyle style) + { + if (App.ExperienceManager is not { } manager) + return; + var preview = manager.CreatePreviewScope(style); + ExperienceScope.Resources = preview.Resources; + ExperienceScope.RequestedThemeVariant = preview.RequestedThemeVariant; + PreviewStyle = style; + } + + public void ClearExperiencePreview() + { + ExperienceScope.Resources = new ResourceDictionary(); + ExperienceScope.RequestedThemeVariant = ThemeVariant.Default; + PreviewStyle = null; + } + private IEnumerable<LibraryBook> CreateMockBooks() { yield return MockLibraryBook.CreateBook(title: "Some Book 1", subtitle: "The Theming", dateAdded: System.DateTime.Now.AddDays(4)).WithBookStatus(LiberatedStatus.Liberated); @@ -65,4 +89,4 @@ private class MockProcessable : FileLiberator.Processable public override Task<StatusHandler> ProcessAsync(LibraryBook libraryBook) => Task.FromResult(new StatusHandler()); public override bool Validate(LibraryBook libraryBook) => false; } -} \ No newline at end of file +} diff --git a/Source/LibationAvalonia/DesignSystem/Assets/AssetResources.axaml b/Source/LibationAvalonia/DesignSystem/Assets/AssetResources.axaml new file mode 100644 index 000000000..7fcdca065 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/AssetResources.axaml @@ -0,0 +1,10 @@ +<ResourceDictionary + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + <ResourceDictionary.MergedDictionaries> + <ResourceInclude Source="avares://Libation/DesignSystem/Assets/BrandAssets.axaml" /> + <ResourceInclude Source="avares://Libation/DesignSystem/Assets/GlyphAssets.axaml" /> + <ResourceInclude Source="avares://Libation/DesignSystem/Assets/StatusAssets.axaml" /> + <ResourceInclude Source="avares://Libation/DesignSystem/Assets/IllustrationAssets.axaml" /> + </ResourceDictionary.MergedDictionaries> +</ResourceDictionary> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/BrandAssets.axaml b/Source/LibationAvalonia/DesignSystem/Assets/BrandAssets.axaml new file mode 100644 index 000000000..59e2bcd6f --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/BrandAssets.axaml @@ -0,0 +1,85 @@ +<ResourceDictionary + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + <!-- + Original code-native master. The open pages form the outer silhouette while the + central carafe supplies the vessel cue. Keep the mark outlined so it remains + legible without relying on profile color or decorative material. + --> + <StreamGeometry x:Key="brand.app-icon"> + M2,6 C5,5 8,5 12,8 C16,5 19,5 22,6 V18 C18,17 15,18 12,21 C9,18 6,17 2,18 Z + M10,3 H14 M11,3 V7 C11,8 8,11 8,15 C8,18 9.8,20 12,20 C14.2,20 16,18 16,15 C16,11 13,8 13,7 V3 + </StreamGeometry> + <StreamGeometry x:Key="brand.mark.one-color"> + M2,6 C5,5 8,5 12,8 C16,5 19,5 22,6 V18 C18,17 15,18 12,21 C9,18 6,17 2,18 Z + M10,3 H14 M11,3 V7 C11,8 8,11 8,15 C8,18 9.8,20 12,20 C14.2,20 16,18 16,15 C16,11 13,8 13,7 V3 + </StreamGeometry> + <StreamGeometry x:Key="brand.mark.light"> + M2,6 C5,5 8,5 12,8 C16,5 19,5 22,6 V18 C18,17 15,18 12,21 C9,18 6,17 2,18 Z + M10,3 H14 M11,3 V7 C11,8 8,11 8,15 C8,18 9.8,20 12,20 C14.2,20 16,18 16,15 C16,11 13,8 13,7 V3 + </StreamGeometry> + <StreamGeometry x:Key="brand.mark.dark"> + M2,6 C5,5 8,5 12,8 C16,5 19,5 22,6 V18 C18,17 15,18 12,21 C9,18 6,17 2,18 Z + M10,3 H14 M11,3 V7 C11,8 8,11 8,15 C8,18 9.8,20 12,20 C14.2,20 16,18 16,15 C16,11 13,8 13,7 V3 + </StreamGeometry> + + <!-- + The horizontal lockups deliberately keep lettering live and system-rendered. + Platform packages must export reviewed outlined masters later; no generated + lettering or board text is embedded here. + --> + <ControlTemplate x:Key="brand.wordmark.horizontal.light" TargetType="ContentControl"> + <StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"> + <Viewbox Width="28" Height="28"> + <Path + Data="{StaticResource brand.mark.light}" + Stroke="{DynamicResource Libation.Brush.TextPrimary}" + StrokeThickness="1.75" + StrokeLineCap="Round" + StrokeJoin="Round" /> + </Viewbox> + <StackPanel VerticalAlignment="Center" Spacing="1"> + <TextBlock + FontFamily="{DynamicResource Libation.Font.Display}" + FontSize="20" + FontWeight="SemiBold" + LetterSpacing="2" + Foreground="{DynamicResource Libation.Brush.AccentSecondary}" + Text="LIBATION" /> + <TextBlock + FontFamily="{DynamicResource Libation.Font.UI}" + FontSize="9" + LetterSpacing="1" + Foreground="{DynamicResource Libation.Brush.TextSecondary}" + Text="OPEN SOURCE AUDIOBOOKS" /> + </StackPanel> + </StackPanel> + </ControlTemplate> + <ControlTemplate x:Key="brand.wordmark.horizontal.dark" TargetType="ContentControl"> + <StackPanel Orientation="Horizontal" Spacing="10" VerticalAlignment="Center"> + <Viewbox Width="28" Height="28"> + <Path + Data="{StaticResource brand.mark.dark}" + Stroke="{DynamicResource Libation.Brush.TextPrimary}" + StrokeThickness="1.75" + StrokeLineCap="Round" + StrokeJoin="Round" /> + </Viewbox> + <StackPanel VerticalAlignment="Center" Spacing="1"> + <TextBlock + FontFamily="{DynamicResource Libation.Font.Display}" + FontSize="20" + FontWeight="SemiBold" + LetterSpacing="2" + Foreground="{DynamicResource Libation.Brush.AccentPrimary}" + Text="LIBATION" /> + <TextBlock + FontFamily="{DynamicResource Libation.Font.UI}" + FontSize="9" + LetterSpacing="1" + Foreground="{DynamicResource Libation.Brush.TextSecondary}" + Text="AUDIOBOOKS, UNCORKED" /> + </StackPanel> + </StackPanel> + </ControlTemplate> +</ResourceDictionary> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/GlyphAssets.axaml b/Source/LibationAvalonia/DesignSystem/Assets/GlyphAssets.axaml new file mode 100644 index 000000000..3da65629c --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/GlyphAssets.axaml @@ -0,0 +1,80 @@ +<ResourceDictionary + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + <!-- + Original 24 x 24 outline vocabulary. Consumers provide semantic Stroke/Fill. + The intended default is a 1.75 stroke with round caps and joins. + --> + <StreamGeometry x:Key="glyph.overview"> + M3,11 L12,3 L21,11 V21 H15 V15 H9 V21 H3 Z + </StreamGeometry> + <StreamGeometry x:Key="glyph.library"> + M3,4 H8 V20 H3 Z M9.5,3 H14.5 V20 H9.5 Z M16,5 H21 V20 H16 Z M4,16 H7 M10.5,6 H13.5 M17,9 H20 + </StreamGeometry> + <StreamGeometry x:Key="glyph.downloads"> + M12,3 V14 M8,10 L12,14 L16,10 M4,17 V21 H20 V17 + </StreamGeometry> + <StreamGeometry x:Key="glyph.processing"> + M19,8 A8,8 0 0 0 5,6 M5,6 V2 M5,6 H9 M5,16 A8,8 0 0 0 19,18 M19,18 V22 M19,18 H15 + </StreamGeometry> + <StreamGeometry x:Key="glyph.history"> + M5,5 V9 H9 M5,9 A8,8 0 1 0 7,5 M12,7 V12 L16,14 + </StreamGeometry> + <StreamGeometry x:Key="glyph.accounts"> + M9,11 A4,4 0 1 0 9,3 A4,4 0 1 0 9,11 M2,21 C2,16 5,13 9,13 C13,13 16,16 16,21 M17,10 A3,3 0 1 0 17,4 M17,13 C20,13 22,16 22,20 + </StreamGeometry> + <StreamGeometry x:Key="glyph.settings"> + M12,3 V6 M12,18 V21 M3,12 H6 M18,12 H21 M5.6,5.6 L7.7,7.7 M16.3,16.3 L18.4,18.4 M18.4,5.6 L16.3,7.7 M7.7,16.3 L5.6,18.4 M12,16 A4,4 0 1 0 12,8 A4,4 0 1 0 12,16 + </StreamGeometry> + <StreamGeometry x:Key="glyph.tools"> + M4,20 L12,12 M14,10 L20,4 M15,3 C17,1 21,3 21,6 L18,7 L17,10 L14,9 C13,6 14,4 15,3 M5,4 L20,19 M3,3 L7,4 L8,7 L5,8 L3,3 + </StreamGeometry> + <StreamGeometry x:Key="glyph.trash"> + M4,6 H20 M9,3 H15 L16,6 M6,6 L7,21 H17 L18,6 M10,10 V17 M14,10 V17 + </StreamGeometry> + <StreamGeometry x:Key="glyph.add-books"> + M3,5 C6,4 9,5 12,7 V20 C9,18 6,18 3,19 Z M12,7 C15,5 18,4 21,5 V12 M17,15 V21 M14,18 H20 + </StreamGeometry> + <StreamGeometry x:Key="glyph.scan"> + M3,8 V3 H8 M16,3 H21 V8 M21,16 V21 H16 M8,21 H3 V16 M17,11 A5,5 0 0 0 8,9 M8,9 V6 M8,9 H11 M7,13 A5,5 0 0 0 16,15 M16,15 V18 M16,15 H13 + </StreamGeometry> + <StreamGeometry x:Key="glyph.flight"> + M3,4 H14 V15 H3 Z M6,7 H17 V18 H6 M9,10 H21 V21 H9 M13,16 L15,18 L19,14 + </StreamGeometry> + <StreamGeometry x:Key="glyph.output-profile"> + M4,3 H16 L20,7 V21 H4 Z M16,3 V7 H20 M8,10 H16 M8,14 H13 M8,18 H11 M15,18 H21 M18,15 L21,18 L18,21 + </StreamGeometry> + <StreamGeometry x:Key="glyph.process"> + M12,3 V5 M12,19 V21 M3,12 H5 M19,12 H21 M5.6,5.6 L7,7 M17,17 L18.4,18.4 M18.4,5.6 L17,7 M7,17 L5.6,18.4 M9,8 L16,12 L9,16 Z + </StreamGeometry> + <StreamGeometry x:Key="glyph.completed"> + M12,21 A9,9 0 1 0 12,3 A9,9 0 1 0 12,21 M7.5,12 L10.5,15 L16.5,9 + </StreamGeometry> + <StreamGeometry x:Key="glyph.failed"> + M12,21 A9,9 0 1 0 12,3 A9,9 0 1 0 12,21 M8.5,8.5 L15.5,15.5 M15.5,8.5 L8.5,15.5 + </StreamGeometry> + <StreamGeometry x:Key="glyph.retry"> + M19,8 A8,8 0 0 0 5,6 M5,6 V2 M5,6 H9 M5,16 A8,8 0 0 0 19,18 M19,18 V22 M19,18 H15 + </StreamGeometry> + <StreamGeometry x:Key="glyph.reveal-file"> + M3,6 H10 L12,8 H21 V20 H3 Z M13,12 H21 M18,9 L21,12 L18,15 + </StreamGeometry> + <StreamGeometry x:Key="glyph.metadata"> + M3,4 H14 L21,11 L12,20 L3,11 Z M7,8 A1,1 0 1 0 7,6 A1,1 0 1 0 7,8 M10,11 H17 M10,14 H14 + </StreamGeometry> + <StreamGeometry x:Key="glyph.filter"> + M3,5 H21 L14,13 V20 L10,22 V13 Z + </StreamGeometry> + <StreamGeometry x:Key="glyph.gallery"> + M3,3 H10 V10 H3 Z M14,3 H21 V10 H14 Z M3,14 H10 V21 H3 Z M14,14 H21 V21 H14 Z + </StreamGeometry> + <StreamGeometry x:Key="glyph.details"> + M4,6 H6 M9,6 H21 M4,12 H6 M9,12 H21 M4,18 H6 M9,18 H21 + </StreamGeometry> + <StreamGeometry x:Key="glyph.queue-log"> + M5,3 H15 L19,7 V21 H5 Z M15,3 V7 H19 M8,11 H16 M8,15 H16 M8,19 H13 + </StreamGeometry> + <StreamGeometry x:Key="glyph.marketplace"> + M3,9 L5,3 H19 L21,9 M4,9 V21 H20 V9 M8,9 V21 M8,15 H16 V21 M3,9 C3,12 8,12 8,9 C8,12 13,12 13,9 C13,12 18,12 18,9 C18,12 21,12 21,9 + </StreamGeometry> +</ResourceDictionary> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/IllustrationAssets.axaml b/Source/LibationAvalonia/DesignSystem/Assets/IllustrationAssets.axaml new file mode 100644 index 000000000..f81abfd53 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/IllustrationAssets.axaml @@ -0,0 +1,100 @@ +<ResourceDictionary + xmlns="https://github.com/avaloniaui" + xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"> + <!-- + Original, code-native functional illustration masters. They contain no text, + cover art, or board fragments. The hosting state owns copy, accessible naming, + commands, and whether decoration is shown. + --> + <ControlTemplate x:Key="illustration.cellar.empty-library" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M14,64 V21 C14,9 82,9 82,21 V64 M10,64 H86" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" StrokeLineCap="Round" /> + <Path Data="M23,25 H73 M23,42 H73 M23,59 H73 M28,17 V25 M35,19 V25 M43,16 V25 M29,34 V42 M38,32 V42 M47,35 V42 M57,31 V42 M65,34 V42 M28,50 V59 M36,48 V59 M45,51 V59" Stroke="{DynamicResource Libation.Brush.AccentSecondary}" StrokeThickness="2" StrokeLineCap="Round" /> + <Path Data="M59,48 C64,46 69,47 73,50 V61 C69,59 64,59 59,61 Z M73,50 C77,47 82,46 87,48 V61 C82,59 77,59 73,61 Z" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="2" StrokeJoin="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.shared.account-connection" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M22,30 A9,9 0 1 0 22,12 A9,9 0 1 0 22,30 M7,55 C7,42 13,34 22,34 C31,34 37,42 37,55" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="2" StrokeLineCap="Round" /> + <Path Data="M70,17 H84 V31 H70 Z M73,17 V13 H81 V17 M73,23 H81 M77,17 V31" Stroke="{DynamicResource Libation.Brush.AccentSecondary}" StrokeThickness="2" StrokeJoin="Round" /> + <Path Data="M37,26 L42,23 M46,21 L51,20 M55,20 L60,20 M64,21 L70,22 M37,46 L42,49 M46,51 L51,53 M55,54 L60,53 M64,51 L69,47 M71,45 L73,43" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" StrokeLineCap="Round" /> + <Path Data="M73,43 L78,48 L88,36" Stroke="{DynamicResource Libation.Brush.Success}" StrokeThickness="3" StrokeLineCap="Round" StrokeJoin="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.cellar.add-books" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M13,64 V20 C13,11 20,6 28,6 H68 C76,6 83,11 83,20 V64 M18,64 H78" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" /> + <Path Data="M24,17 H72 V59 H24 Z M30,17 V59 M66,17 V59" Stroke="{DynamicResource Libation.Brush.AccentSecondary}" StrokeThickness="2" StrokeJoin="Round" /> + <Path Data="M33,29 C39,27 44,29 48,32 V50 C44,47 39,47 33,49 Z M48,32 C52,29 57,27 63,29 V49 C57,47 52,47 48,50 Z M48,22 V28 M45,25 H51" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="2" StrokeLineCap="Round" StrokeJoin="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.tasting-room.add-books" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M12,31 L48,17 L84,31 L48,45 Z M12,31 V58 L48,69 V45 M84,31 V58 L48,69 M18,39 L48,49 L78,39 M18,49 L48,59 L78,49" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" StrokeJoin="Round" /> + <Path Data="M31,24 V9 H40 V21 M43,19 V7 H52 V16 M55,20 V11 H64 V24" Stroke="{DynamicResource Libation.Brush.AccentSecondary}" StrokeThickness="2" StrokeJoin="Round" /> + <Path Data="M72,11 V23 M66,17 H78" Stroke="{DynamicResource Libation.Brush.AccentPrimary}" StrokeThickness="3" StrokeLineCap="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.cellar.empty-decanter" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M40,6 H56 M42,6 V20 C42,23 27,34 27,49 C27,61 36,67 48,67 C60,67 69,61 69,49 C69,34 54,23 54,20 V6 M32,52 C42,55 54,55 64,52" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="2" StrokeLineCap="Round" StrokeJoin="Round" /> + <Path Data="M35,48 H40 M44,48 H49 M53,48 H58 M60,48 H61" Stroke="{DynamicResource Libation.Brush.AccentSecondary}" StrokeThickness="2" StrokeLineCap="Round" /> + <Path Data="M18,67 H78" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.tasting-room.empty-decanter" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M41,7 H55 M43,7 V22 C43,26 29,36 29,50 C29,61 37,66 48,66 C59,66 67,61 67,50 C67,36 53,26 53,22 V7 M34,51 C43,53 53,53 62,51" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="2" StrokeLineCap="Round" StrokeJoin="Round" /> + <Path Data="M18,66 H78" Stroke="{DynamicResource Libation.Brush.BorderSubtle}" StrokeThickness="2" /> + <Path Data="M75,14 V25 M69.5,19.5 H80.5" Stroke="{DynamicResource Libation.Brush.AccentPrimary}" StrokeThickness="2" StrokeLineCap="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.shared.processing-complete" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M48,64 A27,27 0 1 0 48,10 A27,27 0 1 0 48,64" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="3" /> + <Path Data="M31,37 L42,48 L65,25" Stroke="{DynamicResource Libation.Brush.Success}" StrokeThickness="5" StrokeLineCap="Round" StrokeJoin="Round" /> + <Path Data="M16,12 V22 M11,17 H21 M80,47 V57 M75,52 H85" Stroke="{DynamicResource Libation.Brush.AccentSecondary}" StrokeThickness="2" StrokeLineCap="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.shared.no-search-results" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M10,19 C20,16 30,18 39,24 V62 C30,57 20,57 10,60 Z M39,24 C48,18 58,16 68,19 V43" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" StrokeJoin="Round" /> + <Path Data="M64,50 A13,13 0 1 0 64,24 A13,13 0 1 0 64,50 M73,46 L86,59" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="3" StrokeLineCap="Round" /> + <Path Data="M58,31 L70,43 M70,31 L58,43" Stroke="{DynamicResource Libation.Brush.Danger}" StrokeThickness="2" StrokeLineCap="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> + + <ControlTemplate x:Key="illustration.shared.offline-auth-attention" TargetType="ContentControl"> + <Viewbox Stretch="Uniform"> + <Canvas Width="96" Height="72"> + <Path Data="M31,27 A10,10 0 1 0 31,7 A10,10 0 1 0 31,27 M13,56 C13,40 20,31 31,31 C42,31 49,40 49,56" Stroke="{DynamicResource Libation.Brush.TextPrimary}" StrokeThickness="2" StrokeLineCap="Round" /> + <Path Data="M58,15 C67,8 80,13 80,24 C89,25 91,39 81,42 H57 C47,41 46,28 55,25 C54,21 55,18 58,15" Stroke="{DynamicResource Libation.Brush.BorderStrong}" StrokeThickness="2" StrokeJoin="Round" /> + <Path Data="M54,55 L84,25" Stroke="{DynamicResource Libation.Brush.Danger}" StrokeThickness="3" StrokeLineCap="Round" /> + <Path Data="M70,48 V56 M70,61 V61.2" Stroke="{DynamicResource Libation.Brush.Warning}" StrokeThickness="3" StrokeLineCap="Round" /> + </Canvas> + </Viewbox> + </ControlTemplate> +</ResourceDictionary> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-accounts.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-accounts.svg new file mode 100644 index 000000000..037f6ff71 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-accounts.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M9 11a4 4 0 1 0 0-8 4 4 0 1 0 0 8ZM2 21c0-5 3-8 7-8s7 3 7 8m1-11a3 3 0 1 0 0-6m0 9c3 0 5 3 5 7"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-add-books.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-add-books.svg new file mode 100644 index 000000000..c1b64d9b2 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-add-books.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5c3-1 6 0 9 2v13c-3-2-6-2-9-1Zm9 2c3-2 6-3 9-2v7m-4 3v6m-3-3h6"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-completed.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-completed.svg new file mode 100644 index 000000000..7fd3f1455 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-completed.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21a9 9 0 1 0 0-18 9 9 0 1 0 0 18Zm-4.5-9 3 3 6-6"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-details.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-details.svg new file mode 100644 index 000000000..f068f7138 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-details.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h2m3 0h12M4 12h2m3 0h12M4 18h2m3 0h12"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-downloads.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-downloads.svg new file mode 100644 index 000000000..22f397197 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-downloads.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v11m-4-4 4 4 4-4M4 17v4h16v-4"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-failed.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-failed.svg new file mode 100644 index 000000000..aa01a17da --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-failed.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 21a9 9 0 1 0 0-18 9 9 0 1 0 0 18ZM8.5 8.5l7 7m0-7-7 7"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-filter.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-filter.svg new file mode 100644 index 000000000..4829831c2 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-filter.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 5h18l-7 8v7l-4 2v-9Z"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-flight.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-flight.svg new file mode 100644 index 000000000..108beb363 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-flight.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h11v11H3Zm3 3h11v11H6Zm3 3h12v11H9Zm4 6 2 2 4-4"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-gallery.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-gallery.svg new file mode 100644 index 000000000..315355bb8 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-gallery.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 3h7v7H3Zm11 0h7v7h-7ZM3 14h7v7H3Zm11 0h7v7h-7Z"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-history.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-history.svg new file mode 100644 index 000000000..2d744ffe5 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-history.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 5v4h4M5 9a8 8 0 1 0 2-4m5 2v5l4 2"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-library.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-library.svg new file mode 100644 index 000000000..625d0404d --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-library.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h5v16H3Zm6.5-1h5v17h-5ZM16 5h5v15h-5ZM4 16h3m3.5-10h3M17 9h3"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-marketplace.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-marketplace.svg new file mode 100644 index 000000000..8ea6bde8c --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-marketplace.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m3 9 2-6h14l2 6M4 9v12h16V9M8 9v12m0-6h8v6M3 9c0 3 5 3 5 0 0 3 5 3 5 0 0 3 5 3 5 0 0 3 3 3 3 0"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-metadata.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-metadata.svg new file mode 100644 index 000000000..9b4abb583 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-metadata.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 4h11l7 7-9 9-9-9Zm4 4a1 1 0 1 0 0-2 1 1 0 1 0 0 2Zm3 3h7m-7 3h4"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-output-profile.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-output-profile.svg new file mode 100644 index 000000000..64feae014 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-output-profile.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 3h12l4 4v14H4Zm12 0v4h4M8 10h8m-8 4h5m-5 4h3m4 0h6m-3-3 3 3-3 3"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-overview.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-overview.svg new file mode 100644 index 000000000..61ddadc37 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-overview.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 11 12 3l9 8v10h-6v-6H9v6H3Z"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-process.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-process.svg new file mode 100644 index 000000000..d8bc40332 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-process.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v2m0 14v2M3 12h2m14 0h2M5.6 5.6 7 7m10 10 1.4 1.4m0-12.8L17 7M7 17l-1.4 1.4M9 8l7 4-7 4Z"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-processing.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-processing.svg new file mode 100644 index 000000000..87e74e7fc --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-processing.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19 8A8 8 0 0 0 5 6V2m0 4h4M5 16a8 8 0 0 0 14 2v4m0-4h-4"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-queue-log.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-queue-log.svg new file mode 100644 index 000000000..b3106dfc8 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-queue-log.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M5 3h10l4 4v14H5Zm10 0v4h4M8 11h8m-8 4h8m-8 4h5"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-retry.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-retry.svg new file mode 100644 index 000000000..87e74e7fc --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-retry.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M19 8A8 8 0 0 0 5 6V2m0 4h4M5 16a8 8 0 0 0 14 2v4m0-4h-4"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-reveal-file.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-reveal-file.svg new file mode 100644 index 000000000..448d15a76 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-reveal-file.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 6h7l2 2h9v12H3Zm10 6h8m-3-3 3 3-3 3"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-scan.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-scan.svg new file mode 100644 index 000000000..386fabaf8 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-scan.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8V3h5m8 0h5v5m0 8v5h-5m-8 0H3v-5m14-5a5 5 0 0 0-9-2V6m0 3h3m-4 4a5 5 0 0 0 9 2v3m0-3h-3"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-settings.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-settings.svg new file mode 100644 index 000000000..bcd18d7ad --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-settings.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M12 3v3m0 12v3M3 12h3m12 0h3M5.6 5.6l2.1 2.1m8.6 8.6 2.1 2.1m0-12.8-2.1 2.1M7.7 16.3l-2.1 2.1M12 16a4 4 0 1 0 0-8 4 4 0 1 0 0 8Z"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-tools.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-tools.svg new file mode 100644 index 000000000..b9f81ca5c --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-tools.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="m4 20 8-8m2-2 6-6m-5-1c2-2 6 0 6 3l-3 1-1 3-3-1c-1-3 0-5 1-6ZM5 4l15 15M3 3l4 1 1 3-3 1Z"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-trash.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-trash.svg new file mode 100644 index 000000000..98f1e6597 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/Glyphs/glyph-trash.svg @@ -0,0 +1 @@ +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.75" stroke-linecap="round" stroke-linejoin="round"><path d="M4 6h16M9 3h6l1 3M6 6l1 15h10l1-15m-8 4v7m4-7v7"/></svg> diff --git a/Source/LibationAvalonia/DesignSystem/Assets/Source/brand-app-icon.svg b/Source/LibationAvalonia/DesignSystem/Assets/Source/brand-app-icon.svg new file mode 100644 index 000000000..ba3803601 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/Source/brand-app-icon.svg @@ -0,0 +1,30 @@ +<?xml version="1.0" encoding="UTF-8"?> +<!-- + Libation Contemporary Cellar application icon master. + Original artwork authored for Libation; GPL-3.0, matching the repository. + No external artwork, fonts, or generated raster source are embedded. +--> +<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024" role="img" aria-labelledby="title description"> + <title id="title">Libation open book and carafe mark + An open book surrounding a simple carafe on a burgundy rounded square. + + + + + + + + + + + + + + + + diff --git a/Source/LibationAvalonia/DesignSystem/Assets/StatusAssets.axaml b/Source/LibationAvalonia/DesignSystem/Assets/StatusAssets.axaml new file mode 100644 index 000000000..37723e670 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Assets/StatusAssets.axaml @@ -0,0 +1,41 @@ + + + + M12,3 V13 M8,9 L12,13 L16,9 M4,17 H20 V21 H4 Z M18,3 V7 H22 M22,3 L18,7 + + + M12,4 V14 M8,10 L12,14 L16,10 M5,18 H19 M18,6 A8,8 0 0 1 20,12 M6,6 A8,8 0 0 0 4,12 + + + M4,16 V20 H20 V16 M12,3 V13 M8,9 L12,13 L16,9 M15,17 L17,19 L21,15 + + + M19,8 A8,8 0 0 0 5,6 M5,6 V2 M5,6 H9 M5,16 A8,8 0 0 0 19,18 M19,18 V22 M19,18 H15 M12,15 A3,3 0 1 0 12,9 A3,3 0 1 0 12,15 + + + M12,21 A9,9 0 1 0 12,3 A9,9 0 1 0 12,21 M7.5,12 L10.5,15 L16.5,9 + + + M8,3 H16 L21,8 V16 L16,21 H8 L3,16 V8 Z M8.5,8.5 L15.5,15.5 M15.5,8.5 L8.5,15.5 + + + M12,21 A9,9 0 1 0 12,3 A9,9 0 1 0 12,21 M5.6,18.4 L18.4,5.6 + + + M4,4 H20 V20 H4 Z M7,17 L17,7 + + + M12,3 L22,21 H2 Z M12,9 V14 M12,18 V18.1 + + + M6,15 A3,3 0 1 0 6,9 A3,3 0 1 0 6,15 M18,9 A3,3 0 1 0 18,3 A3,3 0 1 0 18,9 M18,21 A3,3 0 1 0 18,15 A3,3 0 1 0 18,21 M9,10 L15,7 M9,14 L15,17 + + + M4,5 H16 M4,9 H16 M4,13 H16 M4,17 H16 M18,8 L22,12 L18,16 + + + M4,5 H20 M4,9 H20 M4,13 H20 M4,17 H20 M8,19 L12,23 L16,19 + + diff --git a/Source/LibationAvalonia/DesignSystem/Components/AttentionBanner.axaml b/Source/LibationAvalonia/DesignSystem/Components/AttentionBanner.axaml new file mode 100644 index 000000000..2603380e7 --- /dev/null +++ b/Source/LibationAvalonia/DesignSystem/Components/AttentionBanner.axaml @@ -0,0 +1,45 @@ + + + + + + + + + + + + + + + + + + + + +