diff --git a/.github/workflows/build-linux.yml b/.github/workflows/build-linux.yml index e76d1b87..91abad26 100644 --- a/.github/workflows/build-linux.yml +++ b/.github/workflows/build-linux.yml @@ -6,63 +6,46 @@ name: build on: workflow_call: inputs: - version_override: + libation-version: type: string - description: "Version number override" - required: false - run_unit_tests: + required: true + dotnet-version: + type: string + required: true + run-unit-tests: type: boolean - description: "Skip running unit tests" - required: false - default: true - runs_on: + publish-r2r: + type: boolean + retention-days: + type: number + architecture: type: string - description: "The GitHub hosted runner to use" + description: "CPU architecture targeted by the build." required: true OS: type: string description: > The operating system targeted by the build. - + There must be a corresponding Bundle_$OS.sh script file in ./Scripts required: true - architecture: - type: string - description: "CPU architecture targeted by the build." - required: true - -env: - DOTNET_CONFIGURATION: "Release" - DOTNET_VERSION: "9.0.x" - RELEASE_NAME: "chardonnay" jobs: build: name: "${{ inputs.OS }}-${{ inputs.architecture }}" - runs-on: ${{ inputs.runs_on }} + runs-on: ubuntu-latest + env: + RUNTIME_ID: "linux-${{ inputs.architecture }}" steps: - uses: actions/checkout@v5 - - name: Setup .NET - uses: actions/setup-dotnet@v5 + + - uses: actions/setup-dotnet@v5 with: - dotnet-version: ${{ env.DOTNET_VERSION }} - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get version - id: get_version - run: | - inputVersion="${{ inputs.version_override }}" - if [[ "${#inputVersion}" -gt 0 ]] - then - version="${inputVersion}" - else - version="$(grep -Eio -m 1 '.*' ./Source/AppScaffolding/AppScaffolding.csproj | sed -r 's/<\/?Version>//g')" - fi - echo "version=${version}" >> "${GITHUB_OUTPUT}" + dotnet-version: ${{ inputs.dotnet-version }} + dotnet-quality: "ga" - name: Unit test - if: ${{ inputs.run_unit_tests }} + if: ${{ inputs.run-unit-tests }} working-directory: ./Source run: dotnet test @@ -70,63 +53,31 @@ jobs: id: publish working-directory: ./Source run: | - if [[ "${{ inputs.OS }}" == "MacOS" ]] - then - display_os="macOS" - RUNTIME_ID="osx-${{ inputs.architecture }}" - else - display_os="Linux" - RUNTIME_ID="linux-${{ inputs.architecture }}" - fi - - OUTPUT="bin/Publish/${display_os}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }}" - - echo "display_os=${display_os}" >> $GITHUB_OUTPUT - echo "Runtime Identifier: $RUNTIME_ID" - echo "Output Directory: $OUTPUT" - - dotnet publish \ - LibationAvalonia/LibationAvalonia.csproj \ - --runtime $RUNTIME_ID \ - --configuration ${{ env.DOTNET_CONFIGURATION }} \ - --output $OUTPUT \ - -p:PublishProfile=LibationAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml - dotnet publish \ - LoadByOS/${display_os}ConfigApp/${display_os}ConfigApp.csproj \ - --runtime $RUNTIME_ID \ - --configuration ${{ env.DOTNET_CONFIGURATION }} \ - --output $OUTPUT \ - -p:PublishProfile=LoadByOS/Properties/${display_os}ConfigApp/PublishProfiles/${display_os}Profile.pubxml - dotnet publish \ - LibationCli/LibationCli.csproj \ - --runtime $RUNTIME_ID \ - --configuration ${{ env.DOTNET_CONFIGURATION }} \ - --output $OUTPUT \ - -p:PublishProfile=LibationCli/Properties/PublishProfiles/${display_os}Profile.pubxml - dotnet publish \ - HangoverAvalonia/HangoverAvalonia.csproj \ - --runtime $RUNTIME_ID \ - --configuration ${{ env.DOTNET_CONFIGURATION }} \ - --output $OUTPUT \ - -p:PublishProfile=HangoverAvalonia/Properties/PublishProfiles/${display_os}Profile.pubxml + PUBLISH_ARGS=( + '--runtime' '${{ env.RUNTIME_ID }}' + '--configuration' 'Release' + '--output' '../bin' + '-p:PublishProtocol=FileSystem' + "-p:PublishReadyToRun=${{ inputs.publish-r2r }}" + '-p:SelfContained=true') + + dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}" + dotnet publish LoadByOS/LinuxConfigApp/LinuxConfigApp.csproj "${PUBLISH_ARGS[@]}" + dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}" + dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}" - name: Build bundle id: bundle - working-directory: ./Source/bin/Publish/${{ steps.publish.outputs.display_os }}-${{ inputs.architecture }}-${{ env.RELEASE_NAME }} run: | - BUNDLE_DIR=$(pwd) - echo "Bundle dir: ${BUNDLE_DIR}" - cd .. - SCRIPT=../../../Scripts/Bundle_${{ inputs.OS }}.sh + SCRIPT=./Scripts/Bundle_${{ inputs.OS }}.sh chmod +rx ${SCRIPT} - ${SCRIPT} "${BUNDLE_DIR}" "${{ steps.get_version.outputs.version }}" "${{ inputs.architecture }}" + ${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" artifact=$(ls ./bundle) echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}" - - name: Publish bundle - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v5 with: name: ${{ steps.bundle.outputs.artifact }} - path: ./Source/bin/Publish/bundle/${{ steps.bundle.outputs.artifact }} + path: ./bundle/${{ steps.bundle.outputs.artifact }} if-no-files-found: error - retention-days: 7 + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/build-mac.yml b/.github/workflows/build-mac.yml new file mode 100644 index 00000000..7681b26d --- /dev/null +++ b/.github/workflows/build-mac.yml @@ -0,0 +1,104 @@ +# build-mac.yml +# Reusable workflow that builds the MacOS (x64 and arm64) versions of Libation. +--- +name: build + +on: + workflow_call: + inputs: + libation-version: + type: string + required: true + dotnet-version: + type: string + required: true + run-unit-tests: + type: boolean + publish-r2r: + type: boolean + retention-days: + type: number + sign-app: + type: boolean + description: "Wheather to sign an notorize the app bundle and dmg." + architecture: + type: string + description: "CPU architecture targeted by the build." + required: true + +jobs: + build: + name: "macOS-${{ inputs.architecture }}" + runs-on: macos-latest + env: + RUNTIME_ID: "osx-${{ inputs.architecture }}" + WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} + steps: + - uses: apple-actions/import-codesign-certs@v3 + if: ${{ inputs.sign-app }} + with: + p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }} + p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }} + + - uses: actions/checkout@v5 + + - uses: actions/setup-dotnet@v5 + with: + dotnet-version: ${{ inputs.dotnet-version }} + dotnet-quality: "ga" + + - name: Unit test + if: ${{ inputs.run-unit-tests }} + working-directory: ./Source + run: dotnet test + + - name: Publish + id: publish + working-directory: ./Source + run: | + PUBLISH_ARGS=( + '--runtime' '${{ env.RUNTIME_ID }}' + '--configuration' 'Release' + '--output' '../bin' + '-p:PublishProtocol=FileSystem' + "-p:PublishReadyToRun=${{ inputs.publish-r2r }}" + '-p:SelfContained=true') + + dotnet publish LibationAvalonia/LibationAvalonia.csproj "${PUBLISH_ARGS[@]}" + dotnet publish LoadByOS/MacOSConfigApp/MacOSConfigApp.csproj "${PUBLISH_ARGS[@]}" + dotnet publish LibationCli/LibationCli.csproj "${PUBLISH_ARGS[@]}" + dotnet publish HangoverAvalonia/HangoverAvalonia.csproj "${PUBLISH_ARGS[@]}" + + - name: Build bundle + id: bundle + run: | + SCRIPT=./Scripts/Bundle_MacOS.sh + chmod +rx ${SCRIPT} + ${SCRIPT} ./bin "${{ inputs.libation-version }}" "${{ inputs.architecture }}" "${{ inputs.sign-app }}" + artifact=$(ls ./bundle) + echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}" + + - name: Notarize bundle + if: ${{ inputs.sign-app }} + run: | + if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then + WAIT="--wait" + fi + echo "::debug::Submitting the disk image for notarization" + RESPONSE=$(xcrun notarytool submit ./bundle/${{ steps.bundle.outputs.artifact }} $WAIT --no-progress --apple-id ${{ vars.APPLE_DEV_EMAIL }} --password ${{ secrets.APPLE_DEV_PASSWORD }} --team-id ${{ secrets.APPLE_TEAM_ID }} 2>&1) + SUBMISSION_ID=$(echo "$RESPONSE" | awk '/id: / { print $2;exit; }') + + echo "$RESPONSE" + echo "::notice::Noraty Submission Id: $SUBMISSION_ID" + + if [ ${{ vars.WAIT_FOR_NOTARIZE == 'true' }} ]; then + echo "::debug::Stapling the notarization ticket to the disk image" + xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}" + fi + + - uses: actions/upload-artifact@v5 + with: + name: ${{ steps.bundle.outputs.artifact }} + path: ./bundle/${{ steps.bundle.outputs.artifact }} + if-no-files-found: error + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/build-windows.yml b/.github/workflows/build-windows.yml index 725ea5a7..62b3a343 100644 --- a/.github/workflows/build-windows.yml +++ b/.github/workflows/build-windows.yml @@ -6,113 +6,77 @@ name: build on: workflow_call: inputs: - version_override: + libation-version: type: string - description: "Version number override" - required: false - run_unit_tests: - type: boolean - description: "Skip running unit tests" - required: false - default: true - architecture: - type: string - description: "CPU architecture targeted by the build." required: true - -env: - DOTNET_CONFIGURATION: "Release" - DOTNET_VERSION: "9.0.x" + dotnet-version: + type: string + required: true + run-unit-tests: + type: boolean + publish-r2r: + type: boolean + retention-days: + type: number jobs: build: - name: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}" + name: "Windows-${{ matrix.release_name }}-x64" runs-on: windows-latest - env: - OUTPUT_NAME: "${{ matrix.os }}-${{ matrix.release_name }}-${{ inputs.architecture }}" - RUNTIME_ID: "win-${{ inputs.architecture }}" strategy: matrix: - os: [Windows] ui: [Avalonia] release_name: [chardonnay] include: - - os: Windows - ui: WinForms + - ui: WinForms release_name: classic prefix: Classic- steps: - uses: actions/checkout@v5 - - name: Setup .NET - uses: actions/setup-dotnet@v5 + + - uses: actions/setup-dotnet@v5 with: - dotnet-version: ${{ env.DOTNET_VERSION }} - env: - NUGET_AUTH_TOKEN: ${{ secrets.GITHUB_TOKEN }} - - - name: Get version - id: get_version - run: | - if ("${{ inputs.version_override }}".length -gt 0) { - $version = "${{ inputs.version_override }}" - } else { - $version = (Select-Xml -Path "./Source/AppScaffolding/AppScaffolding.csproj" -XPath "/Project/PropertyGroup/Version").Node.InnerXML.Trim() - } - "version=$version" >> $env:GITHUB_OUTPUT + dotnet-version: ${{ inputs.dotnet-version }} + dotnet-quality: "ga" - name: Unit test - if: ${{ inputs.run_unit_tests }} + if: ${{ inputs.run-unit-tests }} working-directory: ./Source run: dotnet test - + - name: Publish working-directory: ./Source run: | - dotnet publish ` - Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj ` - --runtime ${{ env.RUNTIME_ID }} ` - --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ env.OUTPUT_NAME }} ` - -p:PublishProfile=Libation${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml - dotnet publish ` - LoadByOS/${{ matrix.os }}ConfigApp/${{ matrix.os }}ConfigApp.csproj ` - --runtime ${{ env.RUNTIME_ID }} ` - --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ env.OUTPUT_NAME }} ` - -p:PublishProfile=LoadByOS/${{ matrix.os }}ConfigApp/PublishProfiles/${{ matrix.os }}Profile.pubxml - dotnet publish ` - LibationCli/LibationCli.csproj ` - --runtime ${{ env.RUNTIME_ID }} ` - --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ env.OUTPUT_NAME }} ` - -p:DefineConstants="${{ matrix.release_name }}" ` - -p:PublishProfile=LibationCli/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml - dotnet publish ` - Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj ` - --runtime ${{ env.RUNTIME_ID }} ` - --configuration ${{ env.DOTNET_CONFIGURATION }} ` - --output bin/Publish/${{ env.OUTPUT_NAME }} ` - -p:PublishProfile=Hangover${{ matrix.ui }}/Properties/PublishProfiles/${{ matrix.os }}Profile.pubxml + $PUBLISH_ARGS=@( + "--runtime", "win-x64", + "--configuration", "Release", + "--output", "../bin", + "-p:PublishProtocol=FileSystem", + "-p:PublishReadyToRun=${{ inputs.publish-r2r }}", + "-p:SelfContained=true") + + dotnet publish "Libation${{ matrix.ui }}/Libation${{ matrix.ui }}.csproj" $PUBLISH_ARGS + dotnet publish "LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj" $PUBLISH_ARGS + dotnet publish "LibationCli/LibationCli.csproj" $PUBLISH_ARGS + dotnet publish "Hangover${{ matrix.ui }}/Hangover${{ matrix.ui }}.csproj" $PUBLISH_ARGS - name: Zip artifact id: zip - working-directory: ./Source/bin/Publish + working-directory: ./bin run: | - $bin_dir = "${{ env.OUTPUT_NAME }}\" $delfiles = @( "WindowsConfigApp.exe", "WindowsConfigApp.runtimeconfig.json", - "WindowsConfigApp.deps.json" - ) - foreach ($file in $delfiles){ if (test-path $bin_dir$file){ Remove-Item $bin_dir$file } } - $artifact="${{ matrix.prefix }}Libation.${{ steps.get_version.outputs.version }}-" + "${{ matrix.os }}".ToLower() + "-${{ matrix.release_name }}-${{ inputs.architecture }}" + "WindowsConfigApp.deps.json") + + foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } } + $artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-x64.zip" "artifact=$artifact" >> $env:GITHUB_OUTPUT - Compress-Archive -Path "${bin_dir}*" -DestinationPath "$artifact.zip" + Compress-Archive -Path * -DestinationPath "$artifact" - - name: Publish artifact - uses: actions/upload-artifact@v5 + - uses: actions/upload-artifact@v5 with: - name: ${{ steps.zip.outputs.artifact }}.zip - path: ./Source/bin/Publish/${{ steps.zip.outputs.artifact }}.zip + name: ${{ steps.zip.outputs.artifact }} + path: ./bin/${{ steps.zip.outputs.artifact }} if-no-files-found: error - retention-days: 7 + retention-days: ${{ inputs.retention-days }} diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 341f8033..fe4a90cc 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -6,26 +6,51 @@ name: build on: workflow_call: inputs: - version_override: + libation-version: type: string - description: "Version number override" - required: false - run_unit_tests: + description: "Libation version number" + required: true + dotnet-version: + type: string + default: "9.x" + description: ".NET version to target" + run-unit-tests: type: boolean - description: "Skip running unit tests" - required: false - default: true + description: "Whether to run unit tests prior to publishing." + publish-r2r: + type: boolean + description: "Whether to publish assemblies as ReadyToRun." + release: + type: boolean + description: "Whether this workflow is being called as a release" + retention-days: + type: number + description: "Number of days the artifacts are to be retained." -jobs: +jobs: windows: - strategy: - matrix: - architecture: [x64] uses: ./.github/workflows/build-windows.yml with: - version_override: ${{ inputs.version_override }} - run_unit_tests: ${{ inputs.run_unit_tests }} + libation-version: ${{ inputs.libation-version }} + dotnet-version: ${{ inputs.dotnet-version }} + run-unit-tests: ${{ inputs.run-unit-tests }} + publish-r2r: ${{ inputs.publish-r2r }} + retention-days: ${{ inputs.retention-days }} + + macOS: + strategy: + matrix: + architecture: [x64, arm64] + uses: ./.github/workflows/build-mac.yml + with: + libation-version: ${{ inputs.libation-version }} + dotnet-version: ${{ inputs.dotnet-version }} + run-unit-tests: ${{ inputs.run-unit-tests }} + publish-r2r: ${{ inputs.publish-r2r }} + retention-days: ${{ inputs.retention-days }} architecture: ${{ matrix.architecture }} + sign-app: ${{ inputs.release || vars.SIGN_MAC_APP_ON_VALIDATE == 'true' }} + secrets: inherit linux: strategy: @@ -34,20 +59,11 @@ jobs: architecture: [x64, arm64] uses: ./.github/workflows/build-linux.yml with: - version_override: ${{ inputs.version_override }} - runs_on: ubuntu-latest + libation-version: ${{ inputs.libation-version }} + dotnet-version: ${{ inputs.dotnet-version }} + run-unit-tests: ${{ inputs.run-unit-tests }} + publish-r2r: ${{ inputs.publish-r2r }} + retention-days: ${{ inputs.retention-days }} + architecture: ${{ matrix.architecture }} OS: ${{ matrix.OS }} - architecture: ${{ matrix.architecture }} - run_unit_tests: ${{ inputs.run_unit_tests }} - macos: - strategy: - matrix: - architecture: [x64, arm64] - uses: ./.github/workflows/build-linux.yml - with: - version_override: ${{ inputs.version_override }} - runs_on: macos-latest - OS: MacOS - architecture: ${{ matrix.architecture }} - run_unit_tests: ${{ inputs.run_unit_tests }} diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 244b6e75..0a575e2e 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -8,7 +8,7 @@ on: - "v*" jobs: prerelease: - runs-on: ubuntu-latest + runs-on: ubuntu-slim outputs: version: ${{ steps.get_version.outputs.version }} steps: @@ -31,9 +31,11 @@ jobs: build: needs: [prerelease] uses: ./.github/workflows/build.yml + secrets: inherit with: - version_override: ${{ needs.prerelease.outputs.version }} - run_unit_tests: false + libation-version: ${{ needs.prerelease.outputs.version }} + publish-r2r: true + release: true release: needs: [prerelease, build] diff --git a/.github/workflows/validate.yml b/.github/workflows/validate.yml index 27abc275..892b2680 100644 --- a/.github/workflows/validate.yml +++ b/.github/workflows/validate.yml @@ -10,12 +10,31 @@ on: branches: [master] jobs: + get_version: + runs-on: ubuntu-slim + outputs: + version: ${{ steps.get_version.outputs.version }} + steps: + - name: Get version + id: get_version + run: | + wget "https://raw.githubusercontent.com/${{ github.repository }}/${{ github.sha }}/Source/AppScaffolding/AppScaffolding.csproj" + version="$(grep -Eio -m 1 '.*' ./AppScaffolding.csproj | sed -r 's/<\/?Version>//g')" + echo "version=${version}" >> "${GITHUB_OUTPUT}" build: + needs: [get_version] uses: ./.github/workflows/build.yml + secrets: inherit + with: + libation-version: ${{ needs.get_version.outputs.version }} + retention-days: 14 + run-unit-tests: true + docker: + needs: [get_version] uses: ./.github/workflows/docker.yml with: - version: ${GITHUB_SHA} + version: ${{ needs.get_version.outputs.version }} release: false secrets: docker_username: ${{ secrets.DOCKERHUB_USERNAME }} diff --git a/.gitignore b/.gitignore index ba279ef4..41375dd9 100644 --- a/.gitignore +++ b/.gitignore @@ -370,4 +370,10 @@ FodyWeavers.xsd /__TODO.txt /DataLayer/LibationContext.db -*/bin-Avalonia \ No newline at end of file +*/bin-Avalonia + +# macOS Directory Info +.DS_Store + +# JetBrains Rider Settings +**/.idea/ \ No newline at end of file diff --git a/.releaseindex.json b/.releaseindex.json index 7ae58e51..a709e453 100644 --- a/.releaseindex.json +++ b/.releaseindex.json @@ -3,8 +3,8 @@ "WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip", "LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb", "LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm", - "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.tgz", + "MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg", "LinuxAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.deb", "LinuxAvalonia_Arm64_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-arm64\\.rpm", - "MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.tgz" + "MacOSAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-arm64\\.dmg" } diff --git a/Documentation/Advanced.md b/Documentation/Advanced.md index 4c438c08..b4463f2b 100644 --- a/Documentation/Advanced.md +++ b/Documentation/Advanced.md @@ -10,12 +10,10 @@ - [Files and folders](#files-and-folders) - [Settings](#settings) - [Custom File Naming](NamingTemplates.md) -- [Command Line Interface](#command-line-interface) - [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only) +- [Command Line Interface](#command-line-interface) - [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md) - - ### Files and folders To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files. @@ -39,59 +37,6 @@ In addition to the options that are enabled if you allow Libation to "fix up" th * Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API. * Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible -### Command Line Interface - -Libationcli.exe allows limited access to Libation's functionalities as a CLI. - -Warnings about relying solely on on the CLI: -* CLI will not perform any upgrades. -* It will show that there is an upgrade, but that will likely scroll by too fast to notice. -* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI. - -``` -help - libationcli --help - -verb-specific help - libationcli scan --help - -scan all libraries - libationcli scan -scan only libraries for specific accounts - libationcli scan nickname1 nickname2 - -convert all m4b files to mp3 - libationcli convert - -liberate all books and pdfs - libationcli liberate -liberate pdfs only - libationcli liberate --pdf - libationcli liberate -p - -Copy the local sqlite database to postgres - libationcli copydb --connectionString "my postgres connection string" - libationcli copydb -c "my postgres connection string" - -export library to file - libationcli export --path "C:\foo\bar\my.json" --json - libationcli export -p "C:\foo\bar\my.json" -j - libationcli export -p "C:\foo\bar\my.csv" --csv - libationcli export -p "C:\foo\bar\my.csv" -c - libationcli export -p "C:\foo\bar\my.xlsx" --xlsx - libationcli export -p "C:\foo\bar\my.xlsx" -x - -Set download statuses throughout library based on whether each book's audio file can be found. -Must include at least one flag: --downloaded , --not-downloaded. -Downloaded: If the audio file can be found, set download status to 'Downloaded'. -Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded' -UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes -CLI: Full library. No prompt - - libationcli set-status -d - libationcli set-status -n - libationcli set-status -d -n -``` ### Custom Theme Colors In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors". @@ -113,4 +58,102 @@ The below video demonstrates using the theme editor to make changes to the Dark [](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349) +### Command Line Interface +Libationcli.exe allows limited access to Libation's functionalities as a CLI. + +Warnings about relying solely on on the CLI: +* CLI will not perform any upgrades. +* It will show that there is an upgrade, but that will likely scroll by too fast to notice. +* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI. + +#### Help +```console +libationcli --help +``` +#### Verb-Specific Help +```console +libationcli scan --help +``` +#### Scan All Libraries +```console +libationcli scan +``` +#### Scan Only Libraries for Specific Accounts +```console +libationcli scan nickname1 nickname2 +``` +#### Convert All m4b Files to mp3 +```console +libationcli convert +``` +#### Liberate All Books and Pdfs +```console +libationcli liberate +``` +#### Liberate Pdfs Only +```console +libationcli liberate --pdf +libationcli liberate -p +``` +#### Force Book(s) to Re-Liberate +```console +libationcli liberate --force +libationcli liberate -f +``` +#### List Libation Settings +```console +libationcli get-setting +libationcli get-setting -b +libationcli get-setting FileDownloadQuality +``` +#### Override Libation Settings for the Command +```console +libationcli liberate B017V4IM1G -override FileDownloadQuality=Normal +libationcli liberate B017V4IM1G -o FileDownloadQuality=normal -o UseWidevine=true Request_xHE_AAC=true -f +``` +#### Copy the Local SQLite Database to Postgres +```console +libationcli copydb --connectionString "my postgres connection string" +libationcli copydb -c "my postgres connection string" +``` +#### Export Library to File +```console +libationcli export --path "C:\foo\bar\my.json" --json +libationcli export -p "C:\foo\bar\my.json" -j +libationcli export -p "C:\foo\bar\my.csv" --csv +libationcli export -p "C:\foo\bar\my.csv" -c +libationcli export -p "C:\foo\bar\my.xlsx" --xlsx +libationcli export -p "C:\foo\bar\my.xlsx" -x +``` +#### Set Download Status +Set download statuses throughout library based on whether each book's audio file can be found. +Must include at least one flag: --downloaded , --not-downloaded. +Downloaded: If the audio file can be found, set download status to 'Downloaded'. +Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded' +UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes +CLI: Full library. No prompt + +```console +libationcli set-status -d +libationcli set-status -n +libationcli set-status -d -n +``` +#### Get a Content License Without Downloading +```console +libationcli get-license B017V4IM1G +``` +#### Example Powershell Script to Download Four Differenf Versions f the Same Book +```powershell +$asin="B017V4IM1G" + +$xHE_AAC=@('true', 'false') +$Qualities=@('Normal', 'High') +foreach($q in $Qualities){ + foreach($x in $xHE_AAC){ + $license = ./libationcli get-license $asin --override FileDownloadQuality=$q --override Request_xHE_AAC=$x + echo $($license | ConvertFrom-Json).ContentMetadata.content_reference + echo $license | ./libationcli liberate --force + } +} +``` diff --git a/Scripts/Bundle_Debian.sh b/Scripts/Bundle_Debian.sh index b69eb76f..2a5d5034 100644 --- a/Scripts/Bundle_Debian.sh +++ b/Scripts/Bundle_Debian.sh @@ -28,14 +28,6 @@ then exit fi -contains() { case "$1" in *"$2"*) true ;; *) false ;; esac } - -if ! contains "$BIN_DIR" "$ARCH" -then - echo "This script must be called with a Libation binaries for ${ARCH}." - exit -fi - ARCH=$(echo $ARCH | sed 's/x64/amd64/') DEB_DIR=./deb diff --git a/Scripts/Bundle_MacOS.sh b/Scripts/Bundle_MacOS.sh index 7b5f6a0b..d023a10b 100644 --- a/Scripts/Bundle_MacOS.sh +++ b/Scripts/Bundle_MacOS.sh @@ -3,6 +3,7 @@ BIN_DIR=$1; shift VERSION=$1; shift ARCH=$1; shift +SIGN_WITH_KEY=$1; shift if [ -z "$BIN_DIR" ] then @@ -28,12 +29,9 @@ then exit fi -contains() { case "$1" in *"$2"*) true ;; *) false ;; esac } - -if ! contains "$BIN_DIR" $ARCH +if [ "$SIGN_WITH_KEY" != "true" ] then - echo "This script must be called with a Libation binaries for ${ARCH}." - exit + echo "::warning:: App will fail Gatekeeper verification without valid Apple Team information." fi BUNDLE=./Libation.app @@ -74,6 +72,15 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns echo "Moving Info.plist file..." mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist +echo "Moving Libation_DS_Store file..." +mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store + +echo "Moving background.png file..." +mv $BUNDLE_MACOS/background.png ./background.png + +echo "Moving background.png file..." +mv $BUNDLE_MACOS/Libation.entitlements ./Libation.entitlements + PLIST_ARCH=$(echo $ARCH | sed 's/x64/x86_64/') echo "Set LSArchitecturePriority to $PLIST_ARCH" sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist @@ -81,27 +88,45 @@ sed -i -e "s/ARCHITECTURE_STRING/$PLIST_ARCH/" $BUNDLE_CONTENTS/Info.plist echo "Set CFBundleVersion to $VERSION" sed -i -e "s/VERSION_STRING/$VERSION/" $BUNDLE_CONTENTS/Info.plist - delfiles=('MacOSConfigApp' 'MacOSConfigApp.deps.json' 'MacOSConfigApp.runtimeconfig.json') - for n in "${delfiles[@]}" do echo "Deleting $n" rm $BUNDLE_MACOS/$n done -APP_FILE=Libation.${VERSION}-macOS-chardonnay-${ARCH}.tgz +DMG_FILE="Libation.${VERSION}-macOS-chardonnay-${ARCH}.dmg" -echo "Signing executables in: $BUNDLE" -codesign --force --deep -s - $BUNDLE +all_identities=$(security find-identity -v -p codesigning) +identity=$(echo ${all_identities} | sed -n 's/.*"\(.*\)".*/\1/p') -echo "Creating app bundle: $APP_FILE" -tar -czvf $APP_FILE $BUNDLE +if [ "$SIGN_WITH_KEY" == "true" ]; then + echo "Signing executables in: $BUNDLE" + codesign --force --deep --timestamp --options=runtime --entitlements "./Libation.entitlements" --sign "${identity}" "$BUNDLE" + codesign --verify --verbose "$BUNDLE" +else + echo "Signing with empty key: $BUNDLE" + codesign --force --deep -s - $BUNDLE +fi -mkdir bundle -echo "moving to ./bundle/$APP_FILE" -mv $APP_FILE ./bundle/$APP_FILE +echo "Creating app disk image: $DMG_FILE" +mkdir Libation +mv $BUNDLE ./Libation/$BUNDLE +mv Libation_DS_Store Libation/.DS_Store +mkdir Libation/.background +mv background.png Libation/.background/ +ln -s /Applications "./Libation/ " +mkdir ./bundle +hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE" +# Create a .DS_Store by: +# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg) +# - Open the folder and edit it to your liking. +# - Copy the .DS_Store from the directory and save it to Libation_DS_Store -rm -r $BUNDLE + +if [ "$SIGN_WITH_KEY" == "true" ]; then + echo "Signing $DMG_FILE" + codesign --deep --sign "${identity}" "./bundle/$DMG_FILE" +fi echo "Done!" diff --git a/Scripts/Bundle_Redhat.sh b/Scripts/Bundle_Redhat.sh index 05d27ac6..e79009f9 100644 --- a/Scripts/Bundle_Redhat.sh +++ b/Scripts/Bundle_Redhat.sh @@ -28,14 +28,6 @@ then exit fi -contains() { case "$1" in *"$2"*) true ;; *) false ;; esac } - -if ! contains "$BIN_DIR" "$ARCH" -then - echo "This script must be called with a Libation binaries for ${ARCH}." - exit -fi - BASEDIR=$(pwd) delfiles=('LinuxConfigApp' 'LinuxConfigApp.deps.json' 'LinuxConfigApp.runtimeconfig.json') diff --git a/Source/AaxDecrypter/IDownloadOptions.cs b/Source/AaxDecrypter/IDownloadOptions.cs index 11e79db8..7f1199c3 100644 --- a/Source/AaxDecrypter/IDownloadOptions.cs +++ b/Source/AaxDecrypter/IDownloadOptions.cs @@ -15,6 +15,7 @@ namespace AaxDecrypter KeyPart2 = keyPart2; } + [Newtonsoft.Json.JsonConstructor] public KeyData(string keyPart1, string? keyPart2 = null) { ArgumentNullException.ThrowIfNull(keyPart1, nameof(keyPart1)); diff --git a/Source/AaxDecrypter/NetworkFileStream.cs b/Source/AaxDecrypter/NetworkFileStream.cs index 769a76fc..ae9d961a 100644 --- a/Source/AaxDecrypter/NetworkFileStream.cs +++ b/Source/AaxDecrypter/NetworkFileStream.cs @@ -306,7 +306,7 @@ namespace AaxDecrypter if (WritePosition > endPosition) throw new WebException($"Downloaded size (0x{WritePosition:X10}) is greater than {nameof(ContentLength)} (0x{ContentLength:X10})."); } - catch (TaskCanceledException) + catch (OperationCanceledException) { Serilog.Log.Information("Download was cancelled"); } @@ -402,7 +402,7 @@ namespace AaxDecrypter */ protected override void Dispose(bool disposing) { - if (disposing && !disposed) + if (disposing && !Interlocked.CompareExchange(ref disposed, true, false)) { _cancellationSource.Cancel(); DownloadTask?.GetAwaiter().GetResult(); @@ -413,7 +413,6 @@ namespace AaxDecrypter OnUpdate(waitForWrite: true); } - disposed = true; base.Dispose(disposing); } diff --git a/Source/AppScaffolding/LibationScaffolding.cs b/Source/AppScaffolding/LibationScaffolding.cs index 2b512e81..7ab25d22 100644 --- a/Source/AppScaffolding/LibationScaffolding.cs +++ b/Source/AppScaffolding/LibationScaffolding.cs @@ -79,8 +79,17 @@ namespace AppScaffolding } /// most migrations go in here - public static void RunPostConfigMigrations(Configuration config) + public static void RunPostConfigMigrations(Configuration config, bool ephemeralSettings = false) { + if (ephemeralSettings) + { + var settings = JObject.Parse(File.ReadAllText(config.LibationFiles.SettingsFilePath)); + config.LoadEphemeralSettings(settings); + } + else + { + config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath); + } AudibleApiStorage.EnsureAccountsSettingsFileExists(); // @@ -150,7 +159,7 @@ namespace AppScaffolding new JObject { // for this sink to work, a path must be provided. we override this below - { "path", Path.Combine(config.LibationFiles, "Log.log") }, + { "path", Path.Combine(config.LibationFiles.Location, "Log.log") }, { "rollingInterval", "Month" }, // Serilog template formatting examples // - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}" @@ -433,8 +442,8 @@ namespace AppScaffolding const string FILENAME_V1 = "FileLocations.json"; const string FILENAME_V2 = "FileLocationsV2.json"; - var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V1); - var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles, FILENAME_V2); + var jsonFileV1 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V1); + var jsonFileV2 = Path.Combine(Configuration.Instance.LibationFiles.Location, FILENAME_V2); if (!File.Exists(jsonFileV2) && File.Exists(jsonFileV1)) { diff --git a/Source/AppScaffolding/UNSAFE_MigrationHelper.cs b/Source/AppScaffolding/UNSAFE_MigrationHelper.cs index 63327778..1df02c43 100644 --- a/Source/AppScaffolding/UNSAFE_MigrationHelper.cs +++ b/Source/AppScaffolding/UNSAFE_MigrationHelper.cs @@ -7,6 +7,7 @@ using LibationFileManager; using Newtonsoft.Json; using Newtonsoft.Json.Linq; +#nullable enable namespace AppScaffolding { /// @@ -20,21 +21,21 @@ namespace AppScaffolding /// public static class UNSAFE_MigrationHelper { - public static string SettingsDirectory - => !APPSETTINGS_TryGet(LIBATION_FILES_KEY, out var value) || value is null + public static string? SettingsDirectory + => !APPSETTINGS_TryGet(LibationFiles.LIBATION_FILES_KEY, out var value) || value is null ? null : value; #region appsettings.json - public static bool APPSETTINGS_TryGet(string key, out string value) + public static bool APPSETTINGS_TryGet(string key, out string? value) { bool success = false; - JToken val = null; + JToken? val = null; process_APPSETTINGS_Json(jObj => success = jObj.TryGetValue(key, out val), false); - value = success ? val.Value() : null; + value = success ? val?.Value() : null; return success; } @@ -59,7 +60,10 @@ namespace AppScaffolding /// True: save if contents changed. False: no not attempt save private static void process_APPSETTINGS_Json(Action action, bool save = true) { - var startingContents = File.ReadAllText(Configuration.AppsettingsJsonFile); + if (Configuration.Instance.LibationFiles.AppsettingsJsonFile is not string appSettingsFile) + return; + + var startingContents = File.ReadAllText(appSettingsFile); JObject jObj; try @@ -82,40 +86,37 @@ namespace AppScaffolding if (startingContents.EqualsInsensitive(endingContents_indented) || startingContents.EqualsInsensitive(endingContents_compact)) return; - File.WriteAllText(Configuration.AppsettingsJsonFile, endingContents_indented); + File.WriteAllText(Configuration.Instance.LibationFiles.AppsettingsJsonFile, endingContents_indented); System.Threading.Thread.Sleep(100); } #endregion #region Settings.json - public const string LIBATION_FILES_KEY = "LibationFiles"; - private const string SETTINGS_JSON = "Settings.json"; - public static string SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, SETTINGS_JSON); - public static bool SettingsJson_Exists => SettingsJsonPath is not null && File.Exists(SettingsJsonPath); + public static string? SettingsJsonPath => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LibationFiles.SETTINGS_JSON); - public static bool Settings_TryGet(string key, out string value) + public static bool Settings_TryGet(string key, out string? value) { bool success = false; - JToken val = null; + JToken? val = null; process_SettingsJson(jObj => success = jObj.TryGetValue(key, out val), false); - value = success ? val.Value() : null; + value = success ? val?.Value() : null; return success; } public static bool Settings_JsonPathIsType(string jsonPath, JTokenType jTokenType) { - JToken val = null; + JToken? val = null; process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false); return val?.Type == jTokenType; } - public static bool Settings_TryGetFromJsonPath(string jsonPath, out string value) + public static bool Settings_TryGetFromJsonPath(string jsonPath, out string? value) { - JToken val = null; + JToken? val = null; process_SettingsJson(jObj => val = jObj.SelectToken(jsonPath), false); @@ -157,10 +158,10 @@ namespace AppScaffolding if (!Settings_JsonPathIsType(jsonPath, JTokenType.Array)) return false; - JArray array = null; - process_SettingsJson(jObj => array = (JArray)jObj.SelectToken(jsonPath)); + JArray? array = null; + process_SettingsJson(jObj => array = jObj.SelectToken(jsonPath) as JArray); - length = array.Count; + length = array?.Count ?? 0; return true; } @@ -171,8 +172,7 @@ namespace AppScaffolding process_SettingsJson(jObj => { - var array = (JArray)jObj.SelectToken(jsonPath); - array.Add(newValue); + (jObj.SelectToken(jsonPath) as JArray)?.Add(newValue); }); } @@ -200,8 +200,7 @@ namespace AppScaffolding process_SettingsJson(jObj => { - var array = (JArray)jObj.SelectToken(jsonPath); - if (position < array.Count) + if (jObj.SelectToken(jsonPath) is JArray array && position < array.Count) array.RemoveAt(position); }); } @@ -228,7 +227,7 @@ namespace AppScaffolding private static void process_SettingsJson(Action action, bool save = true) { // only insert if not exists - if (!SettingsJson_Exists) + if (!File.Exists(SettingsJsonPath)) return; var startingContents = File.ReadAllText(SettingsJsonPath); @@ -260,7 +259,7 @@ namespace AppScaffolding #endregion #region LibationContext.db public const string LIBATION_CONTEXT = "LibationContext.db"; - public static string DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT); + public static string? DatabaseFile => SettingsDirectory is null ? null : Path.Combine(SettingsDirectory, LIBATION_CONTEXT); public static bool DatabaseFile_Exists => DatabaseFile is not null && File.Exists(DatabaseFile); #endregion } diff --git a/Source/ApplicationServices/LibraryCommands.cs b/Source/ApplicationServices/LibraryCommands.cs index f2e2a7cc..f750eaff 100644 --- a/Source/ApplicationServices/LibraryCommands.cs +++ b/Source/ApplicationServices/LibraryCommands.cs @@ -70,7 +70,7 @@ namespace ApplicationServices } catch (AudibleApi.Authentication.LoginFailedException lfEx) { - lfEx.SaveFiles(Configuration.Instance.LibationFiles); + lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location); // nuget Serilog.Exceptions would automatically log custom properties // However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement: @@ -150,7 +150,7 @@ namespace ApplicationServices } catch (AudibleApi.Authentication.LoginFailedException lfEx) { - lfEx.SaveFiles(Configuration.Instance.LibationFiles); + lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location); // nuget Serilog.Exceptions would automatically log custom properties // However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement: @@ -268,7 +268,7 @@ namespace ApplicationServices await using LogArchiver? archiver = Log.Logger.IsDebugEnabled() - ? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles, "LibraryScans.zip")) + ? openLogArchive(System.IO.Path.Combine(Configuration.Instance.LibationFiles.Location, "LibraryScans.zip")) : default; archiver?.DeleteAllButNewestN(20); diff --git a/Source/AudibleUtilities/AudibleApiStorage.cs b/Source/AudibleUtilities/AudibleApiStorage.cs index 2fb43e8b..f0d07005 100644 --- a/Source/AudibleUtilities/AudibleApiStorage.cs +++ b/Source/AudibleUtilities/AudibleApiStorage.cs @@ -25,7 +25,7 @@ namespace AudibleUtilities public static class AudibleApiStorage { - public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles, "AccountsSettings.json"); + public static string AccountsSettingsFile => Path.Combine(Configuration.Instance.LibationFiles.Location, "AccountsSettings.json"); public static event EventHandler LoadError; diff --git a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs index 4a3e60e1..5a2275ab 100644 --- a/Source/DataLayer/QueryObjects/LibraryBookQueries.cs +++ b/Source/DataLayer/QueryObjects/LibraryBookQueries.cs @@ -25,16 +25,17 @@ namespace DataLayer .Where(c => !c.Book.IsEpisodeParent() || includeParents) .ToList(); - public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId) - => context + public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId, bool caseSensative = true) + { + var libraryQuery + = context .LibraryBooks .AsNoTrackingWithIdentityResolution() - .GetLibraryBook(productId); + .GetLibrary(); - public static LibraryBook? GetLibraryBook(this IQueryable library, string productId) - => library - .GetLibrary() - .SingleOrDefault(lb => lb.Book.AudibleProductId == productId); + return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId) + : libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId); + } /// This is still IQueryable. YOU MUST CALL ToList() YOURSELF public static IQueryable GetLibrary(this IQueryable library) diff --git a/Source/FileLiberator/DownloadDecryptBook.cs b/Source/FileLiberator/DownloadDecryptBook.cs index de4d35d1..eeaf06c0 100644 --- a/Source/FileLiberator/DownloadDecryptBook.cs +++ b/Source/FileLiberator/DownloadDecryptBook.cs @@ -23,6 +23,11 @@ namespace FileLiberator private CancellationTokenSource? cancellationTokenSource; private AudiobookDownloadBase? abDownloader; + /// + /// Optional override to supply license info directly instead of querying the api based on Configuration options + /// + public DownloadOptions.LicenseInfo? LicenseInfo { get; set; } + public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists(); public override async Task CancelAsync() { @@ -44,7 +49,9 @@ namespace FileLiberator DownloadValidation(libraryBook); var api = await libraryBook.GetApiAsync(); - using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken); + + LicenseInfo ??= await DownloadOptions.GetDownloadLicenseAsync(api, libraryBook, Configuration.Instance, cancellationToken); + using var downloadOptions = DownloadOptions.BuildDownloadOptions(libraryBook, Configuration.Instance, LicenseInfo); var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken); if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile) diff --git a/Source/FileLiberator/DownloadOptions.Factory.cs b/Source/FileLiberator/DownloadOptions.Factory.cs index 9d4215b6..4c1a8eb4 100644 --- a/Source/FileLiberator/DownloadOptions.Factory.cs +++ b/Source/FileLiberator/DownloadOptions.Factory.cs @@ -4,9 +4,9 @@ using AudibleApi.Common; using AudibleUtilities.Widevine; using DataLayer; using Dinah.Core; -using DocumentFormat.OpenXml.Wordprocessing; using LibationFileManager; using NAudio.Lame; +using Newtonsoft.Json; using System; using System.Collections.Generic; using System.IO; @@ -21,9 +21,9 @@ namespace FileLiberator; public partial class DownloadOptions { /// - /// Initiate an audiobook download from the audible api. + /// Requests a download license from the Api using the Configuration settings to choose the appropriate content. /// - public static async Task InitiateDownloadAsync(Api api, Configuration config, LibraryBook libraryBook, CancellationToken token) + public static async Task GetDownloadLicenseAsync(Api api, LibraryBook libraryBook, Configuration config, CancellationToken token) { var license = await ChooseContent(api, libraryBook, config, token); Serilog.Log.Logger.Debug("Content License {@License}", new @@ -65,14 +65,20 @@ public partial class DownloadOptions license.ContentMetadata.ChapterInfo = metadata.ChapterInfo; token.ThrowIfCancellationRequested(); - return BuildDownloadOptions(libraryBook, config, license); + return license; } - private class LicenseInfo + public class LicenseInfo { - public DrmType DrmType { get; } - public ContentMetadata ContentMetadata { get; } - public KeyData[]? DecryptionKeys { get; } + public DrmType DrmType { get; set; } + public ContentMetadata ContentMetadata { get; set; } + public KeyData[]? DecryptionKeys { get; set; } + + [JsonConstructor] + private LicenseInfo() + { + ContentMetadata = null!; + } public LicenseInfo(ContentLicense license, IEnumerable? keys = null) { DrmType = license.DrmType; @@ -159,7 +165,10 @@ public partial class DownloadOptions } } - private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) + /// + /// Builds DownloadOptions from the given LibraryBook, Configuration, and LicenseInfo. + /// + public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo) { long chapterStartMs = config.StripAudibleBrandAudio diff --git a/Source/FileManager/FileSystemTest.cs b/Source/FileManager/FileSystemTest.cs index eef4ea28..c8be82bf 100644 --- a/Source/FileManager/FileSystemTest.cs +++ b/Source/FileManager/FileSystemTest.cs @@ -1,6 +1,7 @@ using System; using System.IO; +#nullable enable namespace FileManager { public static class FileSystemTest @@ -15,8 +16,10 @@ namespace FileManager /// /// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, <, >, |). /// - public static bool CanWriteWindowsInvalidChars(LongPath directoryName) + public static bool CanWriteWindowsInvalidChars(LongPath? directoryName) { + if (directoryName is null) + return false; var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString()); return CanWriteFile(testFile); } @@ -24,8 +27,10 @@ namespace FileManager /// /// Test if the directory supports filenames with 255 unicode characters. /// - public static bool CanWrite255UnicodeChars(LongPath directoryName) + public static bool CanWrite255UnicodeChars(LongPath? directoryName) { + if (directoryName is null) + return false; const char unicodeChar = 'ü'; var testFileName = new string(unicodeChar, 255); var testFile = Path.Combine(directoryName, testFileName); diff --git a/Source/FileManager/FileUtility.cs b/Source/FileManager/FileUtility.cs index 137b817f..387178a1 100644 --- a/Source/FileManager/FileUtility.cs +++ b/Source/FileManager/FileUtility.cs @@ -263,5 +263,27 @@ namespace FileManager return foundFiles; } + + /// + /// Creates a subdirectory or subdirectories on the specified path. + /// The specified path can be relative to this instance of the class. + /// + /// Fixes an issue with where it fails when the parent is a drive root. + /// + /// The specified path. This cannot be a different disk volume or Universal Naming Convention (UNC) name. + /// The last directory specified in + public static DirectoryInfo CreateSubdirectoryEx(this DirectoryInfo parent, string path) + { + if (parent.Root.FullName != parent.FullName || Path.IsPathRooted(path)) + return parent.CreateSubdirectory(path); + + // parent is a drive root and subDirectory is relative + //Solves a problem with DirectoryInfo.CreateSubdirectory where it fails + //If the parent DirectoryInfo is a drive root. + var fullPath = Path.GetFullPath(Path.Combine(parent.FullName, path)); + var directoryInfo = new DirectoryInfo(fullPath); + directoryInfo.Create(); + return directoryInfo; + } } } diff --git a/Source/FileManager/IPersistentDictionary.cs b/Source/FileManager/IJsonBackedDictionary.cs similarity index 97% rename from Source/FileManager/IPersistentDictionary.cs rename to Source/FileManager/IJsonBackedDictionary.cs index 865733d1..ad508588 100644 --- a/Source/FileManager/IPersistentDictionary.cs +++ b/Source/FileManager/IJsonBackedDictionary.cs @@ -5,7 +5,7 @@ using System.Linq; #nullable enable namespace FileManager; -public interface IPersistentDictionary +public interface IJsonBackedDictionary { bool Exists(string propertyName); string? GetString(string propertyName, string? defaultValue = null); diff --git a/Source/FileManager/PersistentDictionary.cs b/Source/FileManager/PersistentDictionary.cs index 9bc86746..94f8012b 100644 --- a/Source/FileManager/PersistentDictionary.cs +++ b/Source/FileManager/PersistentDictionary.cs @@ -8,7 +8,7 @@ using Newtonsoft.Json.Linq; #nullable enable namespace FileManager { - public class PersistentDictionary : IPersistentDictionary + public class PersistentDictionary : IJsonBackedDictionary { public string Filepath { get; } public bool IsReadOnly { get; } @@ -59,7 +59,7 @@ namespace FileManager objectCache[propertyName] = defaultValue; return defaultValue; } - return IPersistentDictionary.UpCast(obj); + return IJsonBackedDictionary.UpCast(obj); } public object? GetObject(string propertyName) diff --git a/Source/Libation.sln b/Source/Libation.sln index a542ff36..95a4f7af 100644 --- a/Source/Libation.sln +++ b/Source/Libation.sln @@ -109,6 +109,8 @@ Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLayer.Postgres", "DataL EndProject Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "DataLayer.Sqlite", "DataLayer.Sqlite\DataLayer.Sqlite.csproj", "{1E689E85-279E-39D4-7D97-3E993FB6D95B}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "LibationUiBase.Tests", "_Tests\LibationUiBase.Tests\LibationUiBase.Tests.csproj", "{6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -239,6 +241,10 @@ Global {1E689E85-279E-39D4-7D97-3E993FB6D95B}.Debug|Any CPU.Build.0 = Debug|Any CPU {1E689E85-279E-39D4-7D97-3E993FB6D95B}.Release|Any CPU.ActiveCfg = Release|Any CPU {1E689E85-279E-39D4-7D97-3E993FB6D95B}.Release|Any CPU.Build.0 = Release|Any CPU + {6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Debug|Any CPU.Build.0 = Debug|Any CPU + {6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Release|Any CPU.ActiveCfg = Release|Any CPU + {6F9DB713-2879-4B14-9F9E-3B13C9B7F35C}.Release|Any CPU.Build.0 = Release|Any CPU EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -280,6 +286,7 @@ Global {CFE7A0E5-37FE-40BE-A70B-41B5104181C4} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} {0E480D2D-C7C1-A6FE-8C90-8A6F0DBCEAC2} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E} {1E689E85-279E-39D4-7D97-3E993FB6D95B} = {751093DD-5DBA-463E-ADBE-E05FAFB6983E} + {6F9DB713-2879-4B14-9F9E-3B13C9B7F35C} = {67E66E82-5532-4440-AFB3-9FB1DF9DEF53} EndGlobalSection GlobalSection(ExtensibilityGlobals) = postSolution SolutionGuid = {615E00ED-BAEF-4E8E-A92A-9B82D87942A9} diff --git a/Source/LibationAvalonia/App.axaml b/Source/LibationAvalonia/App.axaml index a2499ea8..75763c08 100644 --- a/Source/LibationAvalonia/App.axaml +++ b/Source/LibationAvalonia/App.axaml @@ -84,6 +84,9 @@ + + + diff --git a/Source/LibationAvalonia/App.axaml.cs b/Source/LibationAvalonia/App.axaml.cs index b2a7281b..9bf752c7 100644 --- a/Source/LibationAvalonia/App.axaml.cs +++ b/Source/LibationAvalonia/App.axaml.cs @@ -1,4 +1,5 @@ using ApplicationServices; +using AppScaffolding; using Avalonia; using Avalonia.Controls; using Avalonia.Controls.ApplicationLifetimes; @@ -12,6 +13,7 @@ using LibationAvalonia.Dialogs; using LibationAvalonia.Themes; using LibationAvalonia.Views; using LibationFileManager; +using LibationUiBase; using LibationUiBase.Forms; using System; using System.Collections.Generic; @@ -41,49 +43,70 @@ public class App : Application if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) { - // Chardonnay uses the OnLastWindowClose shutdown mode. As long as the application lifetime - // has one active window, the application will stay alive. Setup windows must be daisy chained, - // each closing windows opens the next window before closing itself to prevent the app from exiting. - + // Chardonnay uses the OnExplicitShutdown shutdown mode. The application will stay alive until + // Shutdown() is called on App.Current.ApplicationLifetime. MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) => MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition); // Avoid duplicate validations from both Avalonia and the CommunityToolkit. // More info: https://docs.avaloniaui.net/docs/guides/development-guides/data-validation#manage-validationplugins DisableAvaloniaDataAnnotationValidation(); - - Configuration config = Configuration.Instance; - - if (!config.LibationSettingsAreValid) - { - string defaultLibationFilesDir = Configuration.DefaultLibationFilesDirectory; - - // check for existing settings in default location - string defaultSettingsFile = Path.Combine(defaultLibationFilesDir, "Settings.json"); - if (Configuration.SettingsFileIsValid(defaultSettingsFile)) - Configuration.SetLibationFiles(defaultLibationFilesDir); - - if (config.LibationSettingsAreValid) - { - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - ShowMainWindow(desktop); - } - else - { - SetupDialog setupDialog = new() { Config = config }; - setupDialog.Closing += (_, e) => SetupClosing(setupDialog, desktop, e); - desktop.MainWindow = setupDialog; - } - } - else - { - ShowMainWindow(desktop); - } + RunSetupIfNeededAsync(desktop, Configuration.Instance); } base.OnFrameworkInitializationCompleted(); } + private static async void RunSetupIfNeededAsync(IClassicDesktopStyleApplicationLifetime desktop, Configuration config) + { + var setup = new LibationSetup(config.LibationFiles) + { + SetupPromptAsync =() => ShowSetupAsync(desktop), + SelectFolderPromptAsync = () => SelectInstallLocation(desktop, config.LibationFiles) + }; + if (await setup.RunSetupIfNeededAsync()) + { + // setup succeeded or wasn't needed and LibationFiles are valid + await RunMigrationsAsync(config); + LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); + ShowMainWindow(desktop); + } + else + { + await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); + desktop.Shutdown(-1); + } + } + + static async Task ShowSetupAsync(IClassicDesktopStyleApplicationLifetime desktop) + { + var tcs = new TaskCompletionSource(); + var setupDialog = new SetupDialog(); + desktop.MainWindow = setupDialog; + setupDialog.Closed += (_, _) => tcs.SetResult(setupDialog); + setupDialog.Show(); + return await tcs.Task; + } + + static async Task SelectInstallLocation(IClassicDesktopStyleApplicationLifetime desktop, LibationFiles libationFiles) + { + var tcs = new TaskCompletionSource(); + var libationFilesDialog = new LibationFilesDialog(libationFiles.Location.PathWithoutPrefix); + desktop.MainWindow = libationFilesDialog; + libationFilesDialog.Closed += (_, _) => tcs.SetResult(libationFilesDialog); + libationFilesDialog.Show(); + return await tcs.Task; + } + + private static async Task RunMigrationsAsync(Configuration config) + { + // most migrations go in here + LibationScaffolding.RunPostConfigMigrations(config); + await MessageBox.VerboseLoggingWarning_ShowIfTrue(); + // logging is init'd here + LibationScaffolding.RunPostMigrationScaffolding(Variety.Chardonnay, config); + } + private void DisableAvaloniaDataAnnotationValidation() { // Get an array of plugins to remove @@ -97,134 +120,6 @@ public class App : Application } } - private async void SetupClosing(SetupDialog setupDialog, IClassicDesktopStyleApplicationLifetime desktop, System.ComponentModel.CancelEventArgs e) - { - try - { - if (setupDialog.IsNewUser) - { - Configuration.SetLibationFiles(Configuration.DefaultLibationFilesDirectory); - setupDialog.Config.Books = Configuration.DefaultBooksDirectory; - - if (setupDialog.Config.LibationSettingsAreValid) - { - string? theme = setupDialog.SelectedTheme.Content as string; - setupDialog.Config.SetString(theme, nameof(ThemeVariant)); - - await RunMigrationsAsync(setupDialog.Config); - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - { - e.Cancel = true; - await CancelInstallation(setupDialog); - } - } - else if (setupDialog.IsReturningUser) - { - ShowLibationFilesDialog(desktop, setupDialog.Config); - } - else - { - e.Cancel = true; - await CancelInstallation(setupDialog); - } - } - catch (Exception ex) - { - string title = "Fatal error, pre-logging"; - string body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file."; - - MessageBoxAlertAdminDialog alert = new(body, title, ex); - desktop.MainWindow = alert; - alert.Show(); - } - } - - private void ShowLibationFilesDialog(IClassicDesktopStyleApplicationLifetime desktop, Configuration config) - { - LibationFilesDialog libationFilesDialog = new(); - desktop.MainWindow = libationFilesDialog; - libationFilesDialog.Show(); - - async void WindowClosing(object? sender, System.ComponentModel.CancelEventArgs e) - { - libationFilesDialog.Closing -= WindowClosing; - e.Cancel = true; - if (libationFilesDialog.DialogResult == DialogResult.OK) - OnLibationFilesCompleted(desktop, libationFilesDialog, config); - else - await CancelInstallation(libationFilesDialog); - } - libationFilesDialog.Closing += WindowClosing; - } - - private async void OnLibationFilesCompleted(IClassicDesktopStyleApplicationLifetime desktop, LibationFilesDialog libationFilesDialog, Configuration config) - { - Configuration.SetLibationFiles(libationFilesDialog.SelectedDirectory); - if (config.LibationSettingsAreValid) - { - await RunMigrationsAsync(config); - - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - { - // path did not result in valid settings - DialogResult continueResult = await MessageBox.Show( - libationFilesDialog, - $"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}", - "New install?", - MessageBoxButtons.YesNo, - MessageBoxIcon.Question); - - if (continueResult == DialogResult.Yes) - { - config.Books = Path.Combine(libationFilesDialog.SelectedDirectory, nameof(Configuration.Books)); - - if (config.LibationSettingsAreValid) - { - await RunMigrationsAsync(config); - LibraryTask = Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking(includeParents: true)); - AudibleUtilities.AudibleApiStorage.EnsureAccountsSettingsFileExists(); - ShowMainWindow(desktop); - } - else - { - await CancelInstallation(libationFilesDialog); - } - } - else - { - await CancelInstallation(libationFilesDialog); - } - } - - libationFilesDialog.Close(); - } - - private static async Task CancelInstallation(Window window) - { - await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning); - Environment.Exit(-1); - } - - private async Task RunMigrationsAsync(Configuration config) - { - // most migrations go in here - AppScaffolding.LibationScaffolding.RunPostConfigMigrations(config); - - await MessageBox.VerboseLoggingWarning_ShowIfTrue(); - - // logging is init'd here - AppScaffolding.LibationScaffolding.RunPostMigrationScaffolding(AppScaffolding.Variety.Chardonnay, config); - Program.LoggingEnabled = true; - } - private static void ShowMainWindow(IClassicDesktopStyleApplicationLifetime desktop) { Configuration.Instance.PropertyChanged += ThemeVariant_PropertyChanged; @@ -234,6 +129,7 @@ public class App : Application MainWindow mainWindow = new(); desktop.MainWindow = MainWindow = mainWindow; mainWindow.Loaded += MainWindow_Loaded; + mainWindow.Closed += (_, _) => desktop.Shutdown(); mainWindow.RestoreSizeAndLocation(Configuration.Instance); mainWindow.Show(); } diff --git a/Source/LibationAvalonia/Controls/Settings/Audio.axaml b/Source/LibationAvalonia/Controls/Settings/Audio.axaml index 54d77e86..333d7845 100644 --- a/Source/LibationAvalonia/Controls/Settings/Audio.axaml +++ b/Source/LibationAvalonia/Controls/Settings/Audio.axaml @@ -412,7 +412,7 @@