mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-09-08 11:47:57 -04:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
791425670b | ||
|
|
a40e9e7cf7 | ||
|
|
e36db71534 | ||
|
|
da23a7ddb0 | ||
|
|
16e6a6f659 | ||
|
|
742d2b140a | ||
|
|
befd1a4c3c | ||
|
|
e859af0296 | ||
|
|
eebc96f439 | ||
|
|
065118cf6c | ||
|
|
8ea95ee812 | ||
|
|
c144c20c53 | ||
|
|
7b09a3bfef | ||
|
|
e0d49b57e1 | ||
|
|
6ba0741393 | ||
|
|
ea3fc17cfa | ||
|
|
f64824e283 | ||
|
|
5334827c09 | ||
|
|
fcefc27a29 | ||
|
|
6bfcfb239c | ||
|
|
917f44fe57 | ||
|
|
134f895c82 | ||
|
|
4513a8007b | ||
|
|
8581c0e1af | ||
|
|
3872c10c81 | ||
|
|
712568b188 | ||
|
|
5d1a31f876 | ||
|
|
00fdd903aa | ||
|
|
8a61e4d7f4 | ||
|
|
c02563204d | ||
|
|
2e4c0a10b0 | ||
|
|
ccc814ce1c | ||
|
|
98f657a40b | ||
|
|
28ce71c1b7 | ||
|
|
a438dfd82f | ||
|
|
12875993fa | ||
|
|
40b75185c2 | ||
|
|
c5a88de8a6 | ||
|
|
cb1074e7fd | ||
|
|
f5ed61fa65 | ||
|
|
680e48638a | ||
|
|
b49384cfcf | ||
|
|
2b4cde62e2 | ||
|
|
636b85f8e4 | ||
|
|
961ee3372e | ||
|
|
7d9882c500 | ||
|
|
785ec7bf96 | ||
|
|
6c917ad751 | ||
|
|
5f4a75aade | ||
|
|
e78a4392f6 | ||
|
|
f12f5d42a1 | ||
|
|
79b6521a4e | ||
|
|
cdcbd28cca | ||
|
|
f35530d18b | ||
|
|
d8dd86123d | ||
|
|
0dfee1ff5c | ||
|
|
842b0c0933 | ||
|
|
1c5119e24d | ||
|
|
a6d637c718 | ||
|
|
9579c3157b | ||
|
|
31258c7951 | ||
|
|
16379168d4 | ||
|
|
6bc82f7c6e | ||
|
|
43216b2947 | ||
|
|
a079752c71 | ||
|
|
a2b4e432b5 | ||
|
|
272dcd87be | ||
|
|
6268fb0332 | ||
|
|
a7f6075a12 | ||
|
|
b5e87847ff | ||
|
|
ad61618cc9 | ||
|
|
3580f6d53a | ||
|
|
1493777396 | ||
|
|
0836a79990 | ||
|
|
2c52513ea9 | ||
|
|
20150cc4ed | ||
|
|
3e03fad490 | ||
|
|
abea874db8 | ||
|
|
bb18e9adc4 | ||
|
|
a9e6f75443 | ||
|
|
b5485f77bf | ||
|
|
936b307d3e | ||
|
|
1c33a253a2 | ||
|
|
b4f7cf4288 | ||
|
|
e1bede5a6a | ||
|
|
5c67351aba | ||
|
|
df6db12f77 | ||
|
|
89155e0b0b | ||
|
|
934e7c6618 | ||
|
|
e372bae144 | ||
|
|
270985341a | ||
|
|
fd03c1bd9c | ||
|
|
123da3c653 | ||
|
|
15838fb2cf | ||
|
|
746f620f95 | ||
|
|
1117eb378a | ||
|
|
07c2f2b2a1 | ||
|
|
a0c59f15e1 | ||
|
|
353a5b8800 | ||
|
|
190818323d | ||
|
|
f180430a3f | ||
|
|
dec86bdee1 | ||
|
|
6e4acd8046 | ||
|
|
80c6c6b9d4 | ||
|
|
3cdaa09aff | ||
|
|
ecc7e49a21 | ||
|
|
fb41955015 | ||
|
|
a8e3404500 | ||
|
|
9ff15a07aa | ||
|
|
674abd1707 | ||
|
|
d30086e53a | ||
|
|
82d4001f0a | ||
|
|
8ed4874c76 | ||
|
|
8898174507 | ||
|
|
2b4923112e | ||
|
|
d955cb7605 | ||
|
|
23f36e2ef1 | ||
|
|
46dc400b14 | ||
|
|
4f2e832327 | ||
|
|
4bd7796f70 | ||
|
|
b6d8378728 | ||
|
|
348056691f | ||
|
|
7bd14bd5af | ||
|
|
a85179f50a | ||
|
|
158ee3d5ba | ||
|
|
b654a8b551 | ||
|
|
bc28398e1c | ||
|
|
4d1b78f7e4 | ||
|
|
a50712256c | ||
|
|
4345971e81 | ||
|
|
1ad458fe5b | ||
|
|
1547e6bec3 | ||
|
|
81175e5427 | ||
|
|
14551c745f | ||
|
|
e3cfe3a8f0 | ||
|
|
51fbe73ea1 | ||
|
|
41fa7b7142 | ||
|
|
b7ce96e91d | ||
|
|
a25fd35f29 | ||
|
|
20a99c6e20 | ||
|
|
f39cfaccc7 | ||
|
|
6db78e4268 | ||
|
|
74e9cf24a8 | ||
|
|
6ce88072c5 | ||
|
|
9058c7368c | ||
|
|
37d5517756 | ||
|
|
0a33863635 | ||
|
|
d03955a2ca | ||
|
|
b2c5ae3c2e | ||
|
|
fb7e0857c6 | ||
|
|
60965902ca | ||
|
|
ef62ee15e2 | ||
|
|
c285522a0c | ||
|
|
0fb3eb152f | ||
|
|
63325b9cad | ||
|
|
37a60ca654 | ||
|
|
6623300883 | ||
|
|
329df0882a | ||
|
|
da000694d7 | ||
|
|
f1a7a837f3 | ||
|
|
869917e0ce | ||
|
|
d532e40fe9 | ||
|
|
9ef5e95801 | ||
|
|
c1c3c33ea8 | ||
|
|
b31e7cb819 | ||
|
|
314a46d59d | ||
|
|
721f09a62b | ||
|
|
545b3057d4 | ||
|
|
2c2ae2c8b0 | ||
|
|
f456c68176 | ||
|
|
e6c17053f7 | ||
|
|
0c22b821bf | ||
|
|
8902ddc565 | ||
|
|
9ed98cbfff | ||
|
|
3cf5d0b3fa | ||
|
|
6bd1ea7ca4 | ||
|
|
f8c5f0da68 | ||
|
|
a3f272a48e | ||
|
|
9ee59738eb | ||
|
|
f57839c104 | ||
|
|
d63fabd73d | ||
|
|
54a97cc3c4 | ||
|
|
1e3daa40dc |
No files matched your search
@@ -0,0 +1 @@
|
||||
*.sh text eol=lf
|
||||
@@ -0,0 +1 @@
|
||||
blank_issues_enabled: false
|
||||
@@ -37,9 +37,9 @@ jobs:
|
||||
env:
|
||||
RUNTIME_ID: "linux-${{ inputs.architecture }}"
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
@@ -40,9 +40,9 @@ jobs:
|
||||
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
|
||||
p12-password: ${{ secrets.DISTRIBUTION_SIGNING_CERT_PW }}
|
||||
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
|
||||
@@ -16,6 +16,10 @@ on:
|
||||
type: boolean
|
||||
publish-r2r:
|
||||
type: boolean
|
||||
build-installer:
|
||||
type: boolean
|
||||
description: "Build *-setup.exe on all Windows matrix jobs when release (zips always built for .releaseindex.json)"
|
||||
default: false
|
||||
retention-days:
|
||||
type: number
|
||||
|
||||
@@ -28,18 +32,20 @@ jobs:
|
||||
ui: [Avalonia]
|
||||
architecture: [x64]
|
||||
release_name: [chardonnay]
|
||||
artifact_stem: [Libation]
|
||||
include:
|
||||
- architecture: x64
|
||||
ui: WinForms
|
||||
release_name: classic
|
||||
prefix: Classic-
|
||||
artifact_stem: Libation-Classic
|
||||
- architecture: arm64
|
||||
ui: Avalonia
|
||||
release_name: chardonnay
|
||||
artifact_stem: Libation
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- uses: actions/setup-dotnet@v5
|
||||
- uses: actions/setup-dotnet@v6
|
||||
with:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
dotnet-quality: "ga"
|
||||
@@ -75,7 +81,7 @@ jobs:
|
||||
"WindowsConfigApp.deps.json")
|
||||
|
||||
foreach ($file in $delfiles){ if (test-path $file){ Remove-Item $file } }
|
||||
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
$artifact="${{ matrix.artifact_stem }}.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
|
||||
"artifact=$artifact" >> $env:GITHUB_OUTPUT
|
||||
Compress-Archive -Path * -DestinationPath "$artifact"
|
||||
|
||||
@@ -85,3 +91,40 @@ jobs:
|
||||
path: ./bin/${{ steps.zip.outputs.artifact }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
- name: Install Inno Setup
|
||||
if: ${{ inputs.build-installer }}
|
||||
run: choco install innosetup -y --no-progress
|
||||
|
||||
- name: Build Windows installer
|
||||
id: setup
|
||||
if: ${{ inputs.build-installer }}
|
||||
shell: pwsh
|
||||
run: |
|
||||
if ("${{ matrix.ui }}" -eq "WinForms") {
|
||||
$setupName = "Libation-Classic.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
|
||||
$ui = "WinForms"
|
||||
} else {
|
||||
$setupName = "Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}-setup.exe"
|
||||
$ui = "Avalonia"
|
||||
}
|
||||
./Scripts/Windows/Build-WindowsInstaller.ps1 `
|
||||
-Version "${{ inputs.libation-version }}" `
|
||||
-Ui $ui `
|
||||
-Architecture "${{ matrix.architecture }}" `
|
||||
-SkipPublish `
|
||||
-BinDir "${{ github.workspace }}/bin"
|
||||
if (-not (Test-Path "${{ github.workspace }}/bin/$setupName")) {
|
||||
throw "Installer not found: bin/$setupName"
|
||||
}
|
||||
"setup=$setupName" >> $env:GITHUB_OUTPUT
|
||||
|
||||
- name: Upload installer artifact
|
||||
if: ${{ inputs.build-installer }}
|
||||
uses: actions/upload-artifact@v7
|
||||
with:
|
||||
name: ${{ steps.setup.outputs.setup }}
|
||||
path: ./bin/${{ steps.setup.outputs.setup }}
|
||||
if-no-files-found: error
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
@@ -35,6 +35,7 @@ jobs:
|
||||
dotnet-version: ${{ inputs.dotnet-version }}
|
||||
run-unit-tests: ${{ inputs.run-unit-tests }}
|
||||
publish-r2r: ${{ inputs.publish-r2r }}
|
||||
build-installer: ${{ inputs.release }}
|
||||
retention-days: ${{ inputs.retention-days }}
|
||||
|
||||
macOS:
|
||||
@@ -67,3 +68,5 @@ jobs:
|
||||
architecture: ${{ matrix.architecture }}
|
||||
OS: ${{ matrix.OS }}
|
||||
|
||||
|
||||
|
||||
@@ -10,6 +10,8 @@ on:
|
||||
- docs/**
|
||||
- .vitepress/**
|
||||
- index.md
|
||||
- package.json
|
||||
- package-lock.json
|
||||
|
||||
# Allows you to run this workflow manually from the Actions tab
|
||||
workflow_dispatch:
|
||||
@@ -32,7 +34,7 @@ jobs:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0 # Not needed if lastUpdated is not enabled
|
||||
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
|
||||
@@ -40,7 +42,7 @@ jobs:
|
||||
# version: 9 # Not needed if you've set "packageManager" in package.json
|
||||
# - uses: oven-sh/setup-bun@v1 # Uncomment this if you're using Bun
|
||||
- name: Setup Node
|
||||
uses: actions/setup-node@v6
|
||||
uses: actions/setup-node@v7
|
||||
with:
|
||||
node-version: 24
|
||||
cache: npm # or pnpm / yarn
|
||||
|
||||
@@ -26,7 +26,7 @@ jobs:
|
||||
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v6
|
||||
uses: actions/checkout@v7
|
||||
|
||||
- name: Set up QEMU
|
||||
uses: docker/setup-qemu-action@v4
|
||||
@@ -36,7 +36,7 @@ jobs:
|
||||
|
||||
- name: Login to Docker Hub
|
||||
if: ${{ inputs.release }}
|
||||
uses: docker/login-action@v4
|
||||
uses: docker/login-action@v4.6.0
|
||||
with:
|
||||
username: ${{ secrets.docker_username }}
|
||||
password: ${{ secrets.docker_token }}
|
||||
@@ -59,7 +59,9 @@ jobs:
|
||||
context: .
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ steps.metadata.outputs.tags != ''}}
|
||||
# Always read cache. Only write on release so PR validate cannot fail
|
||||
# when GHA cache is full or the token cannot write cache.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: ${{ inputs.release && 'type=gha,mode=max,ignore-error=true' || '' }}
|
||||
tags: ${{ steps.metadata.outputs.tags }}
|
||||
labels: ${{ steps.metadata.outputs.labels }}
|
||||
@@ -41,11 +41,19 @@ jobs:
|
||||
needs: [prerelease, build]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
- name: Download Chardonnay, Linux, and Mac artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: "*(Classic-)Libation.*"
|
||||
pattern: "Libation.*"
|
||||
merge-multiple: true
|
||||
|
||||
- name: Download Classic Windows artifacts
|
||||
uses: actions/download-artifact@v8
|
||||
with:
|
||||
path: artifacts
|
||||
pattern: "Libation-Classic.*"
|
||||
merge-multiple: true
|
||||
|
||||
- name: Release
|
||||
id: release
|
||||
@@ -57,4 +65,4 @@ jobs:
|
||||
draft: true
|
||||
prerelease: false
|
||||
files: |
|
||||
artifacts/*/*
|
||||
artifacts/*
|
||||
@@ -17,7 +17,7 @@ jobs:
|
||||
stale:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/stale@v10
|
||||
- uses: actions/stale@v11
|
||||
with:
|
||||
days-before-stale: 30
|
||||
days-before-close: 14
|
||||
|
||||
@@ -18,7 +18,7 @@ jobs:
|
||||
sync-labels:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Sync labels
|
||||
uses: micnncim/action-label-syncer@v1
|
||||
|
||||
@@ -17,6 +17,6 @@ jobs:
|
||||
container:
|
||||
image: ghcr.io/flathub/flatpak-builder-lint:latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- name: Check the MetaInfo file
|
||||
run: flatpak-builder-lint appstream Source/LoadByOS/LinuxConfigApp/com.getlibation.Libation.metainfo.xml
|
||||
@@ -15,7 +15,7 @@ jobs:
|
||||
validate-desktop-file:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v6
|
||||
- uses: actions/checkout@v7
|
||||
- run: sudo apt --yes install desktop-file-utils
|
||||
- name: Check the desktop file
|
||||
run: desktop-file-validate Source/LoadByOS/LinuxConfigApp/Libation.desktop
|
||||
+1
-1
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"WindowsClassic": "Classic-Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||
"WindowsClassic": "(?:Classic-Libation|Libation-Classic)\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-classic-x64\\.zip",
|
||||
"WindowsAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-x64\\.zip",
|
||||
"WindowsAvalonia_Arm64": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-win(?:dows)?-chardonnay-arm64\\.zip",
|
||||
"LinuxAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.deb",
|
||||
|
||||
+34
-3
@@ -2,6 +2,29 @@ import { defineConfig } from "vitepress";
|
||||
|
||||
// https://vitepress.dev/reference/site-config
|
||||
export default defineConfig({
|
||||
vite: {
|
||||
// esbuild 0.28+ will not downlevel destructuring for safari14 / chrome87.
|
||||
// Raise the target so docs:dev and docs:build do not need that transform.
|
||||
build: {
|
||||
target: "es2022",
|
||||
},
|
||||
optimizeDeps: {
|
||||
esbuildOptions: {
|
||||
target: "es2022",
|
||||
},
|
||||
},
|
||||
esbuild: {
|
||||
supported: {
|
||||
destructuring: true,
|
||||
},
|
||||
},
|
||||
// Avoid watching the C# solution / VS locks (EBUSY on .vsidx files).
|
||||
server: {
|
||||
watch: {
|
||||
ignored: ["**/Source/**", "**/.vs/**", "**/bin/**", "**/obj/**"],
|
||||
},
|
||||
},
|
||||
},
|
||||
title: "Libation",
|
||||
description:
|
||||
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
|
||||
@@ -35,7 +58,7 @@ export default defineConfig({
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
{ text: "Donate", link: "/donate" },
|
||||
],
|
||||
sidebar: [
|
||||
{
|
||||
@@ -47,7 +70,7 @@ export default defineConfig({
|
||||
text: "Issues & Requests",
|
||||
link: "https://github.com/rmcrackan/Libation/issues",
|
||||
},
|
||||
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
|
||||
{ text: "Donate", link: "/donate" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -67,6 +90,10 @@ export default defineConfig({
|
||||
text: "Audio File Formats",
|
||||
link: "/docs/features/audio-file-formats",
|
||||
},
|
||||
{
|
||||
text: "Audiobookshelf Auto-Upload",
|
||||
link: "/docs/features/audiobookshelf",
|
||||
},
|
||||
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
|
||||
{
|
||||
text: "Searching & Filtering",
|
||||
@@ -84,7 +111,8 @@ export default defineConfig({
|
||||
items: [
|
||||
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
|
||||
{ text: "Command Line Interface", link: "/docs/advanced/command-line-interface" },
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" }
|
||||
{ text: "Troubleshooting", link: "/docs/advanced/troubleshoot" },
|
||||
{ text: "Spatial Audio & DRM", link: "/docs/advanced/spatial-audio" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -113,5 +141,8 @@ export default defineConfig({
|
||||
search: {
|
||||
provider: "local",
|
||||
},
|
||||
|
||||
// Show the external-link arrow on outbound links in markdown (nav/sidebar already do).
|
||||
externalLinkIcon: true,
|
||||
},
|
||||
});
|
||||
@@ -9,6 +9,51 @@
|
||||
"Liberation Mono", "Courier New", monospace;
|
||||
}
|
||||
|
||||
/* Donate page: primary options */
|
||||
.donate-options {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
margin: 1.25rem 0 0.5rem;
|
||||
}
|
||||
|
||||
.donate-option {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 0.15rem;
|
||||
min-width: 11rem;
|
||||
padding: 1rem 1.25rem;
|
||||
border: 1px solid var(--vp-c-divider);
|
||||
border-radius: 8px;
|
||||
background: var(--vp-c-bg-soft);
|
||||
text-decoration: none !important;
|
||||
color: var(--vp-c-text-1) !important;
|
||||
transition: border-color 0.15s ease, background-color 0.15s ease;
|
||||
}
|
||||
|
||||
.donate-option:hover {
|
||||
border-color: var(--vp-c-brand-1);
|
||||
background: var(--vp-c-bg-elv);
|
||||
}
|
||||
|
||||
.donate-option__icon {
|
||||
width: 1.75rem;
|
||||
height: 1.75rem;
|
||||
margin-bottom: 0.35rem;
|
||||
}
|
||||
|
||||
.donate-option__label {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
line-height: 1.3;
|
||||
}
|
||||
|
||||
.donate-option__hint {
|
||||
font-size: 0.85rem;
|
||||
color: var(--vp-c-text-2);
|
||||
}
|
||||
|
||||
/* Hide certain nav items on tablet devices to prevent horizontal scroll */
|
||||
@media (min-width: 640px) and (max-width: 959px) {
|
||||
/* Target specific nav items by their position */
|
||||
|
||||
@@ -102,6 +102,7 @@ export function getDetectOS() {
|
||||
if (lower.endsWith('.deb')) packageType = 'deb'
|
||||
else if (lower.endsWith('.rpm')) packageType = 'rpm'
|
||||
else if (lower.endsWith('.dmg')) packageType = 'dmg'
|
||||
else if (lower.endsWith('-setup.exe')) packageType = 'installer'
|
||||
else if (lower.endsWith('.zip') || lower.endsWith('.msi')) packageType = 'archive'
|
||||
return { platform, arch, packageType: packageType || 'unknown', name }
|
||||
}
|
||||
@@ -141,6 +142,8 @@ export function getDetectOS() {
|
||||
if (wantOs === 'linux' && p.packageType === 'deb' && preferDeb) s += 20
|
||||
if (wantOs === 'linux' && p.packageType === 'rpm' && preferRpm) s += 20
|
||||
if (wantOs === 'linux' && !preferDeb && !preferRpm && p.packageType === 'deb') s += 5
|
||||
if (wantOs === 'windows' && p.packageType === 'installer') s += 30
|
||||
if (wantOs === 'windows' && p.packageType === 'archive') s -= 10
|
||||
if (nameLower.includes('chardonnay')) s += 10
|
||||
if (nameLower.includes('classic')) s -= 5
|
||||
return s
|
||||
@@ -154,6 +157,20 @@ export function getDetectOS() {
|
||||
if (wantArch) altCandidates = altCandidates.filter(e => e.parsed.arch === wantArch)
|
||||
const alternatives = altCandidates.map(e => ({ name: e.name, url: e.url }))
|
||||
const classicVsChardonnayFaqUrl = 'https://getlibation.com/docs/frequently-asked-questions#what-s-the-difference-between-classic-and-chardonnay'
|
||||
function isWindowsSetup(name) {
|
||||
return (name || '').toLowerCase().endsWith('-setup.exe')
|
||||
}
|
||||
function preferWindowsAsset(current, candidate) {
|
||||
if (!candidate) return current
|
||||
if (!current) return candidate
|
||||
const candSetup = isWindowsSetup(candidate.name)
|
||||
const currSetup = isWindowsSetup(current.name)
|
||||
if (candSetup && !currSetup) return candidate
|
||||
return current
|
||||
}
|
||||
function isClassicWindowsAsset(nameLower) {
|
||||
return nameLower.includes('libation-classic') || nameLower.includes('classic-libation')
|
||||
}
|
||||
let windowsX64Choice = null
|
||||
if (wantOs === 'windows' && wantArch === 'x64') {
|
||||
let winChardonnay = null
|
||||
@@ -162,10 +179,15 @@ export function getDetectOS() {
|
||||
const e = parsed[j]
|
||||
if (e.parsed.platform !== 'windows' || e.parsed.arch !== 'x64') continue
|
||||
const n = (e.name || '').toLowerCase()
|
||||
if (n.includes('chardonnay')) winChardonnay = { name: e.name, url: e.url }
|
||||
if (n.includes('classic')) winClassic = { name: e.name, url: e.url }
|
||||
if (n.includes('chardonnay')) winChardonnay = preferWindowsAsset(winChardonnay, e)
|
||||
else if (isClassicWindowsAsset(n)) winClassic = preferWindowsAsset(winClassic, e)
|
||||
}
|
||||
if (winChardonnay && winClassic) {
|
||||
windowsX64Choice = {
|
||||
chardonnay: { name: winChardonnay.name, url: winChardonnay.url },
|
||||
classic: { name: winClassic.name, url: winClassic.url },
|
||||
}
|
||||
}
|
||||
if (winChardonnay && winClassic) windowsX64Choice = { chardonnay: winChardonnay, classic: winClassic }
|
||||
}
|
||||
if (!recommended && wantOs) {
|
||||
reason = 'No asset matched your system (OS: ' + os + ', arch: ' + arch + '). Try choosing a download manually.'
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
# AGENTS.md
|
||||
|
||||
## Cursor Cloud specific instructions
|
||||
|
||||
Libation is a cross-platform .NET desktop/CLI app for downloading and de-DRMing Audible
|
||||
audiobooks. The core product is the Avalonia GUI (`LibationAvalonia`, assembly name `Libation`)
|
||||
plus a headless CLI (`LibationCli`) that shares the same config and SQLite library database.
|
||||
|
||||
### Toolchain / environment
|
||||
- Requires the **.NET 10 SDK** pinned by `global.json` (`10.0.101`). It is preinstalled at
|
||||
`~/.dotnet`; `~/.bashrc` exports `DOTNET_ROOT` and adds `~/.dotnet` (and `~/.dotnet/tools`) to
|
||||
`PATH`. New non-login shells may not have it — use `dotnet` after a login shell, or call
|
||||
`~/.dotnet/dotnet` directly.
|
||||
- The startup update script runs `dotnet tool restore` (restores `dotnet-ef`) and
|
||||
`dotnet restore Source/Libation.slnx`. No other setup is needed.
|
||||
- The solution file is the new XML format: `Source/Libation.slnx` (there is no classic `.sln`).
|
||||
|
||||
### Building — Linux only builds the cross-platform projects
|
||||
- Do **not** build the whole solution on Linux. Several projects are Windows/OS-specific and will
|
||||
not build here: `LibationWinForms`, `HangoverWinForms` (target `net10.0-windows7.0`) and the
|
||||
`LoadByOS/{Windows,MacOS}ConfigApp` helpers.
|
||||
- Build the runnable cross-platform apps directly:
|
||||
- `dotnet build Source/LibationAvalonia/LibationAvalonia.csproj`
|
||||
- `dotnet build Source/LibationCli/LibationCli.csproj`
|
||||
- There is no dedicated lint step; the repo's `.editorconfig` is minimal and CI
|
||||
(`.github/workflows/validate.yml`) only builds and tests. The compiler/analyzer warnings from a
|
||||
normal build serve as the static-analysis check. (A known transitive `NU1903` SQLite
|
||||
vulnerability warning is expected and harmless.)
|
||||
|
||||
### Testing
|
||||
- Test projects live under `Source/_Tests/` and use MSTest on **Microsoft.Testing.Platform**
|
||||
(configured via `global.json` `test.runner`). Because of this runner, `dotnet test` requires
|
||||
`--project` for a single project (a positional project path is rejected):
|
||||
`dotnet test --project Source/_Tests/FileManager.Tests/FileManager.Tests.csproj`
|
||||
- CI runs `dotnet test` from `Source/`, which builds the entire solution first and is slow;
|
||||
prefer running the seven test projects individually on Linux.
|
||||
- **GNOME Keyring / OS secret store:** Libation's default `TokenStorageMethod` is `Encrypted`,
|
||||
and the AES-GCM master key is stored via the OS secret store (`OsSecretStore` /
|
||||
`IdentityTokenStorageWiring`). On Linux that is GNOME Keyring (Secret Service). Some tests in
|
||||
`AudibleUtilities.Tests` exercise the real OS store when it is available. If the login keyring
|
||||
has never been created/unlocked, those calls can **block for minutes** waiting on a desktop
|
||||
password prompt (looks like a hung test suite). A human must set a keyring password once via
|
||||
the Desktop pane when the prompt appears; after that, subsequent runs are fast. The keyring
|
||||
lives at `~/.local/share/keyrings/`. Do not "kill and retry" a stalled `AudibleUtilities.Tests`
|
||||
run until you have checked for a keyring dialog on `DISPLAY=:1`.
|
||||
|
||||
### Running the apps
|
||||
- GUI: a display is available on `DISPLAY=:1`. Run with
|
||||
`cd Source/LibationAvalonia && dotnet run`. First launch shows a Welcome/walkthrough and creates
|
||||
config under `~/.local/share/Libation/` (`Settings.json`, `AccountsSettings.json`,
|
||||
`LibationContext.db`). The same keyring note above applies when the GUI/CLI first encrypts
|
||||
account tokens.
|
||||
- CLI: `cd Source/LibationCli && dotnet run -- <command>` (e.g. `version`, `get-setting`,
|
||||
`list-accounts`, `scan`, `liberate`). The CLI reads the same `~/.local/share/Libation/` config
|
||||
and database as the GUI.
|
||||
- Actually scanning/downloading a library requires signing into a real Audible account, so full
|
||||
end-to-end liberation cannot be exercised without credentials.
|
||||
+34
-2
@@ -38,6 +38,25 @@ init_config_file() {
|
||||
fi
|
||||
}
|
||||
|
||||
init_master_key() {
|
||||
FILE=libation-master.key
|
||||
FULLPATH=${LIBATION_CONFIG_DIR}/${FILE}
|
||||
if [ -n "${LIBATION_MASTER_KEY_FILE}" ]; then
|
||||
debug "LIBATION_MASTER_KEY_FILE is already set, not looking for ${FILE}"
|
||||
return 0
|
||||
fi
|
||||
if [ -n "${LIBATION_MASTER_KEY}" ]; then
|
||||
debug "LIBATION_MASTER_KEY is already set, not looking for ${FILE}"
|
||||
return 0
|
||||
fi
|
||||
if [ -f ${FULLPATH} ]; then
|
||||
info "loading ${FILE}"
|
||||
cp ${FULLPATH} ${LIBATION_CONFIG_INTERNAL}/
|
||||
else
|
||||
debug "${FULLPATH} not found, assuming accounts don't use encrypted tokens"
|
||||
fi
|
||||
}
|
||||
|
||||
update_settings() {
|
||||
FILE=$1
|
||||
KEY=$2
|
||||
@@ -141,6 +160,11 @@ setup_db() {
|
||||
run() {
|
||||
info "scanning accounts"
|
||||
/libation/LibationCli scan
|
||||
local scan_exit=$?
|
||||
if [ "${scan_exit}" -ne 0 ]; then
|
||||
error "scan failed (exit ${scan_exit}); skipping liberate. If the log shows Failed to decrypt ExistingAccessToken, see https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken"
|
||||
exit "${scan_exit}"
|
||||
fi
|
||||
info "liberating books"
|
||||
/libation/LibationCli liberate
|
||||
}
|
||||
@@ -149,7 +173,8 @@ main() {
|
||||
info "initializing libation"
|
||||
init_config_file AccountsSettings.json
|
||||
init_config_file Settings.json
|
||||
|
||||
init_master_key
|
||||
|
||||
info "loading settings"
|
||||
update_settings Settings.json Books "${LIBATION_BOOKS_DIR:-/data}"
|
||||
update_settings Settings.json InProgress /tmp
|
||||
@@ -171,6 +196,13 @@ main() {
|
||||
warn "${LIBATION_BOOKS_DIR} does not appear to be mounted, books will not be saved"
|
||||
fi
|
||||
|
||||
# If a command was passed to the container (e.g. `docker run -it libation bash`),
|
||||
# run that instead of the liberate loop so the environment is still set up
|
||||
if [ "$#" -gt 0 ]; then
|
||||
info "executing '$*' instead of the liberate loop"
|
||||
exec "$@"
|
||||
fi
|
||||
|
||||
# Let the user know what the run type will be
|
||||
if [[ -z "${SLEEP_TIME}" ]]; then
|
||||
SLEEP_TIME=-1
|
||||
@@ -198,4 +230,4 @@ main() {
|
||||
info "exiting"
|
||||
}
|
||||
|
||||
main
|
||||
main "$@"
|
||||
+3
-1
@@ -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"]
|
||||
@@ -32,6 +32,7 @@ Comprehensive documentation is available in the `docs` directory and on our [Doc
|
||||
|
||||
- [Getting Started](https://getlibation.com/docs/getting-started)
|
||||
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
|
||||
- [Spatial Audio & DRM](https://getlibation.com/docs/advanced/spatial-audio)
|
||||
|
||||
## Development & Contributing
|
||||
|
||||
@@ -53,5 +54,5 @@ Libation is released under the GPL-3.0 License
|
||||
|
||||
---
|
||||
|
||||
If you found this useful, tell a friend. If you found this REALLY useful, you can click here to PayPal.me
|
||||
If you found this useful, tell a friend. If you found this REALLY useful, you can [donate here](https://getlibation.com/donate)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.
|
||||
+49
-9
@@ -1,4 +1,5 @@
|
||||
#!/bin/bash
|
||||
set -euo pipefail
|
||||
|
||||
BIN_DIR=$1; shift
|
||||
VERSION=$1; shift
|
||||
@@ -8,25 +9,25 @@ SIGN_WITH_KEY=$1; shift
|
||||
if [ -z "$BIN_DIR" ]
|
||||
then
|
||||
echo "This script must be called with a the Libation macos bins directory as an argument."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -d "$BIN_DIR" ]
|
||||
then
|
||||
echo "The directory \"$BIN_DIR\" does not exist."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z $VERSION ]
|
||||
if [ -z "${VERSION:-}" ]
|
||||
then
|
||||
echo "This script must be called with the Libation version number as an argument."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ -z $ARCH ]
|
||||
if [ -z "${ARCH:-}" ]
|
||||
then
|
||||
echo "This script must be called with the Libation cpu architecture as an argument."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ "$SIGN_WITH_KEY" != "true" ]
|
||||
@@ -40,9 +41,46 @@ echo "Bundle dir: $BUNDLE"
|
||||
if [[ -d $BUNDLE ]]
|
||||
then
|
||||
echo "$BUNDLE directory already exists, aborting."
|
||||
exit
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# hdiutil create intermittently fails on CI with "Resource busy" (macOS background scanning).
|
||||
create_dmg_with_retry() {
|
||||
local src_folder="$1"
|
||||
local output_path="$2"
|
||||
local max_attempts=10
|
||||
local attempt=1
|
||||
local delay=3
|
||||
|
||||
while [ "$attempt" -le "$max_attempts" ]; do
|
||||
echo "Creating disk image (attempt ${attempt}/${max_attempts}): ${output_path}"
|
||||
rm -f "${output_path}"
|
||||
sync
|
||||
sleep 1
|
||||
|
||||
if hdiutil create -srcFolder "${src_folder}" -ov -format UDZO -o "${output_path}"; then
|
||||
if [ -f "${output_path}" ]; then
|
||||
return 0
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "hdiutil create failed on attempt ${attempt}"
|
||||
hdiutil detach -force /Volumes/Libation 2>/dev/null || true
|
||||
rm -f "${output_path}" "${output_path}.temp.dmg" 2>/dev/null || true
|
||||
sync
|
||||
|
||||
if [ "$attempt" -lt "$max_attempts" ]; then
|
||||
echo "Waiting ${delay}s before retry..."
|
||||
sleep "${delay}"
|
||||
delay=$((delay + 2))
|
||||
fi
|
||||
attempt=$((attempt + 1))
|
||||
done
|
||||
|
||||
echo "Error: hdiutil create failed after ${max_attempts} attempts"
|
||||
return 1
|
||||
}
|
||||
|
||||
BUNDLE_CONTENTS=$BUNDLE/Contents
|
||||
echo "Bundle Contents dir: $BUNDLE_CONTENTS"
|
||||
|
||||
@@ -72,6 +110,9 @@ mv $BUNDLE_MACOS/libation.icns $BUNDLE_RESOURCES/libation.icns
|
||||
echo "Moving Info.plist file..."
|
||||
mv $BUNDLE_MACOS/Info.plist $BUNDLE_CONTENTS/Info.plist
|
||||
|
||||
echo "Writing PkgInfo file..."
|
||||
printf 'APPL????' > $BUNDLE_CONTENTS/PkgInfo
|
||||
|
||||
echo "Moving Libation_DS_Store file..."
|
||||
mv $BUNDLE_MACOS/Libation_DS_Store ./Libation_DS_Store
|
||||
|
||||
@@ -118,8 +159,7 @@ mkdir Libation/.background
|
||||
mv background.png Libation/.background/
|
||||
ln -s /Applications "./Libation/ "
|
||||
mkdir ./bundle
|
||||
sync
|
||||
hdiutil create -srcFolder ./Libation -o "./bundle/$DMG_FILE"
|
||||
create_dmg_with_retry ./Libation "./bundle/$DMG_FILE"
|
||||
# Create a .DS_Store by:
|
||||
# - mounting an existing image in shadow mode (hdiutil attach Libation.dmg -shadow junk.dmg)
|
||||
# - Open the folder and edit it to your liking.
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
#Requires -Version 5.1
|
||||
<#
|
||||
.SYNOPSIS
|
||||
Builds a Libation Windows setup.exe from a published bin directory using Inno Setup.
|
||||
|
||||
.DESCRIPTION
|
||||
Optionally runs dotnet publish (same projects as CI), removes standalone WindowsConfigApp
|
||||
artifacts, then compiles Scripts/Windows/Libation.iss.
|
||||
|
||||
.PARAMETER Version
|
||||
Libation version string (e.g. 13.4.1).
|
||||
|
||||
.PARAMETER Ui
|
||||
Avalonia (Chardonnay) or WinForms (Classic). Classic is x64 only.
|
||||
|
||||
.PARAMETER Architecture
|
||||
x64 or arm64 (Avalonia only).
|
||||
|
||||
.PARAMETER BinDir
|
||||
Folder containing published files. Defaults to repo-root bin.
|
||||
|
||||
.PARAMETER SkipPublish
|
||||
Skip dotnet publish; BinDir must already contain a complete publish output.
|
||||
|
||||
.EXAMPLE
|
||||
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
|
||||
|
||||
.EXAMPLE
|
||||
.\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)]
|
||||
[string] $Version,
|
||||
|
||||
[ValidateSet('Avalonia', 'WinForms')]
|
||||
[string] $Ui = 'Avalonia',
|
||||
|
||||
[ValidateSet('x64', 'arm64')]
|
||||
[string] $Architecture = 'x64',
|
||||
|
||||
[string] $BinDir = '',
|
||||
|
||||
[switch] $SkipPublish
|
||||
)
|
||||
|
||||
$ErrorActionPreference = 'Stop'
|
||||
|
||||
if ($Ui -eq 'WinForms' -and $Architecture -ne 'x64') {
|
||||
throw 'Classic (WinForms) is only published for x64.'
|
||||
}
|
||||
|
||||
$repoRoot = Resolve-Path (Join-Path $PSScriptRoot '..\..')
|
||||
$sourceDir = Join-Path $repoRoot 'Source'
|
||||
if (-not $BinDir) {
|
||||
$BinDir = Join-Path $repoRoot 'bin'
|
||||
}
|
||||
if (-not (Test-Path $BinDir)) {
|
||||
New-Item -ItemType Directory -Path $BinDir -Force | Out-Null
|
||||
}
|
||||
$BinDir = (Resolve-Path $BinDir).Path
|
||||
|
||||
$runtime = "win-$Architecture"
|
||||
$releaseName = if ($Ui -eq 'WinForms') { 'classic' } else { 'chardonnay' }
|
||||
$outputBase = if ($Ui -eq 'WinForms') {
|
||||
"Libation-Classic.$Version-windows-$releaseName-$Architecture-setup"
|
||||
} else {
|
||||
"Libation.$Version-windows-$releaseName-$Architecture-setup"
|
||||
}
|
||||
$architecturesAllowed = if ($Architecture -eq 'arm64') { 'arm64' } else { 'x64compatible' }
|
||||
|
||||
function Find-ISCC {
|
||||
$candidates = @(
|
||||
"${env:ProgramFiles(x86)}\Inno Setup 6\ISCC.exe",
|
||||
"$env:ProgramFiles\Inno Setup 6\ISCC.exe",
|
||||
"${env:LocalAppData}\Programs\Inno Setup 6\ISCC.exe"
|
||||
)
|
||||
foreach ($path in $candidates) {
|
||||
if (Test-Path $path) { return $path }
|
||||
}
|
||||
return $null
|
||||
}
|
||||
|
||||
if (-not $SkipPublish -and (Test-Path $BinDir)) {
|
||||
Write-Host "Clearing $BinDir before publish ..."
|
||||
Get-ChildItem -LiteralPath $BinDir -Force | Remove-Item -Recurse -Force -ErrorAction SilentlyContinue
|
||||
}
|
||||
|
||||
if (-not $SkipPublish) {
|
||||
Write-Host "Publishing Libation ($Ui, $runtime) to $BinDir ..."
|
||||
Push-Location $sourceDir
|
||||
try {
|
||||
$publishArgs = @(
|
||||
'--runtime', $runtime,
|
||||
'--configuration', 'Release',
|
||||
'--output', $BinDir,
|
||||
'-p:PublishProtocol=FileSystem',
|
||||
'-p:PublishReadyToRun=false',
|
||||
'-p:SelfContained=true'
|
||||
)
|
||||
dotnet publish "Libation$Ui/Libation$Ui.csproj" @publishArgs
|
||||
dotnet publish 'LoadByOS/WindowsConfigApp/WindowsConfigApp.csproj' @publishArgs
|
||||
dotnet publish 'LibationCli/LibationCli.csproj' @publishArgs
|
||||
dotnet publish "Hangover$Ui/Hangover$Ui.csproj" @publishArgs
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
}
|
||||
|
||||
Write-Host 'Removing standalone WindowsConfigApp launcher artifacts ...'
|
||||
$removeFiles = @(
|
||||
'WindowsConfigApp.exe',
|
||||
'WindowsConfigApp.runtimeconfig.json',
|
||||
'WindowsConfigApp.deps.json'
|
||||
)
|
||||
Push-Location $BinDir
|
||||
try {
|
||||
foreach ($file in $removeFiles) {
|
||||
if (Test-Path $file) { Remove-Item $file -Force }
|
||||
}
|
||||
if (-not (Test-Path 'Libation.exe')) {
|
||||
throw "Libation.exe not found in $BinDir. Publish or point -BinDir at a complete output folder."
|
||||
}
|
||||
if (-not (Test-Path 'ZipExtractor.exe')) {
|
||||
throw "ZipExtractor.exe not found in $BinDir. It is required for in-app upgrades."
|
||||
}
|
||||
}
|
||||
finally {
|
||||
Pop-Location
|
||||
}
|
||||
|
||||
$iscc = Find-ISCC
|
||||
if (-not $iscc) {
|
||||
throw @"
|
||||
Inno Setup 6 (ISCC.exe) was not found. Install from https://jrsoftware.org/isdl.php
|
||||
and ensure ISCC.exe is under Program Files (x86)\Inno Setup 6\ or similar.
|
||||
"@
|
||||
}
|
||||
|
||||
$setupIcon = Join-Path $PSScriptRoot 'libation.ico'
|
||||
if (-not (Test-Path $setupIcon)) {
|
||||
throw "Installer icon not found: $setupIcon"
|
||||
}
|
||||
$iss = Join-Path $PSScriptRoot 'Libation.iss'
|
||||
$defines = @(
|
||||
"/DMyVersion=$Version",
|
||||
"/DMySetupIcon=$setupIcon",
|
||||
"/DMyReleaseName=$releaseName",
|
||||
"/DMyArchitecture=$Architecture",
|
||||
"/DOutputBaseFilename=$outputBase",
|
||||
"/DSourceDir=$BinDir",
|
||||
"/DOutputDir=$BinDir",
|
||||
"/DArchitecturesAllowed=$architecturesAllowed"
|
||||
)
|
||||
|
||||
Write-Host "Compiling $outputBase.exe ..."
|
||||
& $iscc $defines $iss
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
throw "ISCC failed with exit code $LASTEXITCODE."
|
||||
}
|
||||
|
||||
$setupPath = Join-Path $BinDir "$outputBase.exe"
|
||||
Write-Host "Done: $setupPath"
|
||||
@@ -0,0 +1,93 @@
|
||||
; Libation Windows installer (Inno Setup 6.x)
|
||||
;
|
||||
; Compile with ISCC and /D defines. See Scripts/Windows/README.md and
|
||||
; Build-WindowsInstaller.ps1 for local builds; CI will pass the same defines.
|
||||
;
|
||||
; Required /D defines from build script:
|
||||
; MyVersion, MyReleaseName, MyArchitecture, MySetupIcon,
|
||||
; OutputBaseFilename, SourceDir, OutputDir, ArchitecturesAllowed
|
||||
|
||||
#ifndef MyVersion
|
||||
#define MyVersion "0.0.0-dev"
|
||||
#endif
|
||||
#ifndef MyReleaseName
|
||||
#define MyReleaseName "chardonnay"
|
||||
#endif
|
||||
#ifndef MyArchitecture
|
||||
#define MyArchitecture "x64"
|
||||
#endif
|
||||
#ifndef MySetupIcon
|
||||
#define MySetupIcon "libation.ico"
|
||||
#endif
|
||||
#ifndef OutputBaseFilename
|
||||
#define OutputBaseFilename "Libation.0.0.0-dev-windows-chardonnay-x64-setup"
|
||||
#endif
|
||||
#ifndef SourceDir
|
||||
#define SourceDir "."
|
||||
#endif
|
||||
#ifndef OutputDir
|
||||
#define OutputDir "."
|
||||
#endif
|
||||
#ifndef ArchitecturesAllowed
|
||||
#define ArchitecturesAllowed "x64compatible"
|
||||
#endif
|
||||
|
||||
#if MyReleaseName == 'classic'
|
||||
#define InstallFolderName "Libation-Classic"
|
||||
#define AppName "Libation (Classic)"
|
||||
#else
|
||||
#define InstallFolderName "Libation"
|
||||
#define AppName "Libation (Chardonnay)"
|
||||
#endif
|
||||
|
||||
[Setup]
|
||||
#if MyReleaseName == 'classic'
|
||||
AppId={{B1C2D3E4-F5A6-4B7C-8D9E-0F1A2B3C4D5E}}
|
||||
#elif MyArchitecture == 'arm64'
|
||||
AppId={{C2D3E4F5-A6B7-4C8D-9E0F-1A2B3C4D5E6F}}
|
||||
#else
|
||||
AppId={{A7B8C9D0-E1F2-4A3B-8C9D-0E1F2A3B4C5D}}
|
||||
#endif
|
||||
AppName={#AppName}
|
||||
AppVersion={#MyVersion}
|
||||
AppVerName={#AppName} {#MyVersion}
|
||||
AppPublisher=Libation
|
||||
AppPublisherURL=https://getlibation.com/
|
||||
AppSupportURL=https://github.com/rmcrackan/Libation/issues
|
||||
AppUpdatesURL=https://github.com/rmcrackan/Libation/releases
|
||||
DefaultDirName={localappdata}\{#InstallFolderName}
|
||||
DefaultGroupName={#AppName}
|
||||
PrivilegesRequired=lowest
|
||||
OutputDir={#OutputDir}
|
||||
OutputBaseFilename={#OutputBaseFilename}
|
||||
SourceDir={#SourceDir}
|
||||
ArchitecturesAllowed={#ArchitecturesAllowed}
|
||||
ArchitecturesInstallIn64BitMode=x64compatible
|
||||
DisableProgramGroupPage=no
|
||||
Compression=lzma2
|
||||
SolidCompression=yes
|
||||
WizardStyle=modern
|
||||
SetupIconFile={#MySetupIcon}
|
||||
UninstallDisplayIcon={app}\Libation.exe
|
||||
UninstallDisplayName={#AppName}
|
||||
VersionInfoVersion={#MyVersion}
|
||||
VersionInfoProductName={#AppName}
|
||||
VersionInfoCompany=Libation
|
||||
|
||||
[Languages]
|
||||
Name: "english"; MessagesFile: "compiler:Default.isl"
|
||||
|
||||
[Tasks]
|
||||
Name: "desktopicon"; Description: "{cm:CreateDesktopIcon}"; GroupDescription: "{cm:AdditionalIcons}"; Flags: unchecked
|
||||
|
||||
[Files]
|
||||
Source: "*"; DestDir: "{app}"; Flags: ignoreversion recursesubdirs createallsubdirs
|
||||
|
||||
[Icons]
|
||||
Name: "{group}\Libation"; Filename: "{app}\Libation.exe"; Comment: "Libation audiobook manager"
|
||||
Name: "{group}\Hangover"; Filename: "{app}\Hangover.exe"; Comment: "Hangover license viewer"
|
||||
Name: "{group}\Libation CLI"; Filename: "{app}\LibationCli.exe"; Comment: "Libation command-line tools"
|
||||
Name: "{autodesktop}\Libation"; Filename: "{app}\Libation.exe"; Tasks: desktopicon; Comment: "Libation audiobook manager"
|
||||
|
||||
[Run]
|
||||
Filename: "{app}\Libation.exe"; Description: "{cm:LaunchProgram,{#StringChange(AppName, '&', '&&')}}"; Flags: nowait postinstall skipifsilent
|
||||
@@ -0,0 +1,89 @@
|
||||
# Windows installer (Inno Setup)
|
||||
|
||||
Step 8.1 of the Windows installers plan: a parameterized Inno Setup script that installs the same flat publish layout as the release zip, under a per-user directory, so the in-app upgrader can still overlay files via `ZipExtractor.exe`.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. [.NET SDK](https://dotnet.microsoft.com/download) (same version as the repo; see `build.yml`).
|
||||
2. [Inno Setup 6](https://jrsoftware.org/isdl.php) with `ISCC.exe` on PATH or in a default install location.
|
||||
|
||||
## Quick build (publish + installer)
|
||||
|
||||
From PowerShell, repo root or this folder:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture x64
|
||||
```
|
||||
|
||||
Outputs:
|
||||
|
||||
- Publish tree: `bin\` (repo root)
|
||||
- Installer: `bin\Libation.13.4.1-windows-chardonnay-x64-setup.exe`
|
||||
|
||||
### Variants
|
||||
|
||||
| UI | `-Ui` | `-Architecture` | Install folder | Output prefix |
|
||||
|----|-------|-----------------|----------------|---------------|
|
||||
| Chardonnay x64 | `Avalonia` | `x64` | `%LocalAppData%\Libation` | `Libation.*` |
|
||||
| Chardonnay arm64 | `Avalonia` | `arm64` | `%LocalAppData%\Libation` | `Libation.*` |
|
||||
| Classic x64 | `WinForms` | `x64` (default) | `%LocalAppData%\Libation-Classic` | `Libation-Classic.*-setup.exe` |
|
||||
|
||||
Examples:
|
||||
|
||||
```powershell
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui WinForms
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -Architecture arm64
|
||||
```
|
||||
|
||||
### Reuse an existing publish folder
|
||||
|
||||
```powershell
|
||||
.\Scripts\Windows\Build-WindowsInstaller.ps1 -Version 13.4.1 -Ui Avalonia -BinDir C:\path\to\bin -SkipPublish
|
||||
```
|
||||
|
||||
`BinDir` must already match CI layout (including `ZipExtractor.exe`, without standalone `WindowsConfigApp.exe`).
|
||||
|
||||
## Manual ISCC (without the helper script)
|
||||
|
||||
After publish and cleanup (remove `WindowsConfigApp.exe`, `WindowsConfigApp.runtimeconfig.json`, `WindowsConfigApp.deps.json` from `bin`), from `Scripts\Windows`:
|
||||
|
||||
```text
|
||||
"C:\Program Files (x86)\Inno Setup 6\ISCC.exe" Libation.iss ^
|
||||
/DMyVersion=13.4.1 ^
|
||||
/DMyPrefix= ^
|
||||
/DMyReleaseName=chardonnay ^
|
||||
/DMyArchitecture=x64 ^
|
||||
/DInstallFolderName=Libation ^
|
||||
/DAppName=Libation ^
|
||||
/DOutputBaseFilename=Libation.13.4.1-windows-chardonnay-x64-setup ^
|
||||
/DSourceDir=C:\Dev\_rmcrackan\Libation\bin ^
|
||||
/DOutputDir=C:\Dev\_rmcrackan\Libation\bin ^
|
||||
/DArchitecturesAllowed=x64compatible
|
||||
```
|
||||
|
||||
Classic x64: use `MyReleaseName=classic` and matching `OutputBaseFilename` (`Libation-Classic.{version}-windows-classic-x64-setup`). Install folder and display name are set in `Libation.iss`. GitHub zip assets use `Libation-Classic.{version}-windows-classic-x64.zip`; `.releaseindex.json` also accepts legacy `Classic-Libation.*` zips on older releases.
|
||||
|
||||
arm64: set `MyArchitecture=arm64` and `ArchitecturesAllowed=arm64`.
|
||||
|
||||
## Installer behavior
|
||||
|
||||
- **Per-user install** (`PrivilegesRequired=lowest`, default dir under `{localappdata}`).
|
||||
- **Shortcuts** target `{app}\Libation.exe` (required for upgrade path detection).
|
||||
- **Does not** create `appsettings.json` or Libation Files; same as zip install.
|
||||
- **Includes** full publish output so `ZipExtractor.exe` remains for in-app updates.
|
||||
- **Settings -> Apps:** shows `Libation (Chardonnay)` or `Libation (Classic)` with version in `DisplayVersion` (Inno at install; synced after in-app zip upgrades via `WindowsUninstallRegistrySync`); icon from `SetupIconFile` / `UninstallDisplayIcon` (`Scripts/Windows/libation.ico` and `{app}\Libation.exe`).
|
||||
|
||||
## Next plan step (8.2)
|
||||
|
||||
Install via `*-setup.exe`, run the app, then verify an older release can upgrade in-app using the existing zip-based upgrader.
|
||||
|
||||
CI wiring (`build-windows.yml`) is step 8.3.
|
||||
|
||||
## Files in this folder
|
||||
|
||||
| File | Purpose |
|
||||
|------|---------|
|
||||
| `Libation.iss` | Parameterized Inno Setup script |
|
||||
| `libation.ico` | Setup/uninstaller icon (copied from Avalonia assets) |
|
||||
| `Build-WindowsInstaller.ps1` | Local publish + ISCC helper |
|
||||
| `README.md` | This document |
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 35 KiB |
@@ -14,7 +14,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="3.0.2" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="3.1.0" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Version>13.4.2.1</Version>
|
||||
<Version>13.7.7</Version>
|
||||
<Nullable>enable</Nullable>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -41,6 +41,7 @@ public static class LibationScaffolding
|
||||
{
|
||||
public const string RepositoryUrl = "ht" + "tps://github.com/rmcrackan/Libation";
|
||||
public const string WebsiteUrl = "ht" + "tps://getlibation.com";
|
||||
public const string DonateUrl = WebsiteUrl + "/donate";
|
||||
public const string RepositoryLatestUrl = "ht" + "tps://github.com/rmcrackan/Libation/releases/latest";
|
||||
/// <summary>Documentation for naming template syntax</summary>
|
||||
public const string NamingTemplatesDocUrl = WebsiteUrl + "/docs/features/naming-templates";
|
||||
@@ -69,6 +70,8 @@ public static class LibationScaffolding
|
||||
// // outdated. kept here as an example of what belongs in this area
|
||||
// // Migrations.migrate_to_v5_2_0__pre_config();
|
||||
|
||||
InstallFolderUnblock.TryUnblockProcessDirectoryIfWindows();
|
||||
|
||||
Configuration.SetLibationVersion(BuildVersion);
|
||||
|
||||
//***********************************************//
|
||||
@@ -91,16 +94,15 @@ public static class LibationScaffolding
|
||||
{
|
||||
config.LoadPersistentSettings(config.LibationFiles.SettingsFilePath);
|
||||
}
|
||||
config.ValidateEnumSettings();
|
||||
DeleteOpenSqliteFiles(config);
|
||||
AudibleApiStorage.EnsureAccountsSettingsFileExists();
|
||||
IdentityTokenStorageWiring.Apply(config);
|
||||
|
||||
//
|
||||
// migrations go below here
|
||||
//
|
||||
|
||||
Migrations.migrate_to_v6_6_9(config);
|
||||
Migrations.migrate_to_v11_5_0(config);
|
||||
Migrations.migrate_to_v11_6_5(config);
|
||||
Migrations.migrate_to_v12_0_1(config);
|
||||
}
|
||||
|
||||
@@ -159,65 +161,7 @@ public static class LibationScaffolding
|
||||
}
|
||||
|
||||
private static void ensureSerilogConfig(Configuration config)
|
||||
{
|
||||
if (config.GetObject("Serilog") is JObject serilog)
|
||||
{
|
||||
bool fileChanged = false;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'ZipFile')]", false) is JObject zipFileSink)
|
||||
{
|
||||
zipFileSink["Name"] = "File";
|
||||
fileChanged = true;
|
||||
}
|
||||
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
|
||||
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
|
||||
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
|
||||
{
|
||||
fileSinkArgs["hooks"] = hooks;
|
||||
fileChanged = true;
|
||||
}
|
||||
|
||||
if (fileChanged)
|
||||
config.SetNonString(serilog.DeepClone(), "Serilog");
|
||||
return;
|
||||
}
|
||||
|
||||
var serilogObj = new JObject
|
||||
{
|
||||
{ "MinimumLevel", "Information" },
|
||||
{ "WriteTo", new JArray
|
||||
{
|
||||
// ABOUT SINKS
|
||||
// Only File sink is currently used. By user request (June 2024) others packages are included for experimental use.
|
||||
|
||||
// new JObject { {"Name", "Console" } }, // this has caused more problems than it's solved
|
||||
new JObject
|
||||
{
|
||||
{ "Name", "File" },
|
||||
{ "Args",
|
||||
new JObject
|
||||
{
|
||||
// for this sink to work, a path must be provided. we override this below
|
||||
{ "path", Path.Combine(config.LibationFiles.Location, "Log.log") },
|
||||
{ "rollingInterval", "Month" },
|
||||
// Serilog template formatting examples
|
||||
// - default: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] {Message:lj}{NewLine}{Exception}"
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] Begin Libation
|
||||
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
|
||||
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
|
||||
// {Properties:j} needed for expanded exception logging
|
||||
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
|
||||
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
// better exception logging with: Serilog.Exceptions library -- WithExceptionDetails
|
||||
{ "Using", new JArray{ "Dinah.Core", "Serilog.Exceptions" } }, // dll's name, NOT namespace
|
||||
{ "Enrich", new JArray{ "WithCaller", "WithExceptionDetails" } },
|
||||
};
|
||||
config.SetNonString(serilogObj, "Serilog");
|
||||
}
|
||||
=> config.EnsureSerilogConfig();
|
||||
|
||||
// to restore original: Console.SetOut(origOut);
|
||||
private static TextWriter origOut { get; } = Console.Out;
|
||||
@@ -347,7 +291,7 @@ public static class LibationScaffolding
|
||||
|
||||
private static void wireUpSystemEvents(Configuration configuration)
|
||||
{
|
||||
LibraryCommands.LibrarySizeChanged += (object? _, List<DataLayer.LibraryBook> libraryBooks)
|
||||
LibraryCommands.LibrarySizeChanged += (_, libraryBooks)
|
||||
=> SearchEngineCommands.FullReIndex(libraryBooks);
|
||||
|
||||
LibraryCommands.BookUserDefinedItemCommitted += (_, books)
|
||||
@@ -421,85 +365,44 @@ public static class LibationScaffolding
|
||||
var releaseIndex = JObject.Parse(System.Text.Encoding.ASCII.GetString(bts));
|
||||
|
||||
string? regexPattern;
|
||||
string? releaseIdString = null;
|
||||
|
||||
try
|
||||
{
|
||||
regexPattern = releaseIndex.Value<string>(InteropFactory.Create().ReleaseIdString);
|
||||
releaseIdString = InteropFactory.Create().ReleaseIdString;
|
||||
regexPattern = releaseIndex.Value<string>(releaseIdString);
|
||||
}
|
||||
catch
|
||||
{
|
||||
regexPattern = null;
|
||||
}
|
||||
if (string.IsNullOrEmpty(regexPattern) && Configuration.IsLinux)
|
||||
{
|
||||
var baseId = ReleaseIdentifier.ToString();
|
||||
regexPattern = releaseIndex.Value<string>($"{baseId}_RPM")
|
||||
?? releaseIndex.Value<string>($"{baseId}_DEB");
|
||||
releaseIdString ??= $"{baseId}_RPM";
|
||||
}
|
||||
if (string.IsNullOrEmpty(regexPattern))
|
||||
regexPattern = releaseIndex.Value<string>(ReleaseIdentifier.ToString());
|
||||
if (string.IsNullOrEmpty(regexPattern))
|
||||
{
|
||||
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}). Version check inconclusive.", ReleaseIdentifier);
|
||||
Log.Logger.Warning("Release index has no entry for this platform (ReleaseIdentifier: {ReleaseId}, ReleaseIdString: {ReleaseIdString}). Version check inconclusive.", ReleaseIdentifier, releaseIdString);
|
||||
return (null, null, null, false);
|
||||
}
|
||||
|
||||
var regex = new System.Text.RegularExpressions.Regex(regexPattern, System.Text.RegularExpressions.RegexOptions.IgnoreCase);
|
||||
var zip = latestRelease?.Assets?.FirstOrDefault(a => regex.IsMatch(a.Name));
|
||||
|
||||
if (zip is not null && !string.IsNullOrEmpty(releaseIdString))
|
||||
Log.Logger.Information("Update asset matched using {ReleaseIdString}: {AssetName}", releaseIdString, zip.Name);
|
||||
|
||||
return (releaseVersion, latestRelease, zip, true);
|
||||
}
|
||||
}
|
||||
|
||||
internal static class Migrations
|
||||
{
|
||||
public static void migrate_to_v6_6_9(Configuration config)
|
||||
{
|
||||
var writeToPath = $"Serilog.WriteTo";
|
||||
|
||||
// remove WriteTo[].Name == Console
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length1))
|
||||
{
|
||||
for (var i = length1 - 1; i >= 0; i--)
|
||||
{
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{writeToPath}[{i}].Name", out var value);
|
||||
|
||||
if (exists && value == "Console")
|
||||
UNSAFE_MigrationHelper.Settings_RemoveFromArray(writeToPath, i);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// add Serilog.Exceptions -- WithExceptionDetails
|
||||
{
|
||||
// outputTemplate should contain "{Properties:j}"
|
||||
{
|
||||
// re-calculate. previous loop may have changed the length
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGetArrayLength(writeToPath, out var length2))
|
||||
{
|
||||
var propertyName = "outputTemplate";
|
||||
for (var i = 0; i < length2; i++)
|
||||
{
|
||||
var jsonPath = $"{writeToPath}[{i}].Args";
|
||||
var exists = UNSAFE_MigrationHelper.Settings_TryGetFromJsonPath($"{jsonPath}.{propertyName}", out var value);
|
||||
|
||||
var newValue = "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}";
|
||||
|
||||
if (exists && value != newValue)
|
||||
UNSAFE_MigrationHelper.Settings_SetWithJsonPath(jsonPath, propertyName, newValue);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Serilog.Using must include "Serilog.Exceptions"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Using", "Serilog.Exceptions");
|
||||
|
||||
// Serilog.Enrich must include "WithExceptionDetails"
|
||||
UNSAFE_MigrationHelper.Settings_AddUniqueToArray("Serilog.Enrich", "WithExceptionDetails");
|
||||
}
|
||||
}
|
||||
|
||||
class FilterState_6_6_9
|
||||
{
|
||||
public bool UseDefault { get; set; }
|
||||
public List<string> Filters { get; set; } = new();
|
||||
}
|
||||
|
||||
public static void migrate_to_v12_0_1(Configuration config)
|
||||
{
|
||||
//Migrate from version 1 file cache to the dictionary-based version 2 cache
|
||||
@@ -518,7 +421,7 @@ internal static class Migrations
|
||||
if (JArray.Parse(File.ReadAllText(jsonFileV1)) is not JArray v1Cache || v1Cache.Count == 0)
|
||||
return;
|
||||
|
||||
Dictionary<string, JArray> cache = new();
|
||||
Dictionary<string, JArray> cache = [];
|
||||
|
||||
//Convert to c# objects to speed up searching by ID inside the iterator
|
||||
var allItems
|
||||
@@ -572,57 +475,4 @@ internal static class Migrations
|
||||
catch { /* eat */ }
|
||||
}
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_6_5(Configuration config)
|
||||
{
|
||||
//Settings migration for unsupported sample rates (#1116)
|
||||
if (config.MaxSampleRate < AAXClean.SampleRate.Hz_8000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_8000;
|
||||
else if (config.MaxSampleRate > AAXClean.SampleRate.Hz_48000)
|
||||
config.MaxSampleRate = AAXClean.SampleRate.Hz_48000;
|
||||
}
|
||||
|
||||
public static void migrate_to_v11_5_0(Configuration config)
|
||||
{
|
||||
// Read file, but convert old format to new (with Name field) as necessary.
|
||||
if (!File.Exists(QuickFilters.JsonFile))
|
||||
{
|
||||
QuickFilters.InMemoryState = new();
|
||||
return;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<QuickFilters.FilterState>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is QuickFilters.FilterState inMemState)
|
||||
{
|
||||
QuickFilters.InMemoryState = inMemState;
|
||||
return;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (JsonConvert.DeserializeObject<FilterState_6_6_9>(File.ReadAllText(QuickFilters.JsonFile))
|
||||
is FilterState_6_6_9 inMemState)
|
||||
{
|
||||
// Copy old structure to new.
|
||||
QuickFilters.InMemoryState = new();
|
||||
QuickFilters.InMemoryState.UseDefault = inMemState.UseDefault;
|
||||
foreach (var oldFilter in inMemState.Filters)
|
||||
QuickFilters.InMemoryState.Filters.Add(new QuickFilters.NamedFilter(oldFilter, null));
|
||||
|
||||
return;
|
||||
}
|
||||
Debug.Assert(false, "Should not get here, QuickFilters.json deserialization issue");
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Eat
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,566 @@
|
||||
using Newtonsoft.Json;
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Net.Http.Headers;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class AudiobookshelfApiService
|
||||
{
|
||||
public record Library(string Id, string Name, string MediaType, List<Folder> Folders);
|
||||
public record Folder(string Id, string FullPath);
|
||||
public enum UploadResult { Success, AlreadyExists, Failed }
|
||||
|
||||
/// <summary>
|
||||
/// Audiobookshelf client (browser) route segments. If present in a pasted URL,
|
||||
/// everything from that segment onward is UI path, not part of the API base.
|
||||
/// </summary>
|
||||
private static readonly HashSet<string> ClientRouteSegments = new(StringComparer.OrdinalIgnoreCase)
|
||||
{
|
||||
"library",
|
||||
"item",
|
||||
"collection",
|
||||
"playlist",
|
||||
"author",
|
||||
"series",
|
||||
"audiobook",
|
||||
"config",
|
||||
"login",
|
||||
"account",
|
||||
};
|
||||
|
||||
/// <summary>
|
||||
/// Normalize a user-supplied Audiobookshelf server URL to an API base address.
|
||||
/// Compensates for common mistakes such as pasting a browser library/item page URL,
|
||||
/// including a trailing <c>/api</c> path, query strings, or omitting the scheme.
|
||||
/// </summary>
|
||||
/// <returns>Absolute http(s) base URL with no trailing slash.</returns>
|
||||
public static string NormalizeServerUrl(string? serverUrl)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverUrl))
|
||||
throw new ArgumentException("Server URL is required.", nameof(serverUrl));
|
||||
|
||||
var trimmed = serverUrl.Trim();
|
||||
|
||||
// Allow host:port without a scheme (common when copying from docs or local setups).
|
||||
if (!trimmed.Contains("://", StringComparison.Ordinal))
|
||||
trimmed = "http://" + trimmed;
|
||||
|
||||
if (!Uri.TryCreate(trimmed, UriKind.Absolute, out var uri)
|
||||
|| (uri.Scheme != Uri.UriSchemeHttp && uri.Scheme != Uri.UriSchemeHttps)
|
||||
|| string.IsNullOrWhiteSpace(uri.Host))
|
||||
{
|
||||
throw new ArgumentException(
|
||||
"Server URL must be an absolute http(s) address, for example http://localhost:13378 or https://abs.example.com",
|
||||
nameof(serverUrl));
|
||||
}
|
||||
|
||||
var path = StripClientRoutesAndApi(uri.AbsolutePath);
|
||||
var authority = uri.GetLeftPart(UriPartial.Authority);
|
||||
if (string.IsNullOrEmpty(path) || path == "/")
|
||||
return authority;
|
||||
|
||||
return authority + path.TrimEnd('/');
|
||||
}
|
||||
|
||||
private static string StripClientRoutesAndApi(string absolutePath)
|
||||
{
|
||||
if (string.IsNullOrEmpty(absolutePath) || absolutePath == "/")
|
||||
return "";
|
||||
|
||||
var parts = absolutePath.Split('/', StringSplitOptions.RemoveEmptyEntries);
|
||||
var prefixLength = 0;
|
||||
|
||||
for (var i = 0; i < parts.Length; i++)
|
||||
{
|
||||
var part = parts[i];
|
||||
|
||||
// Trailing or mid-path /api/... means the user included the API root.
|
||||
if (string.Equals(part, "api", StringComparison.OrdinalIgnoreCase))
|
||||
break;
|
||||
|
||||
if (ClientRouteSegments.Contains(part))
|
||||
break;
|
||||
|
||||
prefixLength += 1 + part.Length;
|
||||
}
|
||||
|
||||
if (prefixLength <= 0)
|
||||
return "";
|
||||
|
||||
return absolutePath[..prefixLength];
|
||||
}
|
||||
|
||||
private static HttpClient CreateClient(string serverUrl)
|
||||
{
|
||||
var normalized = NormalizeServerUrl(serverUrl);
|
||||
var client = new HttpClient();
|
||||
client.BaseAddress = new Uri(normalized + "/");
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
return client;
|
||||
}
|
||||
|
||||
public static async Task<List<Library>> GetLibrariesAsync(string serverUrl, string apiToken)
|
||||
{
|
||||
var normalizedUrl = NormalizeServerUrl(serverUrl);
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
||||
|
||||
using var client = CreateClient(normalizedUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
|
||||
HttpResponseMessage response;
|
||||
try
|
||||
{
|
||||
response = await client.GetAsync("api/libraries");
|
||||
}
|
||||
catch (TaskCanceledException ex)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"Timed out connecting to Audiobookshelf at {normalizedUrl}. Check the Server URL and that the server is running.",
|
||||
ex);
|
||||
}
|
||||
catch (HttpRequestException ex)
|
||||
{
|
||||
throw new HttpRequestException(
|
||||
$"Could not reach Audiobookshelf at {normalizedUrl}. Check the Server URL, network, and that the server is running. Details: {ex.Message}",
|
||||
ex);
|
||||
}
|
||||
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
throw CreateLibrariesFetchException(normalizedUrl, response.StatusCode, errorBody);
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var obj = JObject.Parse(json);
|
||||
var libraries = obj["libraries"] as JArray ?? new JArray();
|
||||
|
||||
var allLibraries = libraries.Select(l => new Library(
|
||||
l["id"]?.Value<string>() ?? "",
|
||||
l["name"]?.Value<string>() ?? "",
|
||||
l["mediaType"]?.Value<string>() ?? "book",
|
||||
(l["folders"] as JArray ?? new JArray())
|
||||
.Select(f => new Folder(
|
||||
f["id"]?.Value<string>() ?? "",
|
||||
f["fullPath"]?.Value<string>() ?? ""))
|
||||
.ToList()))
|
||||
.ToList();
|
||||
|
||||
// Only return libraries with book media type
|
||||
return allLibraries.Where(l => string.Equals(l.MediaType, "book", StringComparison.OrdinalIgnoreCase)).ToList();
|
||||
}
|
||||
|
||||
private static HttpRequestException CreateLibrariesFetchException(string normalizedUrl, HttpStatusCode statusCode, string errorBody)
|
||||
{
|
||||
var code = (int)statusCode;
|
||||
var bodyPreview = SummarizeErrorBody(errorBody);
|
||||
|
||||
if (statusCode is HttpStatusCode.Unauthorized or HttpStatusCode.Forbidden)
|
||||
{
|
||||
return new HttpRequestException(
|
||||
$"Audiobookshelf rejected the API token ({code} {statusCode}). " +
|
||||
$"Check that the token is correct and not expired. Server: {normalizedUrl}");
|
||||
}
|
||||
|
||||
if (statusCode == HttpStatusCode.NotFound)
|
||||
{
|
||||
return new HttpRequestException(
|
||||
$"Audiobookshelf returned 404 when calling {normalizedUrl}/api/libraries. " +
|
||||
"Server URL should be the Audiobookshelf base address only " +
|
||||
"(for example http://localhost:13378 or https://abs.example.com), " +
|
||||
"not a library or book page from the browser. " +
|
||||
"If Audiobookshelf is served under a subpath, include that subpath " +
|
||||
$"(for example http://host/audiobookshelf). Response: {bodyPreview}");
|
||||
}
|
||||
|
||||
return new HttpRequestException(
|
||||
$"Audiobookshelf API returned {code} ({statusCode}) when fetching libraries from {normalizedUrl}. Response: {bodyPreview}");
|
||||
}
|
||||
|
||||
private static string SummarizeErrorBody(string errorBody)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(errorBody))
|
||||
return "(empty)";
|
||||
|
||||
var trimmed = errorBody.Trim();
|
||||
const int maxLen = 280;
|
||||
if (trimmed.Length <= maxLen)
|
||||
return trimmed;
|
||||
|
||||
return trimmed[..maxLen] + "...";
|
||||
}
|
||||
|
||||
public sealed record ConnectResult(
|
||||
bool Success,
|
||||
string StatusMessage,
|
||||
string? NormalizedServerUrl,
|
||||
bool ServerUrlAdjusted,
|
||||
IReadOnlyList<Library> Libraries);
|
||||
|
||||
/// <summary>
|
||||
/// Normalize URL, fetch book libraries, and produce UI status text.
|
||||
/// Callers apply busy-state and bind <see cref="ConnectResult.Libraries"/> to controls.
|
||||
/// </summary>
|
||||
public static async Task<ConnectResult> ConnectAsync(string? serverUrl, string? apiToken)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(serverUrl) || string.IsNullOrWhiteSpace(apiToken))
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: "Please enter both server URL and API token.",
|
||||
NormalizedServerUrl: null,
|
||||
ServerUrlAdjusted: false,
|
||||
Libraries: []);
|
||||
}
|
||||
|
||||
string normalizedUrl;
|
||||
bool urlAdjusted;
|
||||
try
|
||||
{
|
||||
var trimmedUrl = serverUrl.Trim();
|
||||
normalizedUrl = NormalizeServerUrl(trimmedUrl);
|
||||
urlAdjusted = !string.Equals(trimmedUrl.TrimEnd('/'), normalizedUrl, StringComparison.OrdinalIgnoreCase);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: $"Connection failed: {ex.Message}",
|
||||
NormalizedServerUrl: null,
|
||||
ServerUrlAdjusted: false,
|
||||
Libraries: []);
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var libraries = await GetLibrariesAsync(normalizedUrl, apiToken.Trim());
|
||||
if (libraries.Count == 0)
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: "No book libraries found. Check your settings.",
|
||||
NormalizedServerUrl: normalizedUrl,
|
||||
ServerUrlAdjusted: urlAdjusted,
|
||||
Libraries: []);
|
||||
}
|
||||
|
||||
return new ConnectResult(
|
||||
Success: true,
|
||||
StatusMessage: FormatConnectedStatus(libraries.Count, urlAdjusted),
|
||||
NormalizedServerUrl: normalizedUrl,
|
||||
ServerUrlAdjusted: urlAdjusted,
|
||||
Libraries: libraries);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new ConnectResult(
|
||||
Success: false,
|
||||
StatusMessage: $"Connection failed: {ex.Message}",
|
||||
NormalizedServerUrl: normalizedUrl,
|
||||
ServerUrlAdjusted: urlAdjusted,
|
||||
Libraries: []);
|
||||
}
|
||||
}
|
||||
|
||||
public static string FormatConnectedStatus(int libraryCount, bool urlAdjusted)
|
||||
{
|
||||
var libraryWord = libraryCount == 1 ? "library" : "libraries";
|
||||
var message = $"Connected. Found {libraryCount} {libraryWord}.";
|
||||
if (urlAdjusted)
|
||||
message += " Server URL adjusted to the API base address.";
|
||||
return message;
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Normalize for persistence when possible; otherwise keep the trimmed raw value
|
||||
/// so the user can fix it on the next connect attempt.
|
||||
/// </summary>
|
||||
public static string TryNormalizeServerUrlForSave(string? url)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(url))
|
||||
return url?.Trim() ?? "";
|
||||
|
||||
try
|
||||
{
|
||||
return NormalizeServerUrl(url);
|
||||
}
|
||||
catch (ArgumentException)
|
||||
{
|
||||
return url.Trim();
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<bool> BookExistsAsync(string serverUrl, string apiToken, string libraryId, string title, string? author = null)
|
||||
{
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
||||
using var client = CreateClient(serverUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
client.Timeout = TimeSpan.FromSeconds(30);
|
||||
|
||||
var normalizedTitle = NormalizeTitle(title);
|
||||
var baseTitle = GetBaseTitle(normalizedTitle);
|
||||
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: looking for '{Title}' (base: '{BaseTitle}') in library {LibraryId}", normalizedTitle, baseTitle, libraryId);
|
||||
|
||||
try
|
||||
{
|
||||
// Search with base title first (shorter = broader fuzzy matches, better recall)
|
||||
var candidates = await SearchLibraryAsync(client, libraryId, baseTitle, 20);
|
||||
|
||||
// Also search with full title to catch cases where ABS stores the longer/subtitled version
|
||||
if (!string.Equals(normalizedTitle, baseTitle, StringComparison.Ordinal))
|
||||
{
|
||||
var fullCandidates = await SearchLibraryAsync(client, libraryId, normalizedTitle, 10);
|
||||
foreach (var c in fullCandidates)
|
||||
{
|
||||
if (!candidates.Any(existing => string.Equals(existing.Id, c.Id, StringComparison.Ordinal)))
|
||||
candidates.Add(c);
|
||||
}
|
||||
}
|
||||
|
||||
if (candidates.Count == 0)
|
||||
{
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: no candidates found for '{Title}' in library {LibraryId}", normalizedTitle, libraryId);
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: found {Count} candidate(s) for '{Title}' in library {LibraryId}", candidates.Count, normalizedTitle, libraryId);
|
||||
|
||||
foreach (var candidate in candidates)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(candidate.Title))
|
||||
continue;
|
||||
|
||||
bool titleMatch = false;
|
||||
|
||||
// 1) Exact match against normalized title
|
||||
if (string.Equals(candidate.Title, normalizedTitle, StringComparison.OrdinalIgnoreCase))
|
||||
titleMatch = true;
|
||||
|
||||
// 2) Exact match against base title (without subtitle)
|
||||
if (!titleMatch && string.Equals(candidate.Title, baseTitle, StringComparison.OrdinalIgnoreCase))
|
||||
titleMatch = true;
|
||||
|
||||
// 3) Exact match against combined title:subtitle
|
||||
if (!titleMatch && !string.IsNullOrWhiteSpace(candidate.FullTitle)
|
||||
&& string.Equals(candidate.FullTitle, normalizedTitle, StringComparison.OrdinalIgnoreCase))
|
||||
titleMatch = true;
|
||||
|
||||
if (titleMatch && AuthorsCompatible(author, candidate.Authors))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Audiobookshelf duplicate check: found match for '{Title}' (id={ItemId})", title, candidate.Id);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Debug("Audiobookshelf duplicate check: '{Title}' not found in library {LibraryId}", normalizedTitle, libraryId);
|
||||
return false;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Debug(ex, "Audiobookshelf duplicate check failed for '{Title}' in library {LibraryId}", title, libraryId);
|
||||
throw; // Let caller decide whether to proceed with upload
|
||||
}
|
||||
}
|
||||
|
||||
private record SearchCandidate(string Id, string Title, string FullTitle, List<string> Authors);
|
||||
|
||||
private static async Task<List<SearchCandidate>> SearchLibraryAsync(HttpClient client, string libraryId, string query, int limit)
|
||||
{
|
||||
var url = $"api/libraries/{Uri.EscapeDataString(libraryId)}/search?q={Uri.EscapeDataString(query)}&limit={limit}";
|
||||
var response = await client.GetAsync(url);
|
||||
if (!response.IsSuccessStatusCode)
|
||||
{
|
||||
var errorBody = await response.Content.ReadAsStringAsync();
|
||||
throw new HttpRequestException($"Audiobookshelf search returned {(int)response.StatusCode} for query '{query}'. Body: {errorBody}");
|
||||
}
|
||||
|
||||
var json = await response.Content.ReadAsStringAsync();
|
||||
var obj = JObject.Parse(json);
|
||||
var books = obj["book"] as JArray ?? new JArray();
|
||||
|
||||
var results = new List<SearchCandidate>();
|
||||
foreach (var entry in books)
|
||||
{
|
||||
var item = entry["libraryItem"] ?? entry;
|
||||
var id = item["id"]?.Value<string>() ?? entry["id"]?.Value<string>() ?? "";
|
||||
var itemTitle = item["media"]?["metadata"]?["title"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
|
||||
var itemSubtitle = item["media"]?["metadata"]?["subtitle"]?.Value<string>()?.Replace("\u00A0", " ").Trim();
|
||||
|
||||
var itemFullTitle = string.IsNullOrWhiteSpace(itemSubtitle)
|
||||
? itemTitle
|
||||
: $"{itemTitle}: {itemSubtitle}";
|
||||
|
||||
var authors = item["media"]?["metadata"]?["authors"] as JArray;
|
||||
var authorList = authors?.Select(a => a["name"]?.Value<string>()?.Trim() ?? "")
|
||||
.Where(n => !string.IsNullOrWhiteSpace(n))
|
||||
.ToList() ?? [];
|
||||
|
||||
results.Add(new SearchCandidate(
|
||||
id,
|
||||
NormalizeTitle(itemTitle),
|
||||
NormalizeTitle(itemFullTitle),
|
||||
authorList));
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
private static bool AuthorsCompatible(string? libationAuthor, List<string> absAuthors)
|
||||
{
|
||||
// If either side has no author info, we can't use authors to disambiguate
|
||||
if (string.IsNullOrWhiteSpace(libationAuthor))
|
||||
return true;
|
||||
if (absAuthors == null || absAuthors.Count == 0)
|
||||
return true;
|
||||
|
||||
var libationAuthors = libationAuthor.Split(',', StringSplitOptions.RemoveEmptyEntries)
|
||||
.Select(a => NormalizeAuthor(a))
|
||||
.Where(a => !string.IsNullOrWhiteSpace(a))
|
||||
.ToList();
|
||||
|
||||
if (libationAuthors.Count == 0)
|
||||
return true;
|
||||
|
||||
foreach (var absAuthor in absAuthors.Select(NormalizeAuthor))
|
||||
{
|
||||
foreach (var la in libationAuthors)
|
||||
{
|
||||
if (string.Equals(absAuthor, la, StringComparison.OrdinalIgnoreCase))
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private static string NormalizeAuthor(string? author)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(author))
|
||||
return author ?? "";
|
||||
return author.Replace("\u00A0", " ").Trim();
|
||||
}
|
||||
|
||||
private static string NormalizeTitle(string? title)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(title))
|
||||
return title ?? "";
|
||||
|
||||
var normalized = title
|
||||
.Replace("\u00A0", " ")
|
||||
.Replace("(Unabridged)", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Replace("(Abridged)", "", StringComparison.OrdinalIgnoreCase)
|
||||
.Trim();
|
||||
|
||||
// Collapse multiple spaces to single space
|
||||
while (normalized.Contains(" ", StringComparison.Ordinal))
|
||||
normalized = normalized.Replace(" ", " ", StringComparison.Ordinal);
|
||||
|
||||
return normalized;
|
||||
}
|
||||
|
||||
private static string GetBaseTitle(string fullTitle)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(fullTitle))
|
||||
return fullTitle;
|
||||
|
||||
var colonIndex = fullTitle.IndexOf(": ", StringComparison.Ordinal);
|
||||
return colonIndex > 0 ? fullTitle[..colonIndex].Trim() : fullTitle;
|
||||
}
|
||||
|
||||
public static async Task<UploadResult> UploadBookAsync(
|
||||
string serverUrl,
|
||||
string apiToken,
|
||||
string libraryId,
|
||||
string folderId,
|
||||
string title,
|
||||
string? author,
|
||||
string? series,
|
||||
IEnumerable<string> filePaths)
|
||||
{
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(apiToken) ?? "";
|
||||
|
||||
// Pre-check for existing item
|
||||
try
|
||||
{
|
||||
if (await BookExistsAsync(serverUrl, apiToken, libraryId, title, author))
|
||||
{
|
||||
Serilog.Log.Logger.Information("Skipping Audiobookshelf upload: book '{Title}' already exists in library {LibraryId}", title, libraryId);
|
||||
return UploadResult.AlreadyExists;
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Pre-check for existing book on Audiobookshelf failed; will still attempt upload");
|
||||
// Continue to upload attempt below
|
||||
}
|
||||
|
||||
using var client = CreateClient(serverUrl);
|
||||
client.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", apiToken);
|
||||
client.Timeout = TimeSpan.FromMinutes(30);
|
||||
|
||||
using var form = new MultipartFormDataContent();
|
||||
form.Add(new StringContent(title), "title");
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(author))
|
||||
form.Add(new StringContent(author), "author");
|
||||
if (!string.IsNullOrWhiteSpace(series))
|
||||
form.Add(new StringContent(series), "series");
|
||||
|
||||
form.Add(new StringContent(libraryId), "library");
|
||||
form.Add(new StringContent(folderId), "folder");
|
||||
|
||||
int fileIndex = 0;
|
||||
var streams = new List<Stream>();
|
||||
try
|
||||
{
|
||||
foreach (var path in filePaths.Where(File.Exists))
|
||||
{
|
||||
var stream = File.OpenRead(path);
|
||||
streams.Add(stream);
|
||||
var fileContent = new StreamContent(stream);
|
||||
fileContent.Headers.ContentType = new MediaTypeHeaderValue("application/octet-stream");
|
||||
form.Add(fileContent, fileIndex.ToString(), Path.GetFileName(path));
|
||||
fileIndex++;
|
||||
}
|
||||
|
||||
if (fileIndex == 0)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("No audio files found to upload to Audiobookshelf for '{Title}'", title);
|
||||
return UploadResult.Failed;
|
||||
}
|
||||
|
||||
var response = await client.PostAsync("api/upload", form);
|
||||
|
||||
if (response.IsSuccessStatusCode)
|
||||
return UploadResult.Success;
|
||||
|
||||
var responseBody = await response.Content.ReadAsStringAsync();
|
||||
Serilog.Log.Logger.Error("Audiobookshelf upload failed for '{Title}' with status {(int)response.StatusCode} ({StatusCode}). Response body: {ResponseBody}",
|
||||
title, (int)response.StatusCode, response.StatusCode, responseBody);
|
||||
|
||||
// Treat "already exists" 500 errors as skip/success
|
||||
if (response.StatusCode == System.Net.HttpStatusCode.InternalServerError
|
||||
&& responseBody.Contains("already exists", StringComparison.OrdinalIgnoreCase))
|
||||
{
|
||||
return UploadResult.AlreadyExists;
|
||||
}
|
||||
|
||||
return UploadResult.Failed;
|
||||
}
|
||||
finally
|
||||
{
|
||||
foreach (var stream in streams)
|
||||
{
|
||||
try { stream.Dispose(); } catch { /* ignored */ }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
using AudibleApi.Authorization;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace ApplicationServices;
|
||||
|
||||
public static class AudiobookshelfTokenStorage
|
||||
{
|
||||
private const string EncryptedPrefix = "enc:";
|
||||
private const string AssociatedData = "audiobookshelf-api-token";
|
||||
|
||||
public static string? EncryptToken(string? plaintextToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(plaintextToken))
|
||||
return plaintextToken;
|
||||
|
||||
// Guard against double-encrypting an already-encrypted value
|
||||
if (plaintextToken.StartsWith(EncryptedPrefix))
|
||||
return plaintextToken;
|
||||
|
||||
if (Configuration.Instance.TokenStorageMethod != TokenStorageMethod.Encrypted)
|
||||
return plaintextToken;
|
||||
|
||||
var protector = IdentityTokenStorage.Protector;
|
||||
if (protector is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Audiobookshelf API token encryption requested but no protector is available; storing plaintext");
|
||||
return plaintextToken;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var encrypted = protector.Protect(plaintextToken, AssociatedData);
|
||||
return EncryptedPrefix + encrypted;
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to encrypt Audiobookshelf API token; storing plaintext");
|
||||
return plaintextToken;
|
||||
}
|
||||
}
|
||||
|
||||
public static string? DecryptToken(string? storedToken)
|
||||
{
|
||||
if (string.IsNullOrEmpty(storedToken))
|
||||
return storedToken;
|
||||
|
||||
if (!storedToken.StartsWith(EncryptedPrefix))
|
||||
return storedToken;
|
||||
|
||||
var payload = storedToken[EncryptedPrefix.Length..];
|
||||
var protector = IdentityTokenStorage.Protector;
|
||||
if (protector is null)
|
||||
{
|
||||
Serilog.Log.Logger.Warning("Audiobookshelf API token decryption requested but no protector is available; returning stored value");
|
||||
return storedToken;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
return protector.Unprotect(payload, AssociatedData);
|
||||
}
|
||||
catch (System.Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to decrypt Audiobookshelf API token; returning stored value");
|
||||
return storedToken;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,9 +132,9 @@ public static class DbContexts
|
||||
return context.GetDeletedLibraryBooks();
|
||||
}
|
||||
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
public static LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
using var context = GetContext();
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative);
|
||||
return context.GetLibraryBook_Flat_NoTracking(productId, caseSensative, account);
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Text;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using static DtoImporterService.PerfLogger;
|
||||
|
||||
@@ -23,7 +24,12 @@ public static class LibraryCommands
|
||||
public static event EventHandler<int>? ScanEnd;
|
||||
|
||||
public static bool Scanning { get; private set; }
|
||||
private static object _lock { get; } = new();
|
||||
|
||||
/// <summary>
|
||||
/// Serializes library scan and import operations so only one path reads/writes
|
||||
/// <see cref="Book"/> / <see cref="LibraryBook"/> rows at a time (prevents duplicate ASIN inserts).
|
||||
/// </summary>
|
||||
private static readonly SemaphoreSlim ImportGate = new(1, 1);
|
||||
|
||||
static LibraryCommands()
|
||||
{
|
||||
@@ -35,151 +41,170 @@ public static class LibraryCommands
|
||||
{
|
||||
logRestart();
|
||||
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return new();
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||
};
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return new List<LibraryBook>();
|
||||
|
||||
await ImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
var missingBookList = existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
|
||||
return missingBookList;
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
//These are the minimum response groups required for the
|
||||
//library scanner to pass all validation and filtering.
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.ProductAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships
|
||||
};
|
||||
|
||||
try
|
||||
{
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin: true);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = libraryItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
return existingLibrary.Where(b => !libraryItems.Any(i => i.DtoItem.Asin == b.Book.AudibleProductId)).ToList();
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error scanning library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error scanning library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, 0);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, 0);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
#region FULL LIBRARY scan and import
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
public static Task<(int totalCount, int newCount)> ImportAccountAsync(params Account[]? accounts)
|
||||
=> ImportAccountAsync(accounts, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<(int totalCount, int newCount)> ImportAccountAsync(Account[]? accounts, bool allowInteractiveLogin)
|
||||
{
|
||||
logRestart();
|
||||
|
||||
if (accounts is null || accounts.Length == 0)
|
||||
return (0, 0);
|
||||
|
||||
await ImportGate.WaitAsync();
|
||||
int newCount = 0;
|
||||
try
|
||||
{
|
||||
lock (_lock)
|
||||
{
|
||||
if (Scanning)
|
||||
return (0, 0);
|
||||
}
|
||||
ScanBegin?.Invoke(null, accounts.Length);
|
||||
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
try
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
logTime($"pre {nameof(scanAccountsAsync)} all");
|
||||
var libraryOptions = new LibraryOptions
|
||||
{
|
||||
ResponseGroups
|
||||
= LibraryOptions.ResponseGroupOptions.Rating | LibraryOptions.ResponseGroupOptions.Media
|
||||
| LibraryOptions.ResponseGroupOptions.Relationships | LibraryOptions.ResponseGroupOptions.ProductDesc
|
||||
| LibraryOptions.ResponseGroupOptions.Contributors | LibraryOptions.ResponseGroupOptions.ProvidedReview
|
||||
| LibraryOptions.ResponseGroupOptions.ProductPlans | LibraryOptions.ResponseGroupOptions.Series
|
||||
| LibraryOptions.ResponseGroupOptions.CategoryLadders | LibraryOptions.ResponseGroupOptions.ProductExtendedAttrs
|
||||
| LibraryOptions.ResponseGroupOptions.PdfUrl | LibraryOptions.ResponseGroupOptions.OriginAsin
|
||||
| LibraryOptions.ResponseGroupOptions.IsFinished,
|
||||
ImageSizes = LibraryOptions.ImageSizeOptions._500 | LibraryOptions.ImageSizeOptions._1215
|
||||
};
|
||||
var importItems = await scanAccountsAsync(accounts, libraryOptions, allowInteractiveLogin);
|
||||
logTime($"post {nameof(scanAccountsAsync)} all");
|
||||
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
var totalCount = importItems.Count;
|
||||
Log.Logger.Information($"GetAllLibraryItems: Total count {totalCount}");
|
||||
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
if (totalCount == 0)
|
||||
return default;
|
||||
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
Log.Logger.Information("Begin long-running import");
|
||||
logTime($"pre {nameof(ImportIntoDbAsync)}");
|
||||
newCount = await Task.Run(() => ImportIntoDbAsync(importItems));
|
||||
logTime($"post {nameof(ImportIntoDbAsync)}");
|
||||
Log.Logger.Information($"Import complete. New count {newCount}");
|
||||
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||
return (totalCount, newCount);
|
||||
}
|
||||
catch (AudibleApi.Authentication.LoginFailedException lfEx)
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
lfEx.SaveFiles(Configuration.Instance.LibationFiles.Location);
|
||||
|
||||
// nuget Serilog.Exceptions would automatically log custom properties
|
||||
// However, it comes with a scary warning when used with EntityFrameworkCore which I'm not yet ready to implement:
|
||||
// https://github.com/RehanSaeed/Serilog.Exceptions
|
||||
// work-around: use 3rd param. don't just put exception object in 3rd param -- info overload: stack trace, etc
|
||||
Log.Logger.Error(lfEx, "Error importing library. Login failed. {@DebugInfo}", new
|
||||
{
|
||||
lfEx.RequestUrl,
|
||||
ResponseStatusCodeNumber = (int)lfEx.ResponseStatusCode,
|
||||
ResponseStatusCodeDesc = lfEx.ResponseStatusCode,
|
||||
lfEx.ResponseInputFields,
|
||||
lfEx.ResponseBodyFilePaths
|
||||
});
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Log.Logger.Error(ex, "Error importing library");
|
||||
throw;
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, newCount);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
}
|
||||
}
|
||||
finally
|
||||
{
|
||||
stop();
|
||||
var putBreakPointHere = logOutput;
|
||||
ScanEnd?.Invoke(null, newCount);
|
||||
GC.Collect(GC.MaxGeneration, GCCollectionMode.Aggressive, true, true);
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
|
||||
public static Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName) => Task.Run(() => importSingleToDb(item, accountId, localeName));
|
||||
public static async Task<int> ImportSingleToDbAsync(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
await ImportGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
return importSingleToDb(item, accountId, localeName);
|
||||
}
|
||||
finally
|
||||
{
|
||||
ImportGate.Release();
|
||||
}
|
||||
}
|
||||
private static int importSingleToDb(AudibleApi.Common.Item item, string accountId, string localeName)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(item, nameof(item));
|
||||
@@ -250,7 +275,7 @@ public static class LibraryCommands
|
||||
return null;
|
||||
}
|
||||
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions)
|
||||
private static async Task<List<ImportItem>> scanAccountsAsync(Account[] accounts, LibraryOptions libraryOptions, bool allowInteractiveLogin)
|
||||
{
|
||||
var tasks = new List<Task<List<ImportItem>>>();
|
||||
|
||||
@@ -266,11 +291,15 @@ public static class LibraryCommands
|
||||
try
|
||||
{
|
||||
// get APIs in serial b/c of logins. do NOT move inside of parallel (Task.WhenAll)
|
||||
var apiExtended = await ApiExtended.CreateAsync(account);
|
||||
var apiExtended = await ApiExtended.CreateAsync(account, allowInteractiveLogin);
|
||||
|
||||
// add scanAccountAsync as a TASK: do not await
|
||||
tasks.Add(scanAccountAsync(apiExtended, account, libraryOptions, archiver));
|
||||
}
|
||||
catch (Exception ex) when (!allowInteractiveLogin && AuthenticationExceptionHelper.IsAuthenticationFailure(ex))
|
||||
{
|
||||
throw;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
//Catch to allow other accounts to continue scanning.
|
||||
|
||||
@@ -0,0 +1,89 @@
|
||||
using AudibleApi.Authorization;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Libation-facing helpers when AccountsSettings.json contains encrypted tokens
|
||||
/// that cannot be unlocked on this OS/machine (common when copying Windows config into Docker).
|
||||
/// </summary>
|
||||
public static class AccountSettingsDecryptFailure
|
||||
{
|
||||
public const string LoadErrorCaption = "Error Loading Account Settings";
|
||||
|
||||
public const string FaqUrl
|
||||
= "https://getlibation.com/docs/frequently-asked-questions#docker-finds-no-new-books-failed-to-decrypt-existingaccesstoken";
|
||||
|
||||
private static readonly string[] ThingsToTryBullets =
|
||||
[
|
||||
"Keep tokens encrypted: on the machine that created the file, export the master key (Settings -> Important -> Export encryption key..., or LibationCli export-master-key libation-master.key), then copy that file into the Docker/config folder next to AccountsSettings.json (or set LIBATION_MASTER_KEY_FILE / LIBATION_MASTER_KEY).",
|
||||
"Or convert to plaintext: Settings -> Important, uncheck \"Store authentication tokens encrypted\", convert existing tokens when prompted, then copy the updated AccountsSettings.json again.",
|
||||
"Or re-authenticate on this machine/container with LibationCli login-external or import-account.",
|
||||
$"Details: {FaqUrl}",
|
||||
];
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="ex"/> (or an inner exception) is a decrypt failure from identity token JSON.
|
||||
/// </summary>
|
||||
public static bool TryFindInTree(Exception ex, out IdentityTokenDecryptException? match)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
IdentityTokenDecryptException? found = null;
|
||||
walk(ex);
|
||||
match = found;
|
||||
return found is not null;
|
||||
|
||||
void walk(Exception? e)
|
||||
{
|
||||
if (e is null || found is not null)
|
||||
return;
|
||||
|
||||
if (e is IdentityTokenDecryptException decryptEx)
|
||||
{
|
||||
found = decryptEx;
|
||||
return;
|
||||
}
|
||||
|
||||
if (e is AggregateException agg)
|
||||
{
|
||||
foreach (var inner in agg.InnerExceptions)
|
||||
walk(inner);
|
||||
}
|
||||
|
||||
walk(e.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
public static IEnumerable<string> GetExplainerLines(Exception ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
|
||||
yield return "Encrypted authentication tokens in AccountsSettings.json could not be decrypted on this machine.";
|
||||
yield return "The encryption key is stored in the OS secret store where the tokens were encrypted (for example Windows DPAPI) and does not travel when you copy the file to Docker or another computer.";
|
||||
if (TryFindInTree(ex, out var decryptEx) && decryptEx is not null)
|
||||
yield return $"Underlying error: {decryptEx.Message.TrimEnd('.')}.";
|
||||
yield return string.Empty;
|
||||
yield return "Things to try:";
|
||||
foreach (var bullet in ThingsToTryBullets)
|
||||
yield return "• " + bullet;
|
||||
}
|
||||
|
||||
public static string GetExplainerBody(Exception ex)
|
||||
=> string.Join("\r\n", GetExplainerLines(ex));
|
||||
|
||||
/// <summary>
|
||||
/// Dialog body after backup-and-empty recovery when load failed due to token decrypt.
|
||||
/// </summary>
|
||||
public static string GetRecoveredDialogBody(Exception ex, string backupPath)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(backupPath);
|
||||
|
||||
return $"""
|
||||
{GetExplainerBody(ex)}
|
||||
|
||||
Libation created a new, empty account settings file so the app can start. You will need to re-add your Audible account(s) (or copy a plaintext AccountsSettings.json) before scanning or downloading.
|
||||
|
||||
The previous account settings file was archived at '{backupPath}'
|
||||
""";
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using AudibleApi.Authorization;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Alignment and conversion helpers for identity tokens in <c>AccountsSettings.json</c>.
|
||||
/// Preference changes alone do not convert; conversion is explicit via <see cref="ConvertAllAccounts"/>.
|
||||
/// </summary>
|
||||
public static class AccountTokenStorage
|
||||
{
|
||||
/// <summary>
|
||||
/// Compare persisted encryption metadata for all account identities to <paramref name="method"/>.
|
||||
/// </summary>
|
||||
public static TokenStorageAlignment GetAccountsAlignment(TokenStorageMethod method, string? accountsSettingsFile = null)
|
||||
{
|
||||
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
|
||||
if (!File.Exists(path))
|
||||
return TokenStorageAlignment.NoApplicableTokens;
|
||||
|
||||
using var persister = new AccountsSettingsPersister(path);
|
||||
var alignments = new List<TokenStorageAlignment>();
|
||||
|
||||
foreach (var account in persister.AccountsSettings.Accounts)
|
||||
{
|
||||
if (account.IdentityTokens is null)
|
||||
continue;
|
||||
|
||||
alignments.Add(IdentityTokenConversion.GetAlignment(account.IdentityTokens, method));
|
||||
}
|
||||
|
||||
return CombineAlignments(alignments);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Convert every identity in the accounts settings file to <paramref name="method"/>.
|
||||
/// All-or-nothing: failure leaves the original file unchanged.
|
||||
/// </summary>
|
||||
public static IdentityTokenConversionResult ConvertAllAccounts(TokenStorageMethod method, string? accountsSettingsFile = null)
|
||||
{
|
||||
var path = accountsSettingsFile ?? AudibleApiStorage.AccountsSettingsFile;
|
||||
return IdentityTokenConversion.ConvertAllIdentitiesInFile(path, method);
|
||||
}
|
||||
|
||||
private static TokenStorageAlignment CombineAlignments(IReadOnlyList<TokenStorageAlignment> alignments)
|
||||
{
|
||||
if (alignments.Count == 0)
|
||||
return TokenStorageAlignment.NoApplicableTokens;
|
||||
if (alignments.Any(a => a == TokenStorageAlignment.Indeterminate))
|
||||
return TokenStorageAlignment.Indeterminate;
|
||||
if (alignments.All(a => a is TokenStorageAlignment.AllMatch or TokenStorageAlignment.NoApplicableTokens))
|
||||
{
|
||||
return alignments.Any(a => a == TokenStorageAlignment.AllMatch)
|
||||
? TokenStorageAlignment.AllMatch
|
||||
: TokenStorageAlignment.NoApplicableTokens;
|
||||
}
|
||||
|
||||
return TokenStorageAlignment.SomeMismatch;
|
||||
}
|
||||
}
|
||||
@@ -26,8 +26,13 @@ public class ApiExtended
|
||||
|
||||
private ApiExtended(Api api) => Api = api;
|
||||
|
||||
private static readonly SemaphoreSlim InteractiveLoginGate = new(1, 1);
|
||||
|
||||
/// <summary>Get api from existing tokens else login with 'eager' choice. External browser url is provided. Response can be external browser login or continuing with native api callbacks.</summary>
|
||||
public static async Task<ApiExtended> CreateAsync(Account account)
|
||||
public static Task<ApiExtended> CreateAsync(Account account)
|
||||
=> CreateAsync(account, allowInteractiveLogin: true);
|
||||
|
||||
public static async Task<ApiExtended> CreateAsync(Account account, bool allowInteractiveLogin)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNull(account, nameof(account));
|
||||
ArgumentValidator.EnsureNotNull(account.AccountId, nameof(account.AccountId));
|
||||
@@ -46,25 +51,52 @@ public class ApiExtended
|
||||
account.GetIdentityTokensJsonPath());
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
catch
|
||||
catch (Exception ex)
|
||||
{
|
||||
if (LoginChoiceFactory is null)
|
||||
throw new InvalidOperationException($"The UI module must first set {nameof(LoginChoiceFactory)} before attempting to create the api");
|
||||
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
if (!allowInteractiveLogin || LoginChoiceFactory is null)
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
Serilog.Log.Logger.Error(ex,
|
||||
"Stored credentials could not be used and interactive login is not available. {@DebugInfo}",
|
||||
new
|
||||
{
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name,
|
||||
AllowInteractiveLogin = allowInteractiveLogin,
|
||||
LoginChoiceFactorySet = LoginChoiceFactory is not null
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
throw new AuthenticationRequiredException(
|
||||
account,
|
||||
message: $"Stored credentials for '{account.AccountId}' could not be used"
|
||||
+ (LoginChoiceFactory is null
|
||||
? " and interactive login is not available in this context (CLI/Docker)."
|
||||
: " and interactive login was not allowed.")
|
||||
+ $" {ex.Message}",
|
||||
innerException: ex);
|
||||
}
|
||||
|
||||
return new ApiExtended(api);
|
||||
await InteractiveLoginGate.WaitAsync();
|
||||
try
|
||||
{
|
||||
Serilog.Log.Logger.Information("{@DebugInfo}", new
|
||||
{
|
||||
LoginType = nameof(ILoginChoiceEager),
|
||||
Account = account.MaskedLogEntry ?? "[null]",
|
||||
LocaleName = locale.Name
|
||||
});
|
||||
|
||||
var api = await EzApiCreator.GetApiAsync(
|
||||
LoginChoiceFactory(account),
|
||||
locale,
|
||||
AudibleApiStorage.AccountsSettingsFile,
|
||||
account.GetIdentityTokensJsonPath());
|
||||
|
||||
return new ApiExtended(api);
|
||||
}
|
||||
finally
|
||||
{
|
||||
InteractiveLoginGate.Release();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -62,24 +62,16 @@ public static class AudibleApiStorage
|
||||
=> GetIdentityTokensJsonPath(account.AccountId, account.Locale?.Name);
|
||||
public static string GetIdentityTokensJsonPath(string username, string? localeName)
|
||||
{
|
||||
var usernameSanitized = trimSurroundingQuotes(JsonConvert.ToString(username));
|
||||
var localeNameSanitized = trimSurroundingQuotes(JsonConvert.ToString(localeName));
|
||||
var usernameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(username);
|
||||
var localeNameEscaped = EscapeNewtonsoftJsonPathSingleQuotedLiteral(localeName ?? string.Empty);
|
||||
|
||||
return $"$.Accounts[?(@.AccountId == '{usernameSanitized}' && @.IdentityTokens.LocaleName == '{localeNameSanitized}')].IdentityTokens";
|
||||
return $"$.Accounts[?(@.AccountId == '{usernameEscaped}' && @.IdentityTokens.LocaleName == '{localeNameEscaped}')].IdentityTokens";
|
||||
}
|
||||
private static string trimSurroundingQuotes(string str)
|
||||
{
|
||||
// SubString algo is better than .Trim("\"")
|
||||
// orig string "
|
||||
// json string "\""
|
||||
// Eg:
|
||||
// => str.Trim("\"")
|
||||
// output \
|
||||
// vs
|
||||
// => str.Substring(1, str.Length - 2)
|
||||
// output \"
|
||||
// also works with surrounding single quotes
|
||||
|
||||
return str.Substring(1, str.Length - 2);
|
||||
}
|
||||
/// <summary>
|
||||
/// Escape a value for use inside single-quoted Newtonsoft JSONPath filter literals.
|
||||
/// See https://www.newtonsoft.com/json/help/html/QueryJsonSelectTokenEscaped.htm
|
||||
/// </summary>
|
||||
internal static string EscapeNewtonsoftJsonPathSingleQuotedLiteral(string value)
|
||||
=> value.Replace("\\", @"\\").Replace("'", @"\'");
|
||||
}
|
||||
@@ -3,10 +3,11 @@
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
<Nullable>enable</Nullable>
|
||||
<ImplicitUsings>enable</ImplicitUsings>
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="10.1.5.1" />
|
||||
<PackageReference Include="AudibleApi" Version="10.3.5.1" />
|
||||
<PackageReference Include="Google.Protobuf" Version="3.34.1" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
using AudibleApi.Authentication;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
public static class AuthenticationExceptionHelper
|
||||
{
|
||||
public static bool IsAuthenticationFailure(Exception ex)
|
||||
{
|
||||
if (ex is AggregateException aggregate)
|
||||
{
|
||||
return aggregate.InnerExceptions.Any(IsAuthenticationFailure)
|
||||
|| (aggregate.InnerException is not null && IsAuthenticationFailure(aggregate.InnerException));
|
||||
}
|
||||
|
||||
for (var current = ex; current is not null; current = current.InnerException)
|
||||
{
|
||||
if (current is AuthenticationRequiredException or LoginFailedException)
|
||||
return true;
|
||||
|
||||
if (current is InvalidOperationException { Message: var message }
|
||||
&& message.Contains("ADP token is null", StringComparison.Ordinal))
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Stored Audible credentials are missing or invalid and interactive login is required.
|
||||
/// Thrown instead of opening login UI when the caller disallows interactive login (e.g. auto-scan).
|
||||
/// </summary>
|
||||
public sealed class AuthenticationRequiredException : Exception
|
||||
{
|
||||
public Account? Account { get; }
|
||||
|
||||
public AuthenticationRequiredException(Account? account, string? message = null, Exception? innerException = null)
|
||||
: base(message ?? "Audible authentication is required.", innerException)
|
||||
=> Account = account;
|
||||
}
|
||||
@@ -0,0 +1,286 @@
|
||||
using AudibleApi.Authorization;
|
||||
using Dinah.Core.IO;
|
||||
using Dinah.Core.Security;
|
||||
using LibationFileManager;
|
||||
using System.ComponentModel;
|
||||
using System.Security.Cryptography;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Applies Libation's <see cref="Configuration.TokenStorageMethod"/> to AudibleApi identity persistence.
|
||||
/// Changing the preference alone does not convert existing tokens.
|
||||
/// </summary>
|
||||
public static class IdentityTokenStorageWiring
|
||||
{
|
||||
public const string ApplicationName = "Libation";
|
||||
|
||||
/// <summary>Env var: path to a raw 32-byte master key file (from <c>export-master-key</c>).</summary>
|
||||
public const string MasterKeyFileEnvVar = "LIBATION_MASTER_KEY_FILE";
|
||||
|
||||
/// <summary>Env var: Base64-encoded 32-byte master key.</summary>
|
||||
public const string MasterKeyEnvVar = "LIBATION_MASTER_KEY";
|
||||
|
||||
/// <summary>Default master key file name under the Libation files directory.</summary>
|
||||
public const string DefaultMasterKeyFileName = "libation-master.key";
|
||||
|
||||
private static Lock Gate { get; } = new();
|
||||
private static Configuration? _wiredConfig;
|
||||
|
||||
/// <summary>
|
||||
/// Configure AudibleApi token persistence from <paramref name="config"/> and keep it in sync when the preference changes.
|
||||
/// </summary>
|
||||
public static void Apply(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
ConfigureFrom(config);
|
||||
|
||||
lock (Gate)
|
||||
{
|
||||
if (ReferenceEquals(_wiredConfig, config))
|
||||
return;
|
||||
|
||||
if (_wiredConfig is not null)
|
||||
_wiredConfig.PropertyChanged -= OnPropertyChanged;
|
||||
|
||||
_wiredConfig = config;
|
||||
_wiredConfig.PropertyChanged += OnPropertyChanged;
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Configure AudibleApi from the current preference without attaching change listeners.
|
||||
/// Prefer <see cref="Apply"/> at application startup.
|
||||
/// </summary>
|
||||
public static void ConfigureFrom(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var method = config.TokenStorageMethod;
|
||||
var store = ResolveSecretStore(config);
|
||||
AesGcmSecretProtector? protector = store.IsAvailable
|
||||
? new AesGcmSecretProtector(store)
|
||||
: null;
|
||||
|
||||
// Encrypted + unavailable store => protector null => fail-closed on encrypt/decrypt.
|
||||
IdentityTokenStorage.Configure(method, protector);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Resolve the secret store used for the AES-GCM master key.
|
||||
/// Supported priority: <see cref="MasterKeyFileEnvVar"/> -> existing default <see cref="DefaultMasterKeyFileName"/>
|
||||
/// under Libation files -> <see cref="MasterKeyEnvVar"/> -> OS-bound <see cref="OsSecretStore"/>.
|
||||
/// If the OS store is unavailable, falls through to
|
||||
/// <see cref="TryCreateLastResortPortableMasterKeyStore"/> (headless compatibility path; not the preferred setup).
|
||||
/// </summary>
|
||||
public static IOsSecretStore ResolveSecretStore(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var keyFileEnv = Environment.GetEnvironmentVariable(MasterKeyFileEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(keyFileEnv))
|
||||
return LoadMasterKeyFileOrUnavailable(keyFileEnv.Trim());
|
||||
|
||||
var defaultKeyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
|
||||
if (File.Exists(defaultKeyPath))
|
||||
return LoadMasterKeyFileOrUnavailable(defaultKeyPath);
|
||||
|
||||
var keyEnv = Environment.GetEnvironmentVariable(MasterKeyEnvVar);
|
||||
if (!string.IsNullOrWhiteSpace(keyEnv))
|
||||
return LoadMasterKeyBase64OrUnavailable(keyEnv.Trim());
|
||||
|
||||
var osStore = OsSecretStore.Create(ApplicationName);
|
||||
if (osStore.IsAvailable)
|
||||
return osStore;
|
||||
|
||||
return TryCreateLastResortPortableMasterKeyStore(config);
|
||||
}
|
||||
|
||||
/// <summary>True when the OS secret store can hold Libation's encryption master key.</summary>
|
||||
public static bool IsOsSecretStoreAvailable(out string? unavailableReason)
|
||||
{
|
||||
var store = OsSecretStore.Create(ApplicationName);
|
||||
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
|
||||
return store.IsAvailable;
|
||||
}
|
||||
|
||||
/// <summary>True when any resolved secret store (portable or OS) can supply an encryption master key.</summary>
|
||||
public static bool IsEncryptionKeyAvailable(Configuration config, out string? unavailableReason)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
var store = ResolveSecretStore(config);
|
||||
unavailableReason = store.IsAvailable ? null : store.UnavailableReason;
|
||||
return store.IsAvailable;
|
||||
}
|
||||
|
||||
/// <summary>Sidecar notice written beside an auto-minted last-resort master key.</summary>
|
||||
public const string LastResortMasterKeyNoticeFileName = "libation-master.key.NOTICE.txt";
|
||||
|
||||
/// <summary>
|
||||
/// Headless / Docker compatibility path when encryption is enabled but there is no OS secret store
|
||||
/// and the user did not supply a master key.
|
||||
/// Prefer setting <see cref="Configuration.TokenStorageMethod"/> to plaintext, or supplying an exported
|
||||
/// <see cref="DefaultMasterKeyFileName"/> / env key, instead of relying on this path.
|
||||
/// Creates <see cref="DefaultMasterKeyFileName"/> under Libation files if missing, then loads it.
|
||||
/// </summary>
|
||||
internal static IOsSecretStore TryCreateLastResortPortableMasterKeyStore(Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
var keyPath = Path.Combine(config.LibationFiles.Location, DefaultMasterKeyFileName);
|
||||
try
|
||||
{
|
||||
if (!File.Exists(keyPath))
|
||||
{
|
||||
MintRawMasterKeyFile(keyPath);
|
||||
AnnounceLastResortPortableMasterKey(keyPath);
|
||||
}
|
||||
|
||||
return LoadMasterKeyFileOrUnavailable(keyPath);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Last-resort portable master key",
|
||||
$"Could not create or load last-resort portable master key ({keyPath}): {SafeMessage(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Warn that a last-resort portable master key was auto-created. Not the recommended setup.
|
||||
/// </summary>
|
||||
internal static void AnnounceLastResortPortableMasterKey(string keyPath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(keyPath);
|
||||
|
||||
var fullKeyPath = Path.GetFullPath(keyPath);
|
||||
var noticePath = Path.Combine(
|
||||
Path.GetDirectoryName(fullKeyPath) ?? ".",
|
||||
LastResortMasterKeyNoticeFileName);
|
||||
|
||||
var message =
|
||||
"LAST-RESORT: Token encryption is enabled, but no OS secret store is available and no master key was supplied. "
|
||||
+ $"Libation created a portable master key at '{fullKeyPath}'. "
|
||||
+ "This is a compatibility fallback, not the recommended setup. "
|
||||
+ "Prefer: set TokenStorageMethod to Plaintext in Settings, "
|
||||
+ $"OR supply your own key (export from desktop and place '{DefaultMasterKeyFileName}' next to AccountsSettings.json, "
|
||||
+ $"or set {MasterKeyFileEnvVar} / {MasterKeyEnvVar}). "
|
||||
+ "Treat the key file like a password. It will not decrypt tokens encrypted under a different machine's key.";
|
||||
|
||||
try
|
||||
{
|
||||
AtomicFileWriter.WriteAllText(noticePath, message + Environment.NewLine);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Warning(ex, "Could not write last-resort master key notice file at {NoticePath}", noticePath);
|
||||
}
|
||||
|
||||
Serilog.Log.Logger.Warning(message);
|
||||
Console.Error.WriteLine(message);
|
||||
}
|
||||
|
||||
/// <summary>Write a new raw 32-byte master key file (same format as <c>export-master-key</c>).</summary>
|
||||
private static void MintRawMasterKeyFile(string keyPath)
|
||||
{
|
||||
var key = new byte[AesGcmSecretProtector.KeySizeBytes];
|
||||
RandomNumberGenerator.Fill(key);
|
||||
try
|
||||
{
|
||||
var directory = Path.GetDirectoryName(Path.GetFullPath(keyPath));
|
||||
if (!string.IsNullOrEmpty(directory))
|
||||
Directory.CreateDirectory(directory);
|
||||
|
||||
AtomicFileWriter.WriteAllBytes(keyPath, key);
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static IOsSecretStore LoadMasterKeyFileOrUnavailable(string path)
|
||||
{
|
||||
var store = new MemoryOsSecretStore();
|
||||
try
|
||||
{
|
||||
MasterKeyPortability.ImportFromFile(store, path);
|
||||
return store;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Portable master key file",
|
||||
$"Portable master key file is unusable ({path}): {SafeMessage(ex)}");
|
||||
}
|
||||
}
|
||||
|
||||
private static IOsSecretStore LoadMasterKeyBase64OrUnavailable(string base64)
|
||||
{
|
||||
byte[] key;
|
||||
try
|
||||
{
|
||||
key = Convert.FromBase64String(base64);
|
||||
}
|
||||
catch (FormatException ex)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Portable master key env",
|
||||
$"{MasterKeyEnvVar} is not valid Base64: {SafeMessage(ex)}");
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
if (key.Length != AesGcmSecretProtector.KeySizeBytes)
|
||||
{
|
||||
return new UnavailablePortableSecretStore(
|
||||
"Portable master key env",
|
||||
$"{MasterKeyEnvVar} must decode to {AesGcmSecretProtector.KeySizeBytes} bytes.");
|
||||
}
|
||||
|
||||
var store = new MemoryOsSecretStore();
|
||||
store.Set(AesGcmSecretProtector.DefaultMasterKeyName, key);
|
||||
return store;
|
||||
}
|
||||
finally
|
||||
{
|
||||
CryptographicOperations.ZeroMemory(key);
|
||||
}
|
||||
}
|
||||
|
||||
private static string SafeMessage(Exception ex)
|
||||
=> string.IsNullOrWhiteSpace(ex.Message) ? ex.GetType().Name : ex.Message;
|
||||
|
||||
private static void OnPropertyChanged(object? sender, PropertyChangedEventArgs e)
|
||||
{
|
||||
if (e.PropertyName != nameof(Configuration.TokenStorageMethod))
|
||||
return;
|
||||
if (sender is not Configuration config)
|
||||
return;
|
||||
|
||||
ConfigureFrom(config);
|
||||
}
|
||||
|
||||
private sealed class UnavailablePortableSecretStore : IOsSecretStore
|
||||
{
|
||||
public UnavailablePortableSecretStore(string name, string reason)
|
||||
{
|
||||
Name = name;
|
||||
UnavailableReason = reason;
|
||||
}
|
||||
|
||||
public string Name { get; }
|
||||
public bool IsAvailable => false;
|
||||
public string? UnavailableReason { get; }
|
||||
|
||||
public void Set(string key, ReadOnlySpan<byte> value)
|
||||
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
||||
|
||||
public bool TryGet(string key, out byte[] value)
|
||||
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
||||
|
||||
public void Delete(string key)
|
||||
=> throw new OsSecretStoreUnavailableException(Name, UnavailableReason!);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using Dinah.Core.Security;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Export Libation's OS-bound AES-GCM master key for portable use (e.g. Docker).
|
||||
/// Never creates a new key; the desktop OS store must already hold one.
|
||||
/// </summary>
|
||||
public static class MasterKeyExport
|
||||
{
|
||||
/// <summary>
|
||||
/// Write the existing Libation master key as raw bytes to <paramref name="filePath"/>.
|
||||
/// </summary>
|
||||
/// <exception cref="InvalidOperationException">OS secret store unavailable or master key missing.</exception>
|
||||
public static void ExportToFile(string filePath)
|
||||
{
|
||||
ArgumentException.ThrowIfNullOrWhiteSpace(filePath);
|
||||
|
||||
if (!IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason))
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot export the encryption master key because the OS secret store is unavailable."
|
||||
+ (string.IsNullOrWhiteSpace(unavailableReason) ? "" : " " + unavailableReason));
|
||||
}
|
||||
|
||||
var store = OsSecretStore.Create(IdentityTokenStorageWiring.ApplicationName);
|
||||
try
|
||||
{
|
||||
MasterKeyPortability.ExportToFile(store, filePath);
|
||||
}
|
||||
catch (SecretProtectionException ex)
|
||||
{
|
||||
throw new InvalidOperationException(
|
||||
"Cannot export the encryption master key because it was not found. "
|
||||
+ "Encrypt tokens on this machine at least once (Settings -> Important, store tokens encrypted) so a key exists, then try again.",
|
||||
ex);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,69 @@
|
||||
using AudibleApi;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
|
||||
namespace AudibleUtilities;
|
||||
|
||||
/// <summary>
|
||||
/// Libation-facing helpers when Audible returns HTML instead of JSON (library scan, catalog, etc.).
|
||||
/// </summary>
|
||||
public static class NonJsonResponseExceptionExtensions
|
||||
{
|
||||
public const string LibraryScanFailedCaption = "Library scan failed";
|
||||
|
||||
private static readonly string[] ThingsToTryBullets =
|
||||
[
|
||||
"Scan again after a few minutes.",
|
||||
"Sign in to Audible in a browser on the same network.",
|
||||
"Disable VPN or proxy and scan again.",
|
||||
"Remove and re-add the account in Libation.",
|
||||
"If it still fails, check the log for Full body: near the error and attach it to a bug report.",
|
||||
];
|
||||
|
||||
public static IEnumerable<string> GetExplainerLines(this NonJsonResponseException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
yield return getIntro(ex);
|
||||
yield return string.Empty;
|
||||
yield return "Things to try:";
|
||||
foreach (var bullet in ThingsToTryBullets)
|
||||
yield return "• " + bullet;
|
||||
}
|
||||
|
||||
public static string GetExplainerBody(this NonJsonResponseException ex)
|
||||
=> string.Join("\r\n", ex.GetExplainerLines());
|
||||
|
||||
public static bool TryFindInTree(Exception ex, out NonJsonResponseException? match)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
NonJsonResponseException? found = null;
|
||||
walk(ex);
|
||||
match = found;
|
||||
return found is not null;
|
||||
|
||||
void walk(Exception? e)
|
||||
{
|
||||
if (e is null || found is not null)
|
||||
return;
|
||||
|
||||
if (e is NonJsonResponseException nonJson)
|
||||
found = nonJson;
|
||||
|
||||
if (found is not null)
|
||||
return;
|
||||
|
||||
if (e is AggregateException agg)
|
||||
{
|
||||
foreach (var inner in agg.InnerExceptions)
|
||||
walk(inner);
|
||||
}
|
||||
|
||||
walk(e.InnerException);
|
||||
}
|
||||
}
|
||||
|
||||
private static string getIntro(NonJsonResponseException ex)
|
||||
=> ex.HtmlTitle is null
|
||||
? "Audible returned an HTML page instead of the expected library data."
|
||||
: $"Audible returned an HTML page ({ex.HtmlTitle}) instead of the expected library data.";
|
||||
}
|
||||
@@ -290,7 +290,7 @@ public partial class Cdm
|
||||
private static uint RandomUint()
|
||||
{
|
||||
var bts = new byte[4];
|
||||
new Random().NextBytes(bts);
|
||||
RandomNumberGenerator.Fill(bts);
|
||||
return BitConverter.ToUInt32(bts, 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -11,8 +11,8 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.1.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.2.1.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.7" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.7">
|
||||
|
||||
@@ -3,6 +3,7 @@ using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Diagnostics;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Infrastructure;
|
||||
using System;
|
||||
using System.Linq;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
@@ -49,7 +50,15 @@ public class LibationContextFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
context.Database.Migrate();
|
||||
// Migrate() acquires the EF migration lock before checking whether anything is pending.
|
||||
// For SQLite that lock is a row in __EFMigrationsLock with no timeout (SQLite has no
|
||||
// connection-scoped lock that frees on disconnect). A process killed after acquiring the
|
||||
// lock but before releasing it - even during an otherwise no-op Migrate() - orphans the row,
|
||||
// and every later Migrate() then spins forever in SqliteHistoryRepository.AcquireDatabaseLock().
|
||||
// The row is persisted in the db file, so restarting does not clear it. Skipping Migrate()
|
||||
// when nothing is pending keeps the steady-state path off the lock entirely. See #1729.
|
||||
if (context.Database.GetPendingMigrations().Any())
|
||||
context.Database.Migrate();
|
||||
}
|
||||
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
|
||||
@@ -63,7 +72,9 @@ public class LibationContextFactory
|
||||
{
|
||||
try
|
||||
{
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
// See ApplyMigrations for why pending migrations are checked before acquiring the lock (#1729).
|
||||
if ((await context.Database.GetPendingMigrationsAsync(cancellationToken)).Any())
|
||||
await context.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
// SQLITE_READONLY == 8 (https://www.sqlite.org/rescode.html)
|
||||
catch (SqliteException ex) when (ex.SqliteErrorCode == 8 && sqliteDatabaseFilePath is not null)
|
||||
|
||||
@@ -33,7 +33,7 @@ public static class LibraryBookQueries
|
||||
.AsEnumerable()
|
||||
.ToList();
|
||||
|
||||
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true)
|
||||
public LibraryBook? GetLibraryBook_Flat_NoTracking(string productId, bool caseSensative = true, string? account = null)
|
||||
{
|
||||
var libraryQuery
|
||||
= context
|
||||
@@ -41,8 +41,14 @@ public static class LibraryBookQueries
|
||||
.AsNoTrackingWithIdentityResolution()
|
||||
.GetLibrary();
|
||||
|
||||
return caseSensative ? libraryQuery.SingleOrDefault(lb => lb.Book.AudibleProductId == productId)
|
||||
: libraryQuery.SingleOrDefault(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
|
||||
var matches = caseSensative
|
||||
? libraryQuery.Where(lb => lb.Book.AudibleProductId == productId)
|
||||
: libraryQuery.Where(lb => EF.Functions.Collate(lb.Book.AudibleProductId, "NOCASE") == productId);
|
||||
|
||||
if (account is not null)
|
||||
matches = matches.Where(lb => lb.Account == account);
|
||||
|
||||
return matches.FirstOrDefault();
|
||||
}
|
||||
|
||||
public List<LibraryBook> GetUnLiberated_Flat_NoTracking()
|
||||
|
||||
@@ -26,7 +26,9 @@ public static class AudioFileStorageExt
|
||||
var series = libraryBook.Book.SeriesLink.SingleOrDefault();
|
||||
if (series is not null)
|
||||
{
|
||||
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(series.Series.AudibleSeriesId);
|
||||
LibraryBook? seriesParent = ApplicationServices.DbContexts.GetLibraryBook_Flat_NoTracking(
|
||||
series.Series.AudibleSeriesId,
|
||||
account: libraryBook.Account);
|
||||
if (seriesParent is not null)
|
||||
{
|
||||
return Templates.Folder.GetFilename(seriesParent.ToDto(), books, "");
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using LibationFileManager;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
/// <summary>
|
||||
/// Instantly fails processing so the bad-book error dialog can be tested without a real download failure.
|
||||
/// </summary>
|
||||
public class SimulateBadBookFailure : Processable, IProcessable<SimulateBadBookFailure>
|
||||
{
|
||||
public override string Name => "Simulate Bad Book Failure";
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook) => true;
|
||||
|
||||
public override Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
|
||||
var result = new StatusHandler();
|
||||
result.AddError("Simulated processing failure for testing the bad-book error dialog.");
|
||||
|
||||
OnCompleted(libraryBook);
|
||||
return Task.FromResult(result);
|
||||
}
|
||||
|
||||
public static SimulateBadBookFailure Create(Configuration config)
|
||||
=> new() { Configuration = config };
|
||||
}
|
||||
@@ -0,0 +1,128 @@
|
||||
using ApplicationServices;
|
||||
using DataLayer;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
public class UploadToAudiobookshelf : Processable, IProcessable<UploadToAudiobookshelf>
|
||||
{
|
||||
public override string Name => "Upload to Audiobookshelf";
|
||||
|
||||
public override bool Validate(LibraryBook libraryBook)
|
||||
{
|
||||
if (!Configuration.AudiobookshelfEnabled)
|
||||
return false;
|
||||
|
||||
if (string.IsNullOrWhiteSpace(Configuration.AudiobookshelfServerUrl)
|
||||
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfApiToken)
|
||||
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfLibraryId)
|
||||
|| string.IsNullOrWhiteSpace(Configuration.AudiobookshelfFolderId))
|
||||
return false;
|
||||
|
||||
return libraryBook.Book.AudioExists;
|
||||
}
|
||||
|
||||
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
|
||||
{
|
||||
OnBegin(libraryBook);
|
||||
try
|
||||
{
|
||||
var files = GetFilesToUpload(libraryBook);
|
||||
if (files.Count == 0)
|
||||
{
|
||||
OnStatusUpdate("No audio files found to upload");
|
||||
return new StatusHandler();
|
||||
}
|
||||
|
||||
OnStatusUpdate($"Uploading {files.Count} file(s) to Audiobookshelf...");
|
||||
|
||||
var title = libraryBook.Book.TitleWithSubtitle;
|
||||
var author = libraryBook.Book.AuthorNames;
|
||||
var series = libraryBook.Book.SeriesNames();
|
||||
|
||||
var result = await AudiobookshelfApiService.UploadBookAsync(
|
||||
Configuration.AudiobookshelfServerUrl!,
|
||||
Configuration.AudiobookshelfApiToken!,
|
||||
Configuration.AudiobookshelfLibraryId!,
|
||||
Configuration.AudiobookshelfFolderId!,
|
||||
title,
|
||||
author,
|
||||
series,
|
||||
files);
|
||||
|
||||
if (result == AudiobookshelfApiService.UploadResult.Success)
|
||||
{
|
||||
OnStatusUpdate("Upload to Audiobookshelf completed successfully");
|
||||
return new StatusHandler();
|
||||
}
|
||||
else if (result == AudiobookshelfApiService.UploadResult.AlreadyExists)
|
||||
{
|
||||
OnStatusUpdate("Book already exists on Audiobookshelf; skipping upload");
|
||||
Serilog.Log.Logger.Information("Book already exists on Audiobookshelf, skipping upload: {Book}", libraryBook.LogFriendly());
|
||||
return new StatusHandler();
|
||||
}
|
||||
else
|
||||
{
|
||||
OnStatusUpdate("Upload to Audiobookshelf failed; book remains liberated on disk");
|
||||
Serilog.Log.Logger.Error("Audiobookshelf upload failed for {Book}, but continuing as soft-failure", libraryBook.LogFriendly());
|
||||
// Soft-fail: log the error but do not mark the book as failed
|
||||
return new StatusHandler();
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error uploading {Book} to Audiobookshelf; continuing as soft-failure", libraryBook.LogFriendly());
|
||||
OnStatusUpdate($"Audiobookshelf upload error: {ex.Message}");
|
||||
// Soft-fail: log the error but do not mark the book as failed
|
||||
return new StatusHandler();
|
||||
}
|
||||
finally
|
||||
{
|
||||
OnCompleted(libraryBook);
|
||||
}
|
||||
}
|
||||
|
||||
private static List<string> GetFilesToUpload(LibraryBook libraryBook)
|
||||
{
|
||||
var files = new List<string>();
|
||||
var productId = libraryBook.Book.AudibleProductId;
|
||||
|
||||
// Get audio files from cache
|
||||
var audioFiles = FilePathCache.GetFiles(productId)
|
||||
.Where(f => f.fileType == FileType.Audio && File.Exists(f.path))
|
||||
.Select(f => (string)f.path)
|
||||
.Distinct()
|
||||
.ToList();
|
||||
|
||||
files.AddRange(audioFiles);
|
||||
|
||||
// Use Libation's known cover art output path (same logic as DownloadDecryptBook.DownloadCoverArt)
|
||||
if (audioFiles.FirstOrDefault() is { } firstAudioFile)
|
||||
{
|
||||
var dir = Path.GetDirectoryName(firstAudioFile);
|
||||
if (dir is not null)
|
||||
{
|
||||
var coverPath = AudibleFileStorage.Audio.GetCustomDirFilename(
|
||||
libraryBook,
|
||||
dir,
|
||||
".jpg",
|
||||
returnFirstExisting: false);
|
||||
|
||||
if (File.Exists(coverPath))
|
||||
files.Add(coverPath);
|
||||
}
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
public static UploadToAudiobookshelf Create(Configuration config) => new() { Configuration = config };
|
||||
private UploadToAudiobookshelf() { }
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
using AudibleApi;
|
||||
using LibationFileManager;
|
||||
using System;
|
||||
|
||||
namespace FileLiberator;
|
||||
|
||||
/// <summary>
|
||||
/// Detects a rare Audible API failure mode where ADRM licenserequest returns Sable error 000307
|
||||
/// with acr:null. Some titles (e.g. B08XDZCH78 in the US marketplace) only deliver via Widevine.
|
||||
/// Keep all detection here so this edge case does not spread through download or UI code.
|
||||
/// </summary>
|
||||
public static class WidevineRecommendation
|
||||
{
|
||||
/// <summary>
|
||||
/// Audible error code seen when Sable cannot resolve asset details for ADRM licenserequest.
|
||||
/// Not diagnostic on its own — also returned for unrelated API issues (e.g. empty response_groups).
|
||||
/// </summary>
|
||||
public const string SableAssetErrorCode = "000307";
|
||||
|
||||
/// <summary>
|
||||
/// Substring in the Sable error message when no ADRM content reference exists for the title.
|
||||
/// Together with <see cref="SableAssetErrorCode"/> on licenserequest, this is the best signal we have.
|
||||
/// </summary>
|
||||
public const string SableAcrNullMarker = "acr:null";
|
||||
|
||||
/// <summary>
|
||||
/// True when <paramref name="ex"/> matches the rare ADRM licenserequest / Sable acr:null pattern.
|
||||
/// </summary>
|
||||
public static bool IsAdrmLicenseUnavailableError(ApiErrorException ex)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
|
||||
if (ex.RequestUri is not { } requestUri
|
||||
|| !requestUri.Contains("/licenserequest", StringComparison.OrdinalIgnoreCase))
|
||||
return false;
|
||||
|
||||
if (ex.JsonMessage is not { } json)
|
||||
return false;
|
||||
|
||||
return json.Contains(SableAssetErrorCode, StringComparison.Ordinal)
|
||||
&& json.Contains(SableAcrNullMarker, StringComparison.Ordinal);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// True when Libation should suggest enabling Widevine for this failure.
|
||||
/// </summary>
|
||||
public static bool ShouldRecommendWidevine(ApiErrorException ex, Configuration config)
|
||||
{
|
||||
ArgumentNullException.ThrowIfNull(ex);
|
||||
ArgumentNullException.ThrowIfNull(config);
|
||||
|
||||
return !config.UseWidevine && IsAdrmLicenseUnavailableError(ex);
|
||||
}
|
||||
|
||||
/// <summary>One line for logs and CLI when <see cref="ShouldRecommendWidevine"/> applies.</summary>
|
||||
public static string BuildLogSummary(string bookTitleWithSubtitle)
|
||||
=> $"{bookTitleWithSubtitle}: Audible returned no ADRM license (Sable acr:null). "
|
||||
+ "Some rare titles only download with Widevine — try Settings > Audio File Options > Use Widevine DRM.";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net10.0</TargetFramework>
|
||||
@@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="10.2.1.1" />
|
||||
<PackageReference Include="Polly" Version="8.6.6" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
using Newtonsoft.Json.Linq;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
@@ -20,21 +19,66 @@ public interface IJsonBackedDictionary
|
||||
string? GetStringFromJsonPath(string jsonPath, string propertyName)
|
||||
=> GetStringFromJsonPath($"{jsonPath}.{propertyName}");
|
||||
|
||||
static T? UpCast<T>(object obj)
|
||||
static T? UpCast<T>(object obj, string? propertyName = null)
|
||||
{
|
||||
if (obj.GetType().IsAssignableTo(typeof(T))) return (T)obj;
|
||||
if (obj is JObject jObject) return jObject.ToObject<T>();
|
||||
if (obj is JValue jValue)
|
||||
{
|
||||
if (typeof(T).IsAssignableTo(typeof(Enum)))
|
||||
{
|
||||
return
|
||||
Enum.TryParse(typeof(T), jValue.Value<string>(), out var enumVal)
|
||||
? (T)enumVal
|
||||
: Enum.GetValues(typeof(T)).Cast<T>().First();
|
||||
}
|
||||
return ParseEnum<T>(jValue, propertyName ?? typeof(T).Name);
|
||||
|
||||
return jValue.Value<T>();
|
||||
}
|
||||
throw new InvalidCastException($"{obj.GetType()} is not convertible to {typeof(T)}");
|
||||
}
|
||||
|
||||
private static T ParseEnum<T>(JValue jValue, string propertyPath)
|
||||
{
|
||||
var enumType = typeof(T);
|
||||
|
||||
if (TryGetEnumFromNumber(jValue, enumType, propertyPath, out var fromNumber))
|
||||
return (T)fromNumber!;
|
||||
|
||||
var text = jValue.Type == JTokenType.String
|
||||
? jValue.Value<string>()
|
||||
: jValue.Value?.ToString();
|
||||
|
||||
if (text is not null
|
||||
&& Enum.TryParse(enumType, text, ignoreCase: true, out var parsed)
|
||||
&& parsed is not null
|
||||
&& Enum.IsDefined(enumType, parsed))
|
||||
{
|
||||
return (T)parsed;
|
||||
}
|
||||
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, text ?? jValue.ToString(), enumType);
|
||||
}
|
||||
|
||||
private static bool TryGetEnumFromNumber(JValue jValue, Type enumType, string propertyPath, out object? value)
|
||||
{
|
||||
value = null;
|
||||
if (jValue.Type is not (JTokenType.Integer or JTokenType.Float))
|
||||
return false;
|
||||
|
||||
object? raw = jValue.Value;
|
||||
if (raw is null)
|
||||
return false;
|
||||
|
||||
object converted;
|
||||
try
|
||||
{
|
||||
converted = Convert.ChangeType(raw, Enum.GetUnderlyingType(enumType));
|
||||
}
|
||||
catch (Exception ex) when (ex is InvalidCastException or FormatException or OverflowException)
|
||||
{
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
|
||||
}
|
||||
|
||||
if (!Enum.IsDefined(enumType, converted))
|
||||
throw InvalidConfigurationValueException.ForEnum(propertyPath, raw.ToString(), enumType);
|
||||
|
||||
value = Enum.ToObject(enumType, converted);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
using System;
|
||||
|
||||
namespace FileManager;
|
||||
|
||||
/// <summary>
|
||||
/// Settings.json (or other JSON-backed config) contained a value that cannot be mapped to the expected type.
|
||||
/// </summary>
|
||||
public sealed class InvalidConfigurationValueException : Exception
|
||||
{
|
||||
public string PropertyPath { get; }
|
||||
public string? InvalidValue { get; }
|
||||
public Type? ExpectedType { get; }
|
||||
|
||||
public InvalidConfigurationValueException(string propertyPath, string? invalidValue, Type? expectedType, string message)
|
||||
: base(message)
|
||||
{
|
||||
PropertyPath = propertyPath;
|
||||
InvalidValue = invalidValue;
|
||||
ExpectedType = expectedType;
|
||||
}
|
||||
|
||||
public static InvalidConfigurationValueException ForEnum(string propertyPath, string? invalidValue, Type enumType)
|
||||
{
|
||||
var allowed = string.Join(", ", Enum.GetNames(enumType));
|
||||
var display = FormatValue(invalidValue);
|
||||
var message =
|
||||
$"Invalid value for '{propertyPath}': {display}. " +
|
||||
$"Expected one of: {allowed}.";
|
||||
return new InvalidConfigurationValueException(propertyPath, invalidValue, enumType, message);
|
||||
}
|
||||
|
||||
public static InvalidConfigurationValueException ForPath(string propertyPath, string? invalidValue, string message)
|
||||
=> new(propertyPath, invalidValue, expectedType: null, message);
|
||||
|
||||
public static string FormatValue(string? value)
|
||||
=> value is null ? "[null]"
|
||||
: value.Length == 0 ? "[empty]"
|
||||
: $"\"{value}\"";
|
||||
}
|
||||
@@ -58,7 +58,7 @@ public class PersistentDictionary : IJsonBackedDictionary
|
||||
objectCache[propertyName] = defaultValue;
|
||||
return defaultValue;
|
||||
}
|
||||
return IJsonBackedDictionary.UpCast<T>(obj);
|
||||
return IJsonBackedDictionary.UpCast<T>(obj, propertyName);
|
||||
}
|
||||
|
||||
public object? GetObject(string propertyName)
|
||||
|
||||
@@ -73,7 +73,7 @@
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
|
||||
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.14" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.1" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
|
||||
@@ -0,0 +1,61 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Layout;
|
||||
using Avalonia.Media;
|
||||
using HangoverBase;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia;
|
||||
|
||||
internal static class HangoverMutationConfirm
|
||||
{
|
||||
public static async Task<bool> ConfirmAsync(Window owner, string actionDescription)
|
||||
{
|
||||
bool? result = null;
|
||||
|
||||
var yesButton = new Button { Content = "Yes", MinWidth = 75 };
|
||||
var noButton = new Button { Content = "No", MinWidth = 75 };
|
||||
|
||||
var buttonPanel = new StackPanel
|
||||
{
|
||||
Orientation = Orientation.Horizontal,
|
||||
HorizontalAlignment = HorizontalAlignment.Right,
|
||||
Spacing = 10,
|
||||
Margin = new Thickness(0, 15, 0, 0),
|
||||
Children = { noButton, yesButton },
|
||||
};
|
||||
|
||||
var dialog = new Window
|
||||
{
|
||||
Title = HangoverDbMutation.ConfirmTitle,
|
||||
Width = 420,
|
||||
SizeToContent = SizeToContent.Height,
|
||||
WindowStartupLocation = WindowStartupLocation.CenterOwner,
|
||||
CanResize = false,
|
||||
Content = new Grid
|
||||
{
|
||||
RowDefinitions = new RowDefinitions("*,Auto"),
|
||||
Margin = new Thickness(10),
|
||||
Children =
|
||||
{
|
||||
new TextBlock
|
||||
{
|
||||
[Grid.RowProperty] = 0,
|
||||
Text = HangoverDbMutation.BuildConfirmMessage(actionDescription),
|
||||
TextWrapping = TextWrapping.Wrap,
|
||||
},
|
||||
buttonPanel,
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
Grid.SetRow(buttonPanel, 1);
|
||||
|
||||
yesButton.Click += (_, _) => { result = true; dialog.Close(); };
|
||||
noButton.Click += (_, _) => { result = false; dialog.Close(); };
|
||||
dialog.Closed += (_, _) => result ??= false;
|
||||
|
||||
await dialog.ShowDialog(owner);
|
||||
return result == true;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,7 @@
|
||||
using HangoverBase;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
@@ -10,26 +12,78 @@ public partial class MainVM
|
||||
private string _databaseFileText;
|
||||
private bool _databaseFound;
|
||||
private string _sqlResults;
|
||||
private string _duplicateResults;
|
||||
private string _duplicateAsinStatusText;
|
||||
private bool _canRemoveDuplicateAsins;
|
||||
private bool _confirmRemoveDuplicateAsins;
|
||||
|
||||
public string DatabaseFileText { get => _databaseFileText; set => this.RaiseAndSetIfChanged(ref _databaseFileText, value); }
|
||||
public string SqlQuery { get; set; }
|
||||
public bool DatabaseFound { get => _databaseFound; set => this.RaiseAndSetIfChanged(ref _databaseFound, value); }
|
||||
public string SqlResults { get => _sqlResults; set => this.RaiseAndSetIfChanged(ref _sqlResults, value); }
|
||||
public string DuplicateResults { get => _duplicateResults; set => this.RaiseAndSetIfChanged(ref _duplicateResults, value); }
|
||||
public string DuplicateAsinStatusText { get => _duplicateAsinStatusText; set => this.RaiseAndSetIfChanged(ref _duplicateAsinStatusText, value); }
|
||||
public bool CanRemoveDuplicateAsins { get => _canRemoveDuplicateAsins; set => this.RaiseAndSetIfChanged(ref _canRemoveDuplicateAsins, value); }
|
||||
public bool ConfirmRemoveDuplicateAsins
|
||||
{
|
||||
get => _confirmRemoveDuplicateAsins;
|
||||
set => this.RaiseAndSetIfChanged(ref _confirmRemoveDuplicateAsins, value);
|
||||
}
|
||||
|
||||
private void Load_databaseVM()
|
||||
{
|
||||
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s));
|
||||
_tab = new(new DatabaseTabCommands(() => SqlQuery, s => SqlResults += s, s => SqlResults = s, s => DuplicateResults = s));
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
DatabaseFileText = $"Database file not found";
|
||||
DuplicateAsinStatusText = "Duplicate ASIN cleanup unavailable (database not found).";
|
||||
DatabaseFound = false;
|
||||
CanRemoveDuplicateAsins = false;
|
||||
return;
|
||||
}
|
||||
|
||||
DatabaseFileText = $"Database file: {_tab.DbFile}";
|
||||
DatabaseFound = true;
|
||||
RefreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
public void ExecuteQuery() => _tab.ExecuteQuery();
|
||||
public async Task ExecuteQueryAsync()
|
||||
{
|
||||
if (HangoverDbMutation.IsMutatingSql(SqlQuery)
|
||||
&& ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverDbMutation.SqlMutatingDescription))
|
||||
return;
|
||||
|
||||
_tab.ExecuteQuery();
|
||||
}
|
||||
|
||||
public void RefreshDuplicateAsinStatus()
|
||||
{
|
||||
DuplicateAsinStatusText = _tab.GetDuplicateAsinStatusText();
|
||||
CanRemoveDuplicateAsins = _tab.CanRemoveDuplicateAsins();
|
||||
if (!CanRemoveDuplicateAsins)
|
||||
ConfirmRemoveDuplicateAsins = false;
|
||||
}
|
||||
|
||||
public void ScanDuplicateAsins()
|
||||
{
|
||||
_tab.ScanDuplicateAsins();
|
||||
RefreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
public async Task RemoveDuplicateAsinsAsync()
|
||||
{
|
||||
if (!ConfirmRemoveDuplicateAsins)
|
||||
return;
|
||||
|
||||
if (ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverDbMutation.RemoveDuplicateAsinsDescription))
|
||||
return;
|
||||
|
||||
_tab.RemoveDuplicateAsins();
|
||||
ConfirmRemoveDuplicateAsins = false;
|
||||
RefreshDuplicateAsinStatus();
|
||||
}
|
||||
}
|
||||
@@ -1,8 +1,16 @@
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM
|
||||
{
|
||||
public TrashBinViewModel TrashBinViewModel { get; } = new();
|
||||
|
||||
private void Load_deletedVM() { }
|
||||
private void Load_deletedVM()
|
||||
{
|
||||
TrashBinViewModel.ConfirmDbMutationAsync = action
|
||||
=> ConfirmDbMutationAsync is { } confirm
|
||||
? confirm(action)
|
||||
: Task.FromResult(true);
|
||||
}
|
||||
}
|
||||
@@ -1,7 +1,12 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace HangoverAvalonia.ViewModels;
|
||||
|
||||
public partial class MainVM : ViewModelBase
|
||||
{
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public MainVM()
|
||||
{
|
||||
Load_databaseVM();
|
||||
|
||||
@@ -77,8 +77,17 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
item.IsChecked = false;
|
||||
}
|
||||
|
||||
public Func<string, Task<bool>>? ConfirmDbMutationAsync { get; set; }
|
||||
|
||||
public async Task RestoreCheckedAsync()
|
||||
{
|
||||
if (!CheckedBooks.Any())
|
||||
return;
|
||||
|
||||
if (ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
|
||||
return;
|
||||
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await CheckedBooks.RestoreBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
@@ -88,6 +97,13 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
|
||||
|
||||
public async Task PermanentlyDeleteCheckedAsync()
|
||||
{
|
||||
if (!CheckedBooks.Any())
|
||||
return;
|
||||
|
||||
if (ConfirmDbMutationAsync is not null
|
||||
&& !await ConfirmDbMutationAsync(HangoverBase.HangoverDbMutation.PermanentlyDeleteBooksDescription))
|
||||
return;
|
||||
|
||||
ControlsEnabled = false;
|
||||
var qtyChanges = await CheckedBooks.PermanentlyDeleteBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
namespace HangoverAvalonia.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void cliTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -2,14 +2,6 @@
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void databaseTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
}
|
||||
|
||||
public void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
_viewModel.ExecuteQuery();
|
||||
}
|
||||
public async void Execute_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.ExecuteQueryAsync();
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
namespace HangoverAvalonia.Views;
|
||||
|
||||
public partial class MainWindow
|
||||
{
|
||||
private void fixDuplicatesTab_VisibleChanged(bool isVisible)
|
||||
{
|
||||
if (!isVisible)
|
||||
return;
|
||||
|
||||
_viewModel.RefreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
public void ScanDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> _viewModel.ScanDuplicateAsins();
|
||||
|
||||
public async void RemoveDuplicateAsins_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
=> await _viewModel.RemoveDuplicateAsinsAsync();
|
||||
}
|
||||
@@ -1,140 +1,362 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
|
||||
xmlns:vm="using:HangoverAvalonia.ViewModels"
|
||||
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
|
||||
xmlns:controls="clr-namespace:HangoverAvalonia.Controls"
|
||||
|
||||
xmlns:vms="clr-namespace:HangoverAvalonia.ViewModels"
|
||||
|
||||
x:DataType="vms:MainVM"
|
||||
|
||||
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="500"
|
||||
|
||||
Width="800" Height="500"
|
||||
|
||||
x:Class="HangoverAvalonia.Views.MainWindow"
|
||||
|
||||
Icon="/Assets/hangover.ico "
|
||||
|
||||
Title="Hangover: Libation debug and recovery tool">
|
||||
|
||||
<Design.DataContext>
|
||||
|
||||
<vm:MainVM/>
|
||||
|
||||
</Design.DataContext>
|
||||
|
||||
|
||||
|
||||
<TabControl Name="tabControl1" Grid.Row="0">
|
||||
|
||||
<TabControl.Styles>
|
||||
|
||||
<Style Selector="TabControl /template/ ItemsPresenter#PART_ItemsPresenter">
|
||||
|
||||
<Setter Property="Height" Value="33"/>
|
||||
|
||||
</Style>
|
||||
|
||||
<Style Selector="TabItem /template/ Border#PART_LayoutRoot">
|
||||
|
||||
<Setter Property="Height" Value="33"/>
|
||||
|
||||
</Style>
|
||||
|
||||
<Style Selector="TabItem#Header TextBlock">
|
||||
|
||||
<Setter Property="MinHeight" Value="5"/>
|
||||
|
||||
</Style>
|
||||
|
||||
<Style Selector="Button">
|
||||
|
||||
<Setter Property="Padding" Value="20,5,20,5"/>
|
||||
|
||||
</Style>
|
||||
|
||||
</TabControl.Styles>
|
||||
|
||||
|
||||
|
||||
<!-- Database Tab -->
|
||||
|
||||
<TabItem Name="databaseTab">
|
||||
|
||||
<TabItem.Header>
|
||||
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Database</TextBlock>
|
||||
|
||||
</TabItem.Header>
|
||||
|
||||
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*,Auto,2*">
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Margin="0,10,0,5"
|
||||
|
||||
Grid.Row="0"
|
||||
|
||||
Text="{Binding DatabaseFileText}" />
|
||||
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Margin="0,5,0,5"
|
||||
|
||||
Grid.Row="1"
|
||||
|
||||
Text="SQL (database command)" />
|
||||
|
||||
|
||||
|
||||
<TextBox
|
||||
|
||||
Margin="0,5,0,5"
|
||||
|
||||
AcceptsReturn="True"
|
||||
|
||||
Grid.Row="2" Text="{Binding SqlQuery, Mode=OneWayToSource}" />
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
Grid.Row="3"
|
||||
|
||||
Content="Execute"
|
||||
|
||||
IsEnabled="{Binding DatabaseFound}"
|
||||
|
||||
Click="Execute_Click" />
|
||||
|
||||
|
||||
|
||||
<TextBox
|
||||
|
||||
Margin="0,5,0,10"
|
||||
|
||||
IsReadOnly="True"
|
||||
|
||||
Grid.Row="4"
|
||||
|
||||
Text="{Binding SqlResults}" />
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
</TabItem>
|
||||
|
||||
|
||||
|
||||
<!-- Deleted Books Tab -->
|
||||
|
||||
<TabItem Name="deletedTab">
|
||||
|
||||
<TabItem.Header>
|
||||
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Deleted Books</TextBlock>
|
||||
|
||||
</TabItem.Header>
|
||||
|
||||
<Grid
|
||||
|
||||
DataContext="{Binding TrashBinViewModel}"
|
||||
|
||||
RowDefinitions="Auto,*,Auto">
|
||||
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Grid.Row="0"
|
||||
|
||||
Margin="5"
|
||||
|
||||
Text="Check books you want to permanently delete from or restore to Libation" />
|
||||
|
||||
|
||||
|
||||
<controls:CheckedListBox
|
||||
|
||||
Grid.Row="1"
|
||||
|
||||
Margin="5,0,5,0"
|
||||
|
||||
BorderThickness="1"
|
||||
|
||||
BorderBrush="Gray"
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
Items="{Binding DeletedBooks}" />
|
||||
|
||||
|
||||
|
||||
<Grid
|
||||
|
||||
Grid.Row="2"
|
||||
|
||||
Margin="5"
|
||||
|
||||
ColumnDefinitions="Auto,Auto,*,Auto">
|
||||
|
||||
|
||||
|
||||
<CheckBox
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
IsThreeState="True"
|
||||
|
||||
Margin="0,0,20,0"
|
||||
|
||||
IsChecked="{Binding EverythingChecked}"
|
||||
|
||||
Content="Everything" />
|
||||
|
||||
|
||||
|
||||
<TextBlock
|
||||
|
||||
Grid.Column="1"
|
||||
|
||||
VerticalAlignment="Center"
|
||||
|
||||
Text="{Binding CheckedCountText}" />
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
Grid.Column="2"
|
||||
|
||||
Margin="0,0,20,0"
|
||||
|
||||
HorizontalAlignment="Right"
|
||||
|
||||
VerticalAlignment="Stretch"
|
||||
|
||||
VerticalContentAlignment="Center"
|
||||
|
||||
Content="Restore"
|
||||
|
||||
Command="{Binding RestoreCheckedAsync}"/>
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
IsEnabled="{Binding ControlsEnabled}"
|
||||
|
||||
Grid.Column="3"
|
||||
|
||||
Command="{Binding PermanentlyDeleteCheckedAsync}" >
|
||||
|
||||
<TextBlock
|
||||
|
||||
TextAlignment="Center"
|
||||
|
||||
Text="Permanently Delete
from Libation" />
|
||||
|
||||
</Button>
|
||||
|
||||
</Grid>
|
||||
|
||||
</Grid>
|
||||
|
||||
</TabItem>
|
||||
|
||||
<!-- Command Line Interface Tab -->
|
||||
<TabItem Name="cliTab">
|
||||
|
||||
|
||||
<!-- Fix Duplicates Tab -->
|
||||
|
||||
<TabItem Name="fixDuplicatesTab">
|
||||
|
||||
<TabItem.Header>
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Command Line Interface</TextBlock>
|
||||
</TabItem.Header>
|
||||
|
||||
<TextBlock FontSize="14" VerticalAlignment="Center">Fix Duplicates</TextBlock>
|
||||
|
||||
</TabItem.Header>
|
||||
|
||||
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,*">
|
||||
|
||||
<TextBlock
|
||||
|
||||
Margin="0,10,0,10"
|
||||
|
||||
Grid.Row="0"
|
||||
|
||||
Text="{Binding DuplicateAsinStatusText}"
|
||||
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
|
||||
|
||||
<Grid
|
||||
|
||||
Grid.Row="1"
|
||||
|
||||
ColumnDefinitions="Auto,Auto,*">
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
Grid.Column="0"
|
||||
|
||||
Margin="0,0,10,0"
|
||||
|
||||
Content="Scan Duplicate ASINs"
|
||||
|
||||
IsEnabled="{Binding DatabaseFound}"
|
||||
|
||||
Click="ScanDuplicateAsins_Click" />
|
||||
|
||||
|
||||
|
||||
<CheckBox
|
||||
|
||||
Grid.Column="1"
|
||||
|
||||
Margin="0,0,10,0"
|
||||
|
||||
VerticalAlignment="Center"
|
||||
|
||||
IsEnabled="{Binding CanRemoveDuplicateAsins}"
|
||||
|
||||
IsChecked="{Binding ConfirmRemoveDuplicateAsins}"
|
||||
|
||||
Content="Confirm removal" />
|
||||
|
||||
|
||||
|
||||
<Button
|
||||
|
||||
Grid.Column="2"
|
||||
|
||||
HorizontalAlignment="Left"
|
||||
|
||||
Content="Remove Duplicates"
|
||||
|
||||
IsEnabled="{Binding ConfirmRemoveDuplicateAsins}"
|
||||
|
||||
Click="RemoveDuplicateAsins_Click" />
|
||||
|
||||
</Grid>
|
||||
|
||||
|
||||
|
||||
<TextBox
|
||||
|
||||
Margin="0,10,0,10"
|
||||
|
||||
AcceptsReturn="True"
|
||||
|
||||
IsReadOnly="True"
|
||||
|
||||
Grid.Row="2"
|
||||
|
||||
Text="{Binding DuplicateResults}"
|
||||
|
||||
TextWrapping="Wrap" />
|
||||
|
||||
</Grid>
|
||||
|
||||
</TabItem>
|
||||
|
||||
</TabControl>
|
||||
|
||||
</Window>
|
||||
|
||||
@@ -18,8 +18,9 @@ public partial class MainWindow : Window
|
||||
|
||||
public void OnLoad()
|
||||
{
|
||||
databaseTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) databaseTab_VisibleChanged(databaseTab.IsSelected); };
|
||||
_viewModel.ConfirmDbMutationAsync = action => HangoverMutationConfirm.ConfirmAsync(this, action);
|
||||
|
||||
fixDuplicatesTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) fixDuplicatesTab_VisibleChanged(fixDuplicatesTab.IsSelected); };
|
||||
deletedTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) deletedTab_VisibleChanged(deletedTab.IsSelected); };
|
||||
cliTab.PropertyChanged += (_, e) => { if (e.Property.Name == nameof(TabItem.IsSelected)) cliTab_VisibleChanged(cliTab.IsSelected); };
|
||||
}
|
||||
}
|
||||
@@ -11,16 +11,19 @@ public class DatabaseTabCommands
|
||||
public Func<string> SqlInput { get; }
|
||||
public Action<string> SqlOutputAppend { get; }
|
||||
public Action<string> SqlOutputOverwrite { get; }
|
||||
public Action<string> DuplicateOutputOverwrite { get; }
|
||||
|
||||
public DatabaseTabCommands() { }
|
||||
public DatabaseTabCommands(
|
||||
Func<string> sqlInput,
|
||||
Action<string> sqlDisplayAppend,
|
||||
Action<string> sqlDisplayOverwrite)
|
||||
Action<string> sqlDisplayOverwrite,
|
||||
Action<string> duplicateOutputOverwrite)
|
||||
{
|
||||
SqlInput = ArgumentValidator.EnsureNotNull(sqlInput, nameof(sqlInput));
|
||||
SqlOutputAppend = ArgumentValidator.EnsureNotNull(sqlDisplayAppend, nameof(sqlDisplayAppend));
|
||||
SqlOutputOverwrite = ArgumentValidator.EnsureNotNull(sqlDisplayOverwrite, nameof(sqlDisplayOverwrite));
|
||||
DuplicateOutputOverwrite = ArgumentValidator.EnsureNotNull(duplicateOutputOverwrite, nameof(duplicateOutputOverwrite));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -36,6 +39,7 @@ public class DatabaseTab
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlInput));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputAppend));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.SqlOutputOverwrite));
|
||||
ArgumentValidator.EnsureNotNull(commands, nameof(_commands.DuplicateOutputOverwrite));
|
||||
}
|
||||
|
||||
public void LoadDatabaseFile() => DbFile = UNSAFE_MigrationHelper.DatabaseFile;
|
||||
@@ -151,4 +155,40 @@ public class DatabaseTab
|
||||
if (results != 1) _commands.SqlOutputAppend("s");
|
||||
_commands.SqlOutputAppend(" affected");
|
||||
}
|
||||
|
||||
public string GetDuplicateAsinStatusText()
|
||||
{
|
||||
if (DuplicateAsinCleanup.IsCompleted())
|
||||
return "Duplicate ASIN cleanup: completed (one-time migration already run).";
|
||||
|
||||
var scan = DuplicateAsinCleanup.Scan();
|
||||
return scan.DuplicateGroupCount == 0
|
||||
? "Duplicate ASIN cleanup: no duplicates found."
|
||||
: $"Duplicate ASIN cleanup: {scan.DuplicateGroupCount} duplicate ASIN group(s) found. Scan for details, then remove.";
|
||||
}
|
||||
|
||||
public bool CanRemoveDuplicateAsins()
|
||||
=> !DuplicateAsinCleanup.IsCompleted() && DuplicateAsinCleanup.Scan().DuplicateGroupCount > 0;
|
||||
|
||||
public void ScanDuplicateAsins()
|
||||
{
|
||||
var scan = DuplicateAsinCleanup.Scan();
|
||||
_commands.DuplicateOutputOverwrite(scan.Report);
|
||||
}
|
||||
|
||||
public string RemoveDuplicateAsins()
|
||||
{
|
||||
EnsureBackup();
|
||||
|
||||
try
|
||||
{
|
||||
var result = DuplicateAsinCleanup.Execute();
|
||||
_commands.DuplicateOutputOverwrite(result.Report);
|
||||
return result.Report;
|
||||
}
|
||||
finally
|
||||
{
|
||||
DeleteUnneededBackups();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
using ApplicationServices;
|
||||
using AppScaffolding;
|
||||
using DataLayer;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverBase;
|
||||
|
||||
/// <summary>
|
||||
/// One-time utility to find and remove duplicate <see cref="Book"/> + <see cref="LibraryBook"/> rows
|
||||
/// that share the same <see cref="Book.AudibleProductId"/>.
|
||||
/// </summary>
|
||||
public static class DuplicateAsinCleanup
|
||||
{
|
||||
public const string SettingsKey = "HangoverDuplicateAsinCleanupCompleted";
|
||||
|
||||
public static bool IsCompleted()
|
||||
=> UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out var value) && value == "true";
|
||||
|
||||
public static DuplicateAsinScanResult Scan()
|
||||
{
|
||||
using var context = DbContexts.GetContext();
|
||||
var groups = GetDuplicateGroups(context);
|
||||
return new DuplicateAsinScanResult(groups.Count, BuildReport(groups, previewOnly: true));
|
||||
}
|
||||
|
||||
public static DuplicateAsinCleanupResult Execute()
|
||||
{
|
||||
if (IsCompleted())
|
||||
return new DuplicateAsinCleanupResult(0, 0, "Duplicate ASIN cleanup has already been completed for this library.");
|
||||
|
||||
using var context = DbContexts.GetContext();
|
||||
var groups = GetDuplicateGroups(context);
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
MarkCompleted();
|
||||
return new DuplicateAsinCleanupResult(0, 0, "No duplicate ASIN rows found. Nothing to do.");
|
||||
}
|
||||
|
||||
var removedBooks = 0;
|
||||
var removedLibraryBooks = 0;
|
||||
|
||||
foreach (var group in groups)
|
||||
{
|
||||
var keeper = SelectKeeper(group);
|
||||
var duplicates = group.Where(lb => !ReferenceEquals(lb, keeper)).ToList();
|
||||
|
||||
foreach (var duplicate in duplicates)
|
||||
{
|
||||
MergeIntoKeeper(context, keeper, duplicate);
|
||||
|
||||
context.LibraryBooks.Remove(duplicate);
|
||||
context.Books.Remove(duplicate.Book);
|
||||
|
||||
removedLibraryBooks++;
|
||||
removedBooks++;
|
||||
}
|
||||
}
|
||||
|
||||
var changes = context.SaveChanges();
|
||||
MarkCompleted();
|
||||
|
||||
var summary = new StringBuilder();
|
||||
summary.AppendLine($"Removed {removedBooks} duplicate Book row(s) and {removedLibraryBooks} LibraryBook row(s).");
|
||||
summary.AppendLine($"SaveChanges reported {changes} database change(s).");
|
||||
summary.AppendLine();
|
||||
summary.Append(BuildReport(groups, previewOnly: false));
|
||||
|
||||
return new DuplicateAsinCleanupResult(groups.Count, removedBooks, summary.ToString());
|
||||
}
|
||||
|
||||
private static List<List<LibraryBook>> GetDuplicateGroups(LibationContext context)
|
||||
{
|
||||
var libraryBooks = context.LibraryBooks
|
||||
.Include(lb => lb.Book).ThenInclude(b => b.UserDefinedItem)
|
||||
.Include(lb => lb.Book).ThenInclude(b => b.SeriesLink).ThenInclude(sb => sb.Series)
|
||||
.ToList();
|
||||
|
||||
return libraryBooks
|
||||
.GroupBy(lb => lb.Book.AudibleProductId, StringComparer.OrdinalIgnoreCase)
|
||||
.Where(g => g.Count() > 1)
|
||||
.Select(g => g.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).ToList())
|
||||
.ToList();
|
||||
}
|
||||
|
||||
private static LibraryBook SelectKeeper(List<LibraryBook> group)
|
||||
=> group.OrderByDescending(Score).ThenBy(lb => lb.DateAdded).First();
|
||||
|
||||
private static int Score(LibraryBook libraryBook)
|
||||
{
|
||||
var book = libraryBook.Book;
|
||||
var udi = book.UserDefinedItem;
|
||||
|
||||
var score = 0;
|
||||
if (!libraryBook.IsDeleted)
|
||||
score += 1000;
|
||||
|
||||
score += bookStatusRank(udi.BookStatus) * 10;
|
||||
score += bookStatusRank(udi.PdfStatus ?? LiberatedStatus.NotLiberated);
|
||||
|
||||
score += book.ContentType switch
|
||||
{
|
||||
ContentType.Parent => 15,
|
||||
ContentType.Episode => 15,
|
||||
ContentType.Product => 10,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
if (book.SeriesLink.Any())
|
||||
score += 5;
|
||||
if (!string.IsNullOrWhiteSpace(book.PictureId))
|
||||
score += 2;
|
||||
|
||||
return score;
|
||||
}
|
||||
|
||||
private static int bookStatusRank(LiberatedStatus status) => status switch
|
||||
{
|
||||
LiberatedStatus.Liberated => 10,
|
||||
LiberatedStatus.PartialDownload => 8,
|
||||
LiberatedStatus.NotLiberated => 3,
|
||||
LiberatedStatus.Error => 1,
|
||||
_ => 0,
|
||||
};
|
||||
|
||||
private static void MergeIntoKeeper(LibationContext context, LibraryBook keeper, LibraryBook duplicate)
|
||||
{
|
||||
var keeperBook = keeper.Book;
|
||||
var duplicateBook = duplicate.Book;
|
||||
var keeperUdi = keeperBook.UserDefinedItem;
|
||||
var duplicateUdi = duplicateBook.UserDefinedItem;
|
||||
|
||||
if (bookStatusRank(duplicateUdi.BookStatus) > bookStatusRank(keeperUdi.BookStatus))
|
||||
keeperUdi.BookStatus = duplicateUdi.BookStatus;
|
||||
|
||||
if (duplicateUdi.PdfStatus is LiberatedStatus duplicatePdf
|
||||
&& bookStatusRank(duplicatePdf) > bookStatusRank(keeperUdi.PdfStatus ?? LiberatedStatus.NotLiberated))
|
||||
keeperUdi.SetPdfStatus(duplicatePdf);
|
||||
|
||||
if (!string.IsNullOrWhiteSpace(duplicateUdi.Tags))
|
||||
{
|
||||
var mergedTags = keeperUdi.TagsEnumerated
|
||||
.Concat(duplicateUdi.TagsEnumerated)
|
||||
.Distinct(StringComparer.OrdinalIgnoreCase);
|
||||
keeperUdi.Tags = string.Join(" ", mergedTags);
|
||||
}
|
||||
|
||||
if (duplicateUdi.LastDownloaded is DateTime duplicateLast
|
||||
&& (keeperUdi.LastDownloaded is null || duplicateLast > keeperUdi.LastDownloaded))
|
||||
keeperUdi.SetLastDownloaded(duplicateUdi.LastDownloadedVersion, duplicateUdi.LastDownloadedFormat, duplicateUdi.LastDownloadedFileVersion);
|
||||
|
||||
if (duplicateUdi.Rating.OverallRating > keeperUdi.Rating.OverallRating)
|
||||
keeperUdi.UpdateRating(duplicateUdi.Rating.OverallRating, duplicateUdi.Rating.PerformanceRating, duplicateUdi.Rating.StoryRating);
|
||||
|
||||
foreach (var seriesBook in duplicateBook.SeriesLink)
|
||||
{
|
||||
if (keeperBook.SeriesLink.Any(sb => sb.Series.AudibleSeriesId == seriesBook.Series.AudibleSeriesId))
|
||||
continue;
|
||||
keeperBook.UpsertSeries(seriesBook.Series, seriesBook.Order, context);
|
||||
}
|
||||
}
|
||||
|
||||
private static string BuildReport(List<List<LibraryBook>> groups, bool previewOnly)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
|
||||
if (groups.Count == 0)
|
||||
{
|
||||
builder.Append("No duplicate ASIN rows found.");
|
||||
return builder.ToString();
|
||||
}
|
||||
|
||||
var duplicateRowCount = groups.Sum(g => g.Count - 1);
|
||||
builder.Append(previewOnly
|
||||
? $"Found {groups.Count} duplicate ASIN group(s) ({duplicateRowCount} extra row(s) would be removed)."
|
||||
: $"Processed {groups.Count} duplicate ASIN group(s).");
|
||||
builder.AppendLine();
|
||||
builder.AppendLine();
|
||||
|
||||
foreach (var group in groups.OrderBy(g => g[0].Book.AudibleProductId, StringComparer.OrdinalIgnoreCase))
|
||||
{
|
||||
var keeper = SelectKeeper(group);
|
||||
var asin = keeper.Book.AudibleProductId;
|
||||
var title = keeper.Book.TitleWithSubtitle;
|
||||
var removing = group.Where(lb => !ReferenceEquals(lb, keeper))
|
||||
.Select(lb => $"{lb.Book.AudibleProductId} ({lb.DateAdded:d}, {lb.Account})")
|
||||
.ToList();
|
||||
|
||||
builder.Append(asin);
|
||||
builder.Append(" | ");
|
||||
builder.Append(title);
|
||||
builder.AppendLine();
|
||||
builder.Append(" Keep ");
|
||||
builder.Append($"{keeper.DateAdded:d}, {keeper.Account}");
|
||||
builder.Append(previewOnly ? "; would remove " : "; removed ");
|
||||
builder.AppendLine(string.Join("; ", removing));
|
||||
}
|
||||
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
|
||||
private static void MarkCompleted()
|
||||
{
|
||||
if (UNSAFE_MigrationHelper.Settings_TryGet(SettingsKey, out _))
|
||||
UNSAFE_MigrationHelper.Settings_Update(SettingsKey, "true");
|
||||
else
|
||||
UNSAFE_MigrationHelper.Settings_Insert(SettingsKey, "true");
|
||||
}
|
||||
}
|
||||
|
||||
public readonly record struct DuplicateAsinScanResult(int DuplicateGroupCount, string Report);
|
||||
|
||||
public readonly record struct DuplicateAsinCleanupResult(int DuplicateGroupCount, int RemovedBookCount, string Report);
|
||||
@@ -0,0 +1,82 @@
|
||||
using System.Diagnostics;
|
||||
using System.Text;
|
||||
|
||||
namespace HangoverBase;
|
||||
|
||||
public static class HangoverDbMutation
|
||||
{
|
||||
public const string ConfirmTitle = "Modify Database";
|
||||
|
||||
public static string RemoveDuplicateAsinsDescription { get; }
|
||||
= "This one-time cleanup will back up the database, merge useful data into the kept row, and permanently delete duplicate Book + LibraryBook pairs.";
|
||||
|
||||
public static string SqlMutatingDescription { get; }
|
||||
= "This will run a SQL command that modifies the database.";
|
||||
|
||||
public static string RestoreDeletedBooksDescription { get; }
|
||||
= "This will restore the selected book(s) to your Libation library.";
|
||||
|
||||
public static string PermanentlyDeleteBooksDescription { get; }
|
||||
= "This will permanently delete the selected book(s) from Libation.";
|
||||
|
||||
public static bool IsLibationRunning()
|
||||
{
|
||||
try
|
||||
{
|
||||
var currentPid = Environment.ProcessId;
|
||||
foreach (var process in Process.GetProcessesByName("Libation"))
|
||||
{
|
||||
try
|
||||
{
|
||||
if (process.Id != currentPid && !process.HasExited)
|
||||
return true;
|
||||
}
|
||||
finally
|
||||
{
|
||||
process.Dispose();
|
||||
}
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Process enumeration can fail on some platforms; treat as unknown.
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
public static bool IsMutatingSql(string sql)
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(sql))
|
||||
return false;
|
||||
|
||||
var trimmed = sql.Trim();
|
||||
while (trimmed.StartsWith("--", StringComparison.Ordinal))
|
||||
{
|
||||
var lineEnd = trimmed.IndexOf('\n');
|
||||
if (lineEnd < 0)
|
||||
return false;
|
||||
trimmed = trimmed[(lineEnd + 1)..].TrimStart();
|
||||
}
|
||||
|
||||
var lower = trimmed.ToLowerInvariant();
|
||||
return lower.StartsWith("update")
|
||||
|| lower.StartsWith("insert")
|
||||
|| lower.StartsWith("delete");
|
||||
}
|
||||
|
||||
public static string BuildConfirmMessage(string actionDescription)
|
||||
{
|
||||
var builder = new StringBuilder();
|
||||
builder.AppendLine(actionDescription);
|
||||
builder.AppendLine();
|
||||
|
||||
if (IsLibationRunning())
|
||||
builder.AppendLine("Libation is currently running.");
|
||||
|
||||
builder.AppendLine("Close Libation before continuing to avoid database conflicts and a stale library display.");
|
||||
builder.AppendLine();
|
||||
builder.Append("Proceed?");
|
||||
return builder.ToString().TrimEnd();
|
||||
}
|
||||
}
|
||||
@@ -1,15 +0,0 @@
|
||||
namespace HangoverWinForms;
|
||||
|
||||
public partial class Form1
|
||||
{
|
||||
private void Load_cliTab()
|
||||
{
|
||||
|
||||
}
|
||||
|
||||
private void cliTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!cliTab.Visible)
|
||||
return;
|
||||
}
|
||||
}
|
||||
@@ -1,30 +1,59 @@
|
||||
using HangoverBase;
|
||||
|
||||
|
||||
|
||||
namespace HangoverWinForms;
|
||||
|
||||
|
||||
|
||||
public partial class Form1
|
||||
|
||||
{
|
||||
|
||||
private DatabaseTab _tab;
|
||||
|
||||
|
||||
|
||||
private void Load_databaseTab()
|
||||
|
||||
{
|
||||
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s));
|
||||
|
||||
_tab = new(new(() => sqlTb.Text, sqlResultsTb.AppendText, s => sqlResultsTb.Text = s, s => duplicateResultsTb.Text = s));
|
||||
|
||||
|
||||
|
||||
_tab.LoadDatabaseFile();
|
||||
|
||||
if (_tab.DbFile is null)
|
||||
|
||||
{
|
||||
|
||||
databaseFileLbl.Text = $"Database file not found";
|
||||
|
||||
Load_fixDuplicatesTab();
|
||||
|
||||
return;
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
databaseFileLbl.Text = $"Database file: {_tab.DbFile}";
|
||||
|
||||
Load_fixDuplicatesTab();
|
||||
|
||||
}
|
||||
|
||||
private void databaseTab_VisibleChanged(object sender, EventArgs e)
|
||||
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (!databaseTab.Visible)
|
||||
if (HangoverBase.HangoverDbMutation.IsMutatingSql(sqlTb.Text)
|
||||
&& !HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.SqlMutatingDescription))
|
||||
return;
|
||||
|
||||
_tab.ExecuteQuery();
|
||||
}
|
||||
|
||||
private void sqlExecuteBtn_Click(object sender, EventArgs e) => _tab.ExecuteQuery();
|
||||
}
|
||||
|
||||
@@ -42,6 +42,12 @@ public partial class Form1
|
||||
private async void saveBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
var libraryBooksToRestore = deletedCbl.CheckedItems.Cast<LibraryBook>().ToList();
|
||||
if (libraryBooksToRestore.Count == 0)
|
||||
return;
|
||||
|
||||
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RestoreDeletedBooksDescription))
|
||||
return;
|
||||
|
||||
saveBtn.Enabled = false;
|
||||
var qtyChanges = await libraryBooksToRestore.RestoreBooksAsync();
|
||||
if (qtyChanges > 0)
|
||||
|
||||
+461
-89
@@ -1,256 +1,628 @@
|
||||
namespace HangoverWinForms
|
||||
|
||||
{
|
||||
|
||||
partial class Form1
|
||||
|
||||
{
|
||||
/// <summary>
|
||||
/// Required designer variable.
|
||||
/// </summary>
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
/// <summary>
|
||||
/// Clean up any resources being used.
|
||||
|
||||
/// Required designer variable.
|
||||
|
||||
/// </summary>
|
||||
|
||||
private System.ComponentModel.IContainer components = null;
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
||||
/// Clean up any resources being used.
|
||||
|
||||
/// </summary>
|
||||
|
||||
/// <param name="disposing">true if managed resources should be disposed; otherwise, false.</param>
|
||||
|
||||
protected override void Dispose(bool disposing)
|
||||
|
||||
{
|
||||
|
||||
if (disposing && (components != null))
|
||||
|
||||
{
|
||||
|
||||
components.Dispose();
|
||||
|
||||
}
|
||||
|
||||
base.Dispose(disposing);
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
#region Windows Form Designer generated code
|
||||
|
||||
|
||||
|
||||
/// <summary>
|
||||
|
||||
/// Required method for Designer support - do not modify
|
||||
|
||||
/// the contents of this method with the code editor.
|
||||
|
||||
/// </summary>
|
||||
|
||||
private void InitializeComponent()
|
||||
|
||||
{
|
||||
|
||||
System.ComponentModel.ComponentResourceManager resources = new System.ComponentModel.ComponentResourceManager(typeof(Form1));
|
||||
|
||||
this.tabControl1 = new System.Windows.Forms.TabControl();
|
||||
|
||||
this.databaseTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.sqlExecuteBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.sqlResultsTb = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.sqlTb = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.sqlLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.databaseFileLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.fixDuplicatesTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.removeDuplicateAsinsBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.scanDuplicateAsinsBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.duplicateAsinStatusLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.duplicateResultsTb = new System.Windows.Forms.TextBox();
|
||||
|
||||
this.deletedTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.deletedCheckedLbl = new System.Windows.Forms.Label();
|
||||
|
||||
this.label1 = new System.Windows.Forms.Label();
|
||||
|
||||
this.saveBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.uncheckAllBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.checkAllBtn = new System.Windows.Forms.Button();
|
||||
|
||||
this.deletedCbl = new System.Windows.Forms.CheckedListBox();
|
||||
this.cliTab = new System.Windows.Forms.TabPage();
|
||||
|
||||
this.tabControl1.SuspendLayout();
|
||||
|
||||
this.databaseTab.SuspendLayout();
|
||||
|
||||
this.fixDuplicatesTab.SuspendLayout();
|
||||
|
||||
this.deletedTab.SuspendLayout();
|
||||
|
||||
this.SuspendLayout();
|
||||
|
||||
//
|
||||
|
||||
// tabControl1
|
||||
|
||||
//
|
||||
|
||||
this.tabControl1.Controls.Add(this.databaseTab);
|
||||
|
||||
this.tabControl1.Controls.Add(this.deletedTab);
|
||||
this.tabControl1.Controls.Add(this.cliTab);
|
||||
|
||||
this.tabControl1.Controls.Add(this.fixDuplicatesTab);
|
||||
|
||||
this.tabControl1.Dock = System.Windows.Forms.DockStyle.Fill;
|
||||
|
||||
this.tabControl1.Location = new System.Drawing.Point(0, 0);
|
||||
|
||||
this.tabControl1.Name = "tabControl1";
|
||||
|
||||
this.tabControl1.SelectedIndex = 0;
|
||||
|
||||
this.tabControl1.Size = new System.Drawing.Size(800, 450);
|
||||
|
||||
this.tabControl1.TabIndex = 0;
|
||||
|
||||
//
|
||||
|
||||
// databaseTab
|
||||
|
||||
//
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlExecuteBtn);
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlResultsTb);
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlTb);
|
||||
|
||||
this.databaseTab.Controls.Add(this.sqlLbl);
|
||||
|
||||
this.databaseTab.Controls.Add(this.databaseFileLbl);
|
||||
|
||||
this.databaseTab.Location = new System.Drawing.Point(4, 24);
|
||||
|
||||
this.databaseTab.Name = "databaseTab";
|
||||
|
||||
this.databaseTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
|
||||
this.databaseTab.Size = new System.Drawing.Size(792, 422);
|
||||
|
||||
this.databaseTab.TabIndex = 0;
|
||||
|
||||
this.databaseTab.Text = "Database";
|
||||
|
||||
this.databaseTab.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
|
||||
// sqlExecuteBtn
|
||||
|
||||
//
|
||||
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 153);
|
||||
|
||||
this.sqlExecuteBtn.Location = new System.Drawing.Point(8, 165);
|
||||
|
||||
this.sqlExecuteBtn.Name = "sqlExecuteBtn";
|
||||
|
||||
this.sqlExecuteBtn.Size = new System.Drawing.Size(75, 23);
|
||||
|
||||
this.sqlExecuteBtn.TabIndex = 3;
|
||||
|
||||
this.sqlExecuteBtn.Text = "Execute";
|
||||
|
||||
this.sqlExecuteBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.sqlExecuteBtn.Click += new System.EventHandler(this.sqlExecuteBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// sqlResultsTb
|
||||
|
||||
//
|
||||
|
||||
this.sqlResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlResultsTb.Location = new System.Drawing.Point(8, 182);
|
||||
|
||||
this.sqlResultsTb.Location = new System.Drawing.Point(8, 194);
|
||||
|
||||
this.sqlResultsTb.Multiline = true;
|
||||
|
||||
this.sqlResultsTb.Name = "sqlResultsTb";
|
||||
|
||||
this.sqlResultsTb.ReadOnly = true;
|
||||
|
||||
this.sqlResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlResultsTb.Size = new System.Drawing.Size(776, 234);
|
||||
|
||||
this.sqlResultsTb.Size = new System.Drawing.Size(776, 222);
|
||||
|
||||
this.sqlResultsTb.TabIndex = 4;
|
||||
|
||||
//
|
||||
|
||||
// sqlTb
|
||||
|
||||
//
|
||||
|
||||
this.sqlTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.sqlTb.Location = new System.Drawing.Point(8, 48);
|
||||
|
||||
this.sqlTb.Location = new System.Drawing.Point(8, 40);
|
||||
|
||||
this.sqlTb.Multiline = true;
|
||||
|
||||
this.sqlTb.Name = "sqlTb";
|
||||
|
||||
this.sqlTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
this.sqlTb.Size = new System.Drawing.Size(778, 99);
|
||||
|
||||
this.sqlTb.Size = new System.Drawing.Size(778, 119);
|
||||
|
||||
this.sqlTb.TabIndex = 2;
|
||||
|
||||
//
|
||||
|
||||
// sqlLbl
|
||||
|
||||
//
|
||||
|
||||
this.sqlLbl.AutoSize = true;
|
||||
this.sqlLbl.Location = new System.Drawing.Point(6, 30);
|
||||
|
||||
this.sqlLbl.Location = new System.Drawing.Point(6, 22);
|
||||
|
||||
this.sqlLbl.Name = "sqlLbl";
|
||||
|
||||
this.sqlLbl.Size = new System.Drawing.Size(144, 15);
|
||||
|
||||
this.sqlLbl.TabIndex = 1;
|
||||
|
||||
this.sqlLbl.Text = "SQL (database command)";
|
||||
|
||||
//
|
||||
|
||||
// databaseFileLbl
|
||||
|
||||
//
|
||||
|
||||
this.databaseFileLbl.AutoSize = true;
|
||||
|
||||
this.databaseFileLbl.Location = new System.Drawing.Point(6, 3);
|
||||
|
||||
this.databaseFileLbl.Name = "databaseFileLbl";
|
||||
|
||||
this.databaseFileLbl.Size = new System.Drawing.Size(80, 15);
|
||||
|
||||
this.databaseFileLbl.TabIndex = 0;
|
||||
|
||||
this.databaseFileLbl.Text = "Database file: ";
|
||||
|
||||
//
|
||||
// deletedTab
|
||||
|
||||
// fixDuplicatesTab
|
||||
|
||||
//
|
||||
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
|
||||
this.deletedTab.Controls.Add(this.label1);
|
||||
this.deletedTab.Controls.Add(this.saveBtn);
|
||||
this.deletedTab.Controls.Add(this.uncheckAllBtn);
|
||||
this.deletedTab.Controls.Add(this.checkAllBtn);
|
||||
this.deletedTab.Controls.Add(this.deletedCbl);
|
||||
this.deletedTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.deletedTab.Name = "deletedTab";
|
||||
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
this.deletedTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.deletedTab.TabIndex = 2;
|
||||
this.deletedTab.Text = "Deleted Books";
|
||||
this.deletedTab.UseVisualStyleBackColor = true;
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.duplicateResultsTb);
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.removeDuplicateAsinsBtn);
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.scanDuplicateAsinsBtn);
|
||||
|
||||
this.fixDuplicatesTab.Controls.Add(this.duplicateAsinStatusLbl);
|
||||
|
||||
this.fixDuplicatesTab.Location = new System.Drawing.Point(4, 24);
|
||||
|
||||
this.fixDuplicatesTab.Name = "fixDuplicatesTab";
|
||||
|
||||
this.fixDuplicatesTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
|
||||
this.fixDuplicatesTab.Size = new System.Drawing.Size(792, 422);
|
||||
|
||||
this.fixDuplicatesTab.TabIndex = 2;
|
||||
|
||||
this.fixDuplicatesTab.Text = "Fix Duplicates";
|
||||
|
||||
this.fixDuplicatesTab.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
// deletedCheckedLbl
|
||||
|
||||
// removeDuplicateAsinsBtn
|
||||
|
||||
//
|
||||
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.deletedCheckedLbl.AutoSize = true;
|
||||
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
|
||||
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
|
||||
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
|
||||
this.deletedCheckedLbl.TabIndex = 6;
|
||||
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
|
||||
|
||||
this.removeDuplicateAsinsBtn.Location = new System.Drawing.Point(170, 48);
|
||||
|
||||
this.removeDuplicateAsinsBtn.Name = "removeDuplicateAsinsBtn";
|
||||
|
||||
this.removeDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
|
||||
|
||||
this.removeDuplicateAsinsBtn.TabIndex = 2;
|
||||
|
||||
this.removeDuplicateAsinsBtn.Text = "Remove Duplicates";
|
||||
|
||||
this.removeDuplicateAsinsBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.removeDuplicateAsinsBtn.Click += new System.EventHandler(this.removeDuplicateAsinsBtn_Click);
|
||||
|
||||
//
|
||||
// label1
|
||||
|
||||
// scanDuplicateAsinsBtn
|
||||
|
||||
//
|
||||
this.label1.AutoSize = true;
|
||||
this.label1.Location = new System.Drawing.Point(8, 3);
|
||||
this.label1.Name = "label1";
|
||||
this.label1.Size = new System.Drawing.Size(239, 15);
|
||||
this.label1.TabIndex = 0;
|
||||
this.label1.Text = "To restore deleted book, check box and save";
|
||||
|
||||
this.scanDuplicateAsinsBtn.Location = new System.Drawing.Point(8, 48);
|
||||
|
||||
this.scanDuplicateAsinsBtn.Name = "scanDuplicateAsinsBtn";
|
||||
|
||||
this.scanDuplicateAsinsBtn.Size = new System.Drawing.Size(156, 23);
|
||||
|
||||
this.scanDuplicateAsinsBtn.TabIndex = 1;
|
||||
|
||||
this.scanDuplicateAsinsBtn.Text = "Scan Duplicate ASINs";
|
||||
|
||||
this.scanDuplicateAsinsBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.scanDuplicateAsinsBtn.Click += new System.EventHandler(this.scanDuplicateAsinsBtn_Click);
|
||||
|
||||
//
|
||||
// saveBtn
|
||||
|
||||
// duplicateAsinStatusLbl
|
||||
|
||||
//
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
this.saveBtn.Location = new System.Drawing.Point(709, 391);
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
this.saveBtn.Size = new System.Drawing.Size(75, 23);
|
||||
this.saveBtn.TabIndex = 5;
|
||||
this.saveBtn.Text = "Save";
|
||||
this.saveBtn.UseVisualStyleBackColor = true;
|
||||
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
|
||||
//
|
||||
// uncheckAllBtn
|
||||
//
|
||||
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
|
||||
this.uncheckAllBtn.Name = "uncheckAllBtn";
|
||||
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
this.uncheckAllBtn.TabIndex = 4;
|
||||
this.uncheckAllBtn.Text = "Uncheck All";
|
||||
this.uncheckAllBtn.UseVisualStyleBackColor = true;
|
||||
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
|
||||
//
|
||||
// checkAllBtn
|
||||
//
|
||||
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
|
||||
this.checkAllBtn.Name = "checkAllBtn";
|
||||
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
this.checkAllBtn.TabIndex = 3;
|
||||
this.checkAllBtn.Text = "Check All";
|
||||
this.checkAllBtn.UseVisualStyleBackColor = true;
|
||||
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
|
||||
//
|
||||
// deletedCbl
|
||||
//
|
||||
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
this.duplicateAsinStatusLbl.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.duplicateAsinStatusLbl.Location = new System.Drawing.Point(6, 18);
|
||||
|
||||
this.duplicateAsinStatusLbl.Name = "duplicateAsinStatusLbl";
|
||||
|
||||
this.duplicateAsinStatusLbl.Size = new System.Drawing.Size(778, 27);
|
||||
|
||||
this.duplicateAsinStatusLbl.TabIndex = 0;
|
||||
|
||||
this.duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup";
|
||||
|
||||
//
|
||||
|
||||
// duplicateResultsTb
|
||||
|
||||
//
|
||||
|
||||
this.duplicateResultsTb.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.duplicateResultsTb.Location = new System.Drawing.Point(8, 77);
|
||||
|
||||
this.duplicateResultsTb.Multiline = true;
|
||||
|
||||
this.duplicateResultsTb.Name = "duplicateResultsTb";
|
||||
|
||||
this.duplicateResultsTb.ReadOnly = true;
|
||||
|
||||
this.duplicateResultsTb.ScrollBars = System.Windows.Forms.ScrollBars.Both;
|
||||
|
||||
this.duplicateResultsTb.Size = new System.Drawing.Size(776, 339);
|
||||
|
||||
this.duplicateResultsTb.TabIndex = 3;
|
||||
|
||||
//
|
||||
|
||||
// deletedTab
|
||||
|
||||
//
|
||||
|
||||
this.deletedTab.Controls.Add(this.deletedCheckedLbl);
|
||||
|
||||
this.deletedTab.Controls.Add(this.label1);
|
||||
|
||||
this.deletedTab.Controls.Add(this.saveBtn);
|
||||
|
||||
this.deletedTab.Controls.Add(this.uncheckAllBtn);
|
||||
|
||||
this.deletedTab.Controls.Add(this.checkAllBtn);
|
||||
|
||||
this.deletedTab.Controls.Add(this.deletedCbl);
|
||||
|
||||
this.deletedTab.Location = new System.Drawing.Point(4, 24);
|
||||
|
||||
this.deletedTab.Name = "deletedTab";
|
||||
|
||||
this.deletedTab.Padding = new System.Windows.Forms.Padding(3);
|
||||
|
||||
this.deletedTab.Size = new System.Drawing.Size(792, 422);
|
||||
|
||||
this.deletedTab.TabIndex = 1;
|
||||
|
||||
this.deletedTab.Text = "Deleted Books";
|
||||
|
||||
this.deletedTab.UseVisualStyleBackColor = true;
|
||||
|
||||
//
|
||||
|
||||
// deletedCheckedLbl
|
||||
|
||||
//
|
||||
|
||||
this.deletedCheckedLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
this.deletedCheckedLbl.AutoSize = true;
|
||||
|
||||
this.deletedCheckedLbl.Location = new System.Drawing.Point(233, 395);
|
||||
|
||||
this.deletedCheckedLbl.Name = "deletedCheckedLbl";
|
||||
|
||||
this.deletedCheckedLbl.Size = new System.Drawing.Size(104, 15);
|
||||
|
||||
this.deletedCheckedLbl.TabIndex = 6;
|
||||
|
||||
this.deletedCheckedLbl.Text = "Checked: {0} of {1}";
|
||||
|
||||
//
|
||||
|
||||
// label1
|
||||
|
||||
//
|
||||
|
||||
this.label1.AutoSize = true;
|
||||
|
||||
this.label1.Location = new System.Drawing.Point(8, 3);
|
||||
|
||||
this.label1.Name = "label1";
|
||||
|
||||
this.label1.Size = new System.Drawing.Size(239, 15);
|
||||
|
||||
this.label1.TabIndex = 0;
|
||||
|
||||
this.label1.Text = "To restore deleted book, check box and save";
|
||||
|
||||
//
|
||||
|
||||
// saveBtn
|
||||
|
||||
//
|
||||
|
||||
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.saveBtn.Location = new System.Drawing.Point(709, 391);
|
||||
|
||||
this.saveBtn.Name = "saveBtn";
|
||||
|
||||
this.saveBtn.Size = new System.Drawing.Size(75, 23);
|
||||
|
||||
this.saveBtn.TabIndex = 5;
|
||||
|
||||
this.saveBtn.Text = "Save";
|
||||
|
||||
this.saveBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// uncheckAllBtn
|
||||
|
||||
//
|
||||
|
||||
this.uncheckAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
this.uncheckAllBtn.Location = new System.Drawing.Point(129, 391);
|
||||
|
||||
this.uncheckAllBtn.Name = "uncheckAllBtn";
|
||||
|
||||
this.uncheckAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
|
||||
this.uncheckAllBtn.TabIndex = 4;
|
||||
|
||||
this.uncheckAllBtn.Text = "Uncheck All";
|
||||
|
||||
this.uncheckAllBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.uncheckAllBtn.Click += new System.EventHandler(this.uncheckAllBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// checkAllBtn
|
||||
|
||||
//
|
||||
|
||||
this.checkAllBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
|
||||
|
||||
this.checkAllBtn.Location = new System.Drawing.Point(8, 391);
|
||||
|
||||
this.checkAllBtn.Name = "checkAllBtn";
|
||||
|
||||
this.checkAllBtn.Size = new System.Drawing.Size(98, 23);
|
||||
|
||||
this.checkAllBtn.TabIndex = 3;
|
||||
|
||||
this.checkAllBtn.Text = "Check All";
|
||||
|
||||
this.checkAllBtn.UseVisualStyleBackColor = true;
|
||||
|
||||
this.checkAllBtn.Click += new System.EventHandler(this.checkAllBtn_Click);
|
||||
|
||||
//
|
||||
|
||||
// deletedCbl
|
||||
|
||||
//
|
||||
|
||||
this.deletedCbl.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Left)
|
||||
|
||||
| System.Windows.Forms.AnchorStyles.Right)));
|
||||
|
||||
this.deletedCbl.FormattingEnabled = true;
|
||||
|
||||
this.deletedCbl.Location = new System.Drawing.Point(8, 21);
|
||||
|
||||
this.deletedCbl.Name = "deletedCbl";
|
||||
|
||||
this.deletedCbl.Size = new System.Drawing.Size(776, 364);
|
||||
|
||||
this.deletedCbl.TabIndex = 2;
|
||||
|
||||
this.deletedCbl.ItemCheck += new System.Windows.Forms.ItemCheckEventHandler(this.deletedCbl_ItemCheck);
|
||||
|
||||
//
|
||||
// cliTab
|
||||
//
|
||||
this.cliTab.Location = new System.Drawing.Point(4, 24);
|
||||
this.cliTab.Name = "cliTab";
|
||||
this.cliTab.Size = new System.Drawing.Size(792, 422);
|
||||
this.cliTab.TabIndex = 1;
|
||||
this.cliTab.Text = "Command Line Interface";
|
||||
this.cliTab.UseVisualStyleBackColor = true;
|
||||
//
|
||||
|
||||
// Form1
|
||||
|
||||
//
|
||||
|
||||
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
|
||||
|
||||
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
|
||||
|
||||
this.ClientSize = new System.Drawing.Size(800, 450);
|
||||
|
||||
this.Controls.Add(this.tabControl1);
|
||||
|
||||
this.Icon = ((System.Drawing.Icon)(resources.GetObject("$this.Icon")));
|
||||
|
||||
this.Name = "Form1";
|
||||
|
||||
this.Text = "Hangover: Libation debug and recovery tool";
|
||||
|
||||
this.tabControl1.ResumeLayout(false);
|
||||
|
||||
this.databaseTab.ResumeLayout(false);
|
||||
|
||||
this.databaseTab.PerformLayout();
|
||||
|
||||
this.fixDuplicatesTab.ResumeLayout(false);
|
||||
|
||||
this.deletedTab.ResumeLayout(false);
|
||||
|
||||
this.deletedTab.PerformLayout();
|
||||
|
||||
this.ResumeLayout(false);
|
||||
|
||||
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
||||
#endregion
|
||||
|
||||
|
||||
|
||||
private TabControl tabControl1;
|
||||
|
||||
private TabPage databaseTab;
|
||||
|
||||
private Label databaseFileLbl;
|
||||
|
||||
private TextBox sqlResultsTb;
|
||||
|
||||
private TextBox sqlTb;
|
||||
|
||||
private Label sqlLbl;
|
||||
|
||||
private Button sqlExecuteBtn;
|
||||
private TabPage cliTab;
|
||||
|
||||
private TabPage fixDuplicatesTab;
|
||||
|
||||
private Button removeDuplicateAsinsBtn;
|
||||
|
||||
private Button scanDuplicateAsinsBtn;
|
||||
|
||||
private Label duplicateAsinStatusLbl;
|
||||
|
||||
private TextBox duplicateResultsTb;
|
||||
|
||||
private TabPage deletedTab;
|
||||
|
||||
private CheckedListBox deletedCbl;
|
||||
|
||||
private Label label1;
|
||||
|
||||
private Button saveBtn;
|
||||
|
||||
private Button uncheckAllBtn;
|
||||
|
||||
private Button checkAllBtn;
|
||||
|
||||
private Label deletedCheckedLbl;
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
namespace HangoverWinForms;
|
||||
|
||||
public partial class Form1
|
||||
{
|
||||
private void Load_fixDuplicatesTab()
|
||||
{
|
||||
if (_tab.DbFile is null)
|
||||
{
|
||||
duplicateAsinStatusLbl.Text = "Duplicate ASIN cleanup unavailable (database not found).";
|
||||
scanDuplicateAsinsBtn.Enabled = false;
|
||||
removeDuplicateAsinsBtn.Enabled = false;
|
||||
return;
|
||||
}
|
||||
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
private void refreshDuplicateAsinStatus()
|
||||
{
|
||||
duplicateAsinStatusLbl.Text = _tab.GetDuplicateAsinStatusText();
|
||||
scanDuplicateAsinsBtn.Enabled = true;
|
||||
removeDuplicateAsinsBtn.Enabled = _tab.CanRemoveDuplicateAsins();
|
||||
}
|
||||
|
||||
private void fixDuplicatesTab_VisibleChanged(object sender, EventArgs e)
|
||||
{
|
||||
if (!fixDuplicatesTab.Visible)
|
||||
return;
|
||||
|
||||
if (_tab.DbFile is not null)
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
private void scanDuplicateAsinsBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
_tab.ScanDuplicateAsins();
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
|
||||
private void removeDuplicateAsinsBtn_Click(object sender, EventArgs e)
|
||||
{
|
||||
if (!HangoverMutationConfirm.Confirm(this, HangoverBase.HangoverDbMutation.RemoveDuplicateAsinsDescription))
|
||||
return;
|
||||
|
||||
_tab.RemoveDuplicateAsins();
|
||||
refreshDuplicateAsinStatus();
|
||||
}
|
||||
}
|
||||
@@ -12,12 +12,10 @@ public partial class Form1 : Form
|
||||
LibationScaffolding.RunPostConfigMigrations(config);
|
||||
LibationScaffolding.RunPostMigrationScaffolding(Variety.Classic, config);
|
||||
|
||||
databaseTab.VisibleChanged += databaseTab_VisibleChanged;
|
||||
cliTab.VisibleChanged += cliTab_VisibleChanged;
|
||||
fixDuplicatesTab.VisibleChanged += fixDuplicatesTab_VisibleChanged;
|
||||
deletedTab.VisibleChanged += deletedTab_VisibleChanged;
|
||||
|
||||
Load_databaseTab();
|
||||
Load_cliTab();
|
||||
Load_deletedTab();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
using HangoverBase;
|
||||
|
||||
namespace HangoverWinForms;
|
||||
|
||||
internal static class HangoverMutationConfirm
|
||||
{
|
||||
public static bool Confirm(IWin32Window owner, string actionDescription)
|
||||
=> MessageBox.Show(
|
||||
owner,
|
||||
HangoverDbMutation.BuildConfirmMessage(actionDescription),
|
||||
HangoverDbMutation.ConfirmTitle,
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Warning) == DialogResult.Yes;
|
||||
}
|
||||
@@ -61,6 +61,7 @@
|
||||
<File Path="__README - COLLABORATORS.txt" />
|
||||
</Folder>
|
||||
<Folder Name="/_Tests/">
|
||||
<Project Path="_Tests/ApplicationServices.Tests/ApplicationServices.Tests.csproj" />
|
||||
<Project Path="_Tests/AssertionHelper/AssertionHelper.csproj" />
|
||||
<Project Path="_Tests/AudibleUtilities.Tests/AudibleUtilities.Tests.csproj" />
|
||||
<Project Path="_Tests/FileLiberator.Tests/FileLiberator.Tests.csproj" />
|
||||
|
||||
@@ -47,6 +47,12 @@ public class App : Application
|
||||
MessageBoxBase.ShowAsyncImpl = (owner, message, caption, buttons, icon, defaultButton, saveAndRestorePosition) =>
|
||||
MessageBox.Show(owner as Window, message, caption, buttons, icon, defaultButton, saveAndRestorePosition);
|
||||
|
||||
if (InstallUpgradeManager.TakeStartupRecoveryAlert() is { } recovery)
|
||||
_ = MessageBox.Show(null, recovery.Body, recovery.Title, MessageBoxButtons.OK, MessageBoxIcon.Warning);
|
||||
|
||||
BadBookActionDialogBase.ShowAsyncImpl = (owner, message, caption) =>
|
||||
Dialogs.BadBookActionDialog.ShowAsync(owner as Window, message, caption);
|
||||
|
||||
if (LibraryTask is null)
|
||||
{
|
||||
RunSetupIfNeededAsync(desktop, Configuration.Instance);
|
||||
@@ -148,13 +154,14 @@ public class App : Application
|
||||
List<DataLayer.LibraryBook> library = await LibraryTask;
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync(library));
|
||||
}
|
||||
catch (Exception ex) when (StartupAssemblyBootstrap.IsMissingDependencyAssembly(ex))
|
||||
catch (Exception ex) when (StartupAssemblyBootstrap.IsInstallFolderAssemblyLoadFailure(ex))
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to load library at startup");
|
||||
FatalStartupMessage failure = StartupAssemblyBootstrap.GetStartupFailureMessage(ex)!;
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
StartupAssemblyBootstrap.GetLibraryLoadFailureMessage(),
|
||||
"Library load failed",
|
||||
failure.Body,
|
||||
failure.Title,
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Error);
|
||||
await Dispatcher.UIThread.InvokeAsync(() => MainWindow.OnLibraryLoadedAsync([]));
|
||||
|
||||
@@ -0,0 +1,94 @@
|
||||
<UserControl xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
|
||||
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
|
||||
x:DataType="vm:AudiobookshelfSettingsVM"
|
||||
x:Class="LibationAvalonia.Controls.Settings.Audiobookshelf">
|
||||
|
||||
<StackPanel Margin="6" Spacing="0">
|
||||
<CheckBox IsChecked="{Binding Enabled, Mode=TwoWay}"
|
||||
Content="{Binding EnabledText}"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*,Auto" RowDefinitions="Auto,Auto,Auto,Auto,Auto,Auto,Auto,Auto">
|
||||
<!-- Server URL -->
|
||||
<TextBlock Grid.Row="0" Grid.Column="0"
|
||||
Text="{Binding ServerUrlText}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,6" />
|
||||
<TextBox Grid.Row="0" Grid.Column="1"
|
||||
Text="{Binding ServerUrl, Mode=TwoWay}"
|
||||
PlaceholderText="http://localhost:13378"
|
||||
IsEnabled="{Binding Enabled}"
|
||||
Margin="0,0,10,6" />
|
||||
|
||||
<!-- API Token -->
|
||||
<TextBlock Grid.Row="1" Grid.Column="0"
|
||||
Text="{Binding ApiTokenText}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,10" />
|
||||
<TextBox Grid.Row="1" Grid.Column="1"
|
||||
Text="{Binding ApiToken, Mode=TwoWay}"
|
||||
PasswordChar="*"
|
||||
PlaceholderText="Your ABS API token"
|
||||
IsEnabled="{Binding Enabled}"
|
||||
Margin="0,0,10,10" />
|
||||
|
||||
<!-- Connect Button -->
|
||||
<Button Grid.Row="0" Grid.Column="2"
|
||||
Grid.RowSpan="2"
|
||||
Content="{Binding ConnectButtonText}"
|
||||
Command="{Binding ConnectCommand}"
|
||||
IsEnabled="{Binding CanConnect}"
|
||||
HorizontalAlignment="Left"
|
||||
HorizontalContentAlignment="Center"
|
||||
MinWidth="150"
|
||||
MinHeight="34"
|
||||
VerticalAlignment="Stretch"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<!-- Plaintext warning -->
|
||||
<TextBlock Grid.Row="2" Grid.Column="1"
|
||||
Text="{Binding PlaintextWarningText}"
|
||||
Foreground="DarkOrange"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding PlaintextWarningVisible}"
|
||||
Margin="0,0,0,10" />
|
||||
|
||||
<!-- Status -->
|
||||
<ProgressBar Grid.Row="4" Grid.Column="1"
|
||||
IsIndeterminate="True"
|
||||
IsVisible="{Binding IsConnecting}"
|
||||
Margin="0,0,0,6" />
|
||||
<TextBlock Grid.Row="5" Grid.Column="1"
|
||||
Text="{Binding StatusText}"
|
||||
TextWrapping="Wrap"
|
||||
IsVisible="{Binding !IsConnecting}"
|
||||
Margin="0,0,0,8" />
|
||||
|
||||
<!-- Library -->
|
||||
<TextBlock Grid.Row="6" Grid.Column="0"
|
||||
Text="{Binding LibraryText}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,7" />
|
||||
<ComboBox Grid.Row="6" Grid.Column="1"
|
||||
ItemsSource="{Binding LibraryNames}"
|
||||
SelectedIndex="{Binding SelectedLibraryIndex, Mode=TwoWay}"
|
||||
IsEnabled="{Binding Enabled}"
|
||||
Margin="0,0,0,7" />
|
||||
|
||||
<!-- Folder -->
|
||||
<TextBlock Grid.Row="7" Grid.Column="0"
|
||||
Text="{Binding FolderText}"
|
||||
VerticalAlignment="Center"
|
||||
Margin="0,0,10,0" />
|
||||
<ComboBox Grid.Row="7" Grid.Column="1"
|
||||
ItemsSource="{Binding FolderNames}"
|
||||
SelectedIndex="{Binding SelectedFolderIndex, Mode=TwoWay}"
|
||||
IsEnabled="{Binding Enabled}"
|
||||
Margin="0,0,0,0" />
|
||||
</Grid>
|
||||
</StackPanel>
|
||||
</UserControl>
|
||||
@@ -0,0 +1,18 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
|
||||
namespace LibationAvalonia.Controls.Settings;
|
||||
|
||||
public partial class Audiobookshelf : UserControl
|
||||
{
|
||||
public Audiobookshelf()
|
||||
{
|
||||
InitializeComponent();
|
||||
|
||||
if (Design.IsDesignMode)
|
||||
{
|
||||
DataContext = new AudiobookshelfSettingsVM(Configuration.CreateMockInstance());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@
|
||||
x:DataType="vm:ImportantSettingsVM"
|
||||
x:Class="LibationAvalonia.Controls.Settings.Important">
|
||||
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
|
||||
<Grid RowDefinitions="Auto,Auto,Auto,Auto,Auto,*">
|
||||
<controls:GroupBox
|
||||
Grid.Row="0"
|
||||
Margin="5"
|
||||
@@ -85,8 +85,51 @@
|
||||
</StackPanel>
|
||||
</CheckBox>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="2"
|
||||
Margin="5"
|
||||
Label="Authentication tokens">
|
||||
<StackPanel>
|
||||
<CheckBox
|
||||
IsEnabled="{Binding CanEditEncryptTokens}"
|
||||
IsChecked="{Binding EncryptTokens, Mode=TwoWay}">
|
||||
<TextBlock Text="{Binding EncryptTokensText}" TextWrapping="Wrap" />
|
||||
</CheckBox>
|
||||
<TextBlock
|
||||
Margin="24,4,0,0"
|
||||
Foreground="DarkRed"
|
||||
IsVisible="{Binding PlaintextWarningVisible}"
|
||||
Text="{Binding PlaintextWarningText}"
|
||||
TextWrapping="Wrap" />
|
||||
<TextBlock
|
||||
Margin="24,4,0,0"
|
||||
Foreground="DarkRed"
|
||||
IsVisible="{Binding OsStoreUnavailableVisible}"
|
||||
Text="{Binding OsStoreUnavailableMessage}"
|
||||
TextWrapping="Wrap" />
|
||||
<StackPanel
|
||||
Margin="0,10,0,0"
|
||||
Orientation="Horizontal"
|
||||
HorizontalAlignment="Right"
|
||||
Spacing="10">
|
||||
<Button
|
||||
Padding="20,0"
|
||||
Content="{Binding ExportButtonText}"
|
||||
ToolTip.Tip="{Binding ExportButtonToolTip}"
|
||||
IsEnabled="{Binding ExportButtonEnabled}"
|
||||
Click="ExportMasterKey_Click" />
|
||||
<Button
|
||||
Padding="20,0"
|
||||
Content="{Binding ConvertButtonText}"
|
||||
ToolTip.Tip="{Binding ConvertButtonToolTip}"
|
||||
IsEnabled="{Binding ConvertButtonEnabled}"
|
||||
Command="{Binding ConvertExistingTokensCommand}" />
|
||||
</StackPanel>
|
||||
</StackPanel>
|
||||
</controls:GroupBox>
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="2" Margin="5"
|
||||
Grid.Row="3" Margin="5"
|
||||
Orientation="Horizontal">
|
||||
|
||||
<TextBlock
|
||||
@@ -111,7 +154,7 @@
|
||||
</StackPanel>
|
||||
|
||||
<controls:GroupBox
|
||||
Grid.Row="3"
|
||||
Grid.Row="4"
|
||||
Margin="5"
|
||||
Label="Display Settings">
|
||||
<Grid
|
||||
@@ -166,7 +209,7 @@
|
||||
</controls:GroupBox>
|
||||
|
||||
<Grid
|
||||
Grid.Row="4"
|
||||
Grid.Row="5"
|
||||
ColumnDefinitions="Auto,Auto,*"
|
||||
Margin="10"
|
||||
VerticalAlignment="Bottom">
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Platform.Storage;
|
||||
using AudibleUtilities;
|
||||
using LibationAvalonia.Dialogs;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using System.Linq;
|
||||
|
||||
namespace LibationAvalonia.Controls.Settings;
|
||||
@@ -21,6 +24,32 @@ public partial class Important : UserControl
|
||||
ThemeComboBox.SelectionChanged += ThemeComboBox_SelectionChanged;
|
||||
}
|
||||
|
||||
private async void ExportMasterKey_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
var owner = this.GetParentWindow();
|
||||
if (!await TokenStorageSettingsUi.ConfirmExportMasterKeyAsync(owner))
|
||||
return;
|
||||
|
||||
var options = new FilePickerSaveOptions
|
||||
{
|
||||
Title = TokenStorageSettingsUi.ExportConfirmCaption,
|
||||
SuggestedFileName = IdentityTokenStorageWiring.DefaultMasterKeyFileName,
|
||||
DefaultExtension = "key",
|
||||
ShowOverwritePrompt = true,
|
||||
FileTypeChoices =
|
||||
[
|
||||
new("Master key (*.key)") { Patterns = ["*.key"] },
|
||||
new("All files (*.*)") { Patterns = ["*"] }
|
||||
]
|
||||
};
|
||||
|
||||
var selectedFile = (await owner.StorageProvider.SaveFilePickerAsync(options))?.TryGetLocalPath();
|
||||
if (selectedFile is null)
|
||||
return;
|
||||
|
||||
await TokenStorageSettingsUi.ExportMasterKeyToFileAsync(owner, selectedFile);
|
||||
}
|
||||
|
||||
private void EditThemeColors_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
|
||||
{
|
||||
if (App.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime lifetime)
|
||||
|
||||
@@ -15,7 +15,10 @@
|
||||
|
||||
<controls:LinkLabel FontSize="16" FontWeight="Bold" Text="{Binding Version}" ToolTip.Tip="View Release Notes" Tapped="ViewReleaseNotes_Tapped" />
|
||||
|
||||
<controls:LinkLabel Grid.Row="1" FontSize="14" VerticalAlignment="Center" Text="https://getlibation.com" Tapped="Link_getlibation"/>
|
||||
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="16">
|
||||
<controls:LinkLabel FontSize="14" VerticalAlignment="Center" Text="https://getlibation.com" Tapped="Link_getlibation"/>
|
||||
<controls:LinkLabel FontSize="14" VerticalAlignment="Center" Text="Donate" Tapped="Link_donate"/>
|
||||
</StackPanel>
|
||||
|
||||
<Button Grid.Row="2" HorizontalAlignment="Stretch" HorizontalContentAlignment="Center" Margin="0,10,0,0" IsEnabled="{Binding CanCheckForUpgrade}" Content="{Binding UpgradeButtonText}" Click="CheckForUpgrade_Click" />
|
||||
|
||||
|
||||
@@ -62,6 +62,8 @@ public partial class AboutDialog : DialogWindow
|
||||
|
||||
private void Link_getlibation(object sender, Avalonia.Input.TappedEventArgs e) => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.WebsiteUrl);
|
||||
|
||||
private void Link_donate(object sender, Avalonia.Input.TappedEventArgs e) => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.DonateUrl);
|
||||
|
||||
private void ViewReleaseNotes_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
|
||||
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}");
|
||||
}
|
||||
|
||||
@@ -0,0 +1,40 @@
|
||||
<Window xmlns="https://github.com/avaloniaui"
|
||||
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
|
||||
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
|
||||
mc:Ignorable="d" d:DesignWidth="480" d:DesignHeight="320"
|
||||
MinWidth="400" MaxWidth="520"
|
||||
SizeToContent="Height"
|
||||
CanMaximize="False"
|
||||
CanMinimize="False"
|
||||
x:Class="LibationAvalonia.Dialogs.BadBookActionDialog"
|
||||
ShowInTaskbar="True"
|
||||
WindowStartupLocation="CenterOwner">
|
||||
|
||||
<Grid ColumnDefinitions="Auto,*" RowDefinitions="Auto,Auto,Auto" Margin="5,10,10,10">
|
||||
<Image Grid.Row="0" Grid.Column="0" Height="32" Width="32" Margin="5,0,5,0"
|
||||
VerticalAlignment="Top" Stretch="Uniform" Source="/Assets/MBIcons/Question_64.png"/>
|
||||
<TextBlock Grid.Row="0" Grid.Column="1" Name="messageTextBlock" Margin="5,0,0,0" MinWidth="300"
|
||||
TextWrapping="Wrap" HorizontalAlignment="Stretch" VerticalAlignment="Top"
|
||||
FontSize="12"/>
|
||||
|
||||
<StackPanel Grid.Row="1" Grid.ColumnSpan="2" Margin="10,10,10,10" Spacing="6">
|
||||
<CheckBox Name="applyToAllCheckBox" Content="Apply to all remaining books in this queue"/>
|
||||
<CheckBox Name="rememberInSettingsCheckBox" Content="Remember this choice in Settings"/>
|
||||
</StackPanel>
|
||||
|
||||
<DockPanel Height="45" Grid.Row="2" Grid.ColumnSpan="2" Background="{DynamicResource SystemChromeMediumLowColor}">
|
||||
<StackPanel Orientation="Horizontal" HorizontalAlignment="Right" Margin="5" DockPanel.Dock="Bottom">
|
||||
<Button MinWidth="75" MinHeight="28" Name="abortButton" Click="AbortButton_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="Abort"/>
|
||||
</Button>
|
||||
<Button MinWidth="75" MinHeight="28" Name="retryButton" Click="RetryButton_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="Retry"/>
|
||||
</Button>
|
||||
<Button MinWidth="75" MinHeight="28" Name="ignoreButton" Click="IgnoreButton_Click" Margin="5">
|
||||
<TextBlock VerticalAlignment="Center" HorizontalAlignment="Center" Text="Ignore"/>
|
||||
</Button>
|
||||
</StackPanel>
|
||||
</DockPanel>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -0,0 +1,94 @@
|
||||
using Avalonia;
|
||||
using Avalonia.Controls;
|
||||
using Avalonia.Controls.ApplicationLifetimes;
|
||||
using Avalonia.Threading;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.Dialogs;
|
||||
|
||||
public partial class BadBookActionDialog : DialogWindow
|
||||
{
|
||||
public BadBookDialogResult Result { get; private set; } = new(DialogResult.Retry, false, false);
|
||||
|
||||
public BadBookActionDialog()
|
||||
{
|
||||
InitializeComponent();
|
||||
SaveOnEnter = false;
|
||||
CancelOnEscape = false;
|
||||
}
|
||||
|
||||
public BadBookActionDialog(string message, string caption) : this()
|
||||
{
|
||||
Title = caption;
|
||||
messageTextBlock.Text = message;
|
||||
ControlToFocusOnShow = retryButton;
|
||||
}
|
||||
|
||||
private void CloseWith(DialogResult action)
|
||||
{
|
||||
Result = new BadBookDialogResult(
|
||||
action,
|
||||
applyToAllCheckBox.IsChecked == true,
|
||||
rememberInSettingsCheckBox.IsChecked == true);
|
||||
Close(DialogResult.None);
|
||||
}
|
||||
|
||||
public void AbortButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
=> CloseWith(DialogResult.Abort);
|
||||
|
||||
public void RetryButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
=> CloseWith(DialogResult.Retry);
|
||||
|
||||
public void IgnoreButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
|
||||
=> CloseWith(DialogResult.Ignore);
|
||||
|
||||
public static Task<BadBookDialogResult> ShowAsync(Window? owner, string message, string caption)
|
||||
=> Dispatcher.UIThread.InvokeAsync(async () =>
|
||||
{
|
||||
owner = owner?.IsLoaded is true ? owner : null;
|
||||
var dialog = new BadBookActionDialog(message, caption);
|
||||
await DisplayDialogAsync(dialog, owner);
|
||||
return dialog.Result;
|
||||
});
|
||||
|
||||
private static async Task DisplayDialogAsync(BadBookActionDialog dialog, Window? owner)
|
||||
{
|
||||
if (owner is null)
|
||||
{
|
||||
if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop)
|
||||
{
|
||||
if (desktop.MainWindow?.IsLoaded is true)
|
||||
await dialog.ShowDialog(desktop.MainWindow);
|
||||
else
|
||||
{
|
||||
var tcs = new TaskCompletionSource();
|
||||
desktop.MainWindow = dialog;
|
||||
dialog.Closed += (_, _) => tcs.SetResult();
|
||||
dialog.Show();
|
||||
await tcs.Task;
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
var window = new Window
|
||||
{
|
||||
IsVisible = false,
|
||||
Height = 1,
|
||||
Width = 1,
|
||||
WindowDecorations = WindowDecorations.None,
|
||||
ShowInTaskbar = false
|
||||
};
|
||||
|
||||
window.Show();
|
||||
await dialog.ShowDialog(window);
|
||||
window.Close();
|
||||
}
|
||||
}
|
||||
else
|
||||
{
|
||||
await dialog.ShowDialog(owner);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -80,6 +80,14 @@
|
||||
<settings:Audio DataContext="{Binding AudioSettings, Mode=OneTime}" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
<TabItem>
|
||||
<TabItem.Header>
|
||||
<TextBlock Text="Audiobookshelf"/>
|
||||
</TabItem.Header>
|
||||
<ScrollViewer VerticalScrollBarVisibility="Auto">
|
||||
<settings:Audiobookshelf DataContext="{Binding AudiobookshelfSettings, Mode=OneTime}" />
|
||||
</ScrollViewer>
|
||||
</TabItem>
|
||||
</TabControl>
|
||||
</Grid>
|
||||
</Window>
|
||||
@@ -2,6 +2,7 @@ using Avalonia.Controls;
|
||||
using FileManager;
|
||||
using LibationAvalonia.ViewModels.Settings;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
@@ -32,8 +33,19 @@ public partial class SettingsDialog : DialogWindow
|
||||
|
||||
#endregion
|
||||
|
||||
var tokenDecision = await settingsDisp.ImportantSettings.PromptOnSaveAsync(this);
|
||||
if (tokenDecision == TokenStorageSaveDecision.Abort)
|
||||
return;
|
||||
|
||||
settingsDisp.SaveSettings(config);
|
||||
|
||||
if (tokenDecision == TokenStorageSaveDecision.SavePreferenceAndConvert)
|
||||
{
|
||||
var converted = await settingsDisp.ImportantSettings.ConvertAfterSaveAsync(this);
|
||||
if (!converted)
|
||||
return;
|
||||
}
|
||||
|
||||
await MessageBox.VerboseLoggingWarning_ShowIfTrue();
|
||||
await base.SaveAndCloseAsync();
|
||||
}
|
||||
|
||||
@@ -9,6 +9,7 @@
|
||||
Width="500" Height="350"
|
||||
Icon="/Assets/libation.ico"
|
||||
Title="Welcome to Libation"
|
||||
ShowInTaskbar="True"
|
||||
x:DataType="dialogs:SetupDialog">
|
||||
|
||||
<Grid
|
||||
|
||||
@@ -77,7 +77,7 @@
|
||||
<PackageReference Include="Avalonia.Diagnostics" Version="11.3.14" Condition="'$(Configuration)|$(Platform)'=='Debug|AnyCPU'" />
|
||||
<PackageReference Include="Avalonia.Controls.DataGrid" Version="12.0.0" />
|
||||
<PackageReference Include="Avalonia.Desktop" Version="12.0.2" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.1" />
|
||||
<PackageReference Include="ReactiveUI.Avalonia" Version="12.0.2" />
|
||||
<PackageReference Include="Avalonia.Themes.Fluent" Version="12.0.2" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -28,7 +28,7 @@ static class Program
|
||||
//We can do this because we're already executing inside the sandbox.
|
||||
//Any process created in the sandbox executes in the same sandbox.
|
||||
//Unfortunately, all sandbox files are read/execute, so no writing!
|
||||
Process.Start("Hangover");
|
||||
HangoverLauncher.Launch();
|
||||
return;
|
||||
}
|
||||
if (Configuration.IsMacOs && args?.Length > 0 && args[0] == "cli")
|
||||
@@ -53,6 +53,7 @@ static class Program
|
||||
try
|
||||
{
|
||||
var config = LibationScaffolding.RunPreConfigMigrations();
|
||||
StartupAssemblyBootstrap.RecoverFromIncompleteUpgradeIfNeeded();
|
||||
if (config.LibationFiles.SettingsAreValid)
|
||||
{
|
||||
App.RunMigrations(config);
|
||||
@@ -128,13 +129,17 @@ static class Program
|
||||
{
|
||||
var dispatcher = new DispatcherFrame();
|
||||
|
||||
var mbAlert = new MessageBoxAlertAdminDialog("""
|
||||
Libation encountered a fatal error and must close.
|
||||
var fatalMessage = StartupAssemblyBootstrap.GetFatalStartupMessage(
|
||||
exception,
|
||||
new FatalStartupMessage(
|
||||
"Libation Crash",
|
||||
"""
|
||||
Libation encountered a fatal error and must close.
|
||||
|
||||
Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder.
|
||||
""",
|
||||
"Libation Crash",
|
||||
exception);
|
||||
Please consider reporting this issue on GitHub, including the contents of the LibationCrash.log file created in your user folder.
|
||||
"""));
|
||||
|
||||
var mbAlert = new MessageBoxAlertAdminDialog(fatalMessage.Body, fatalMessage.Title, exception);
|
||||
mbAlert.Closed += (_, _) => dispatcher.Continue = false;
|
||||
mbAlert.Show();
|
||||
Dispatcher.UIThread.PushFrame(dispatcher);
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using ApplicationServices;
|
||||
using Avalonia.Threading;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
@@ -77,6 +78,9 @@ partial class MainVM
|
||||
|
||||
if (Configuration.Instance.AutoDownloadEpisodes
|
||||
&& stats.PendingBooks + stats.pdfsNotDownloaded > 0)
|
||||
await BackupAllBooksAsync(stats.LibraryBooks);
|
||||
{
|
||||
// RunWorkerCompleted has no SynchronizationContext; queue items require the UI thread.
|
||||
await Dispatcher.UIThread.InvokeAsync(async () => await BackupAllBooksAsync(stats.LibraryBooks));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
#if DEBUG
|
||||
using LibationUiBase.Forms;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.ViewModels;
|
||||
|
||||
partial class MainVM
|
||||
{
|
||||
public async Task SimulateBadBookFailuresAsync()
|
||||
{
|
||||
var books = ProductsDisplay.GetVisibleBookEntries().Take(5).ToArray();
|
||||
if (books.Length == 0)
|
||||
{
|
||||
await MessageBox.Show(
|
||||
"No books are visible in the grid.\n\nClear your filter or widen it, then try again.",
|
||||
"Test bad book dialog",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Information);
|
||||
return;
|
||||
}
|
||||
|
||||
var confirm = await MessageBox.Show(
|
||||
$"Queue {books.Length} visible book(s) with simulated failures?\n\n"
|
||||
+ "No files will be downloaded. Each book will immediately show the bad-book error dialog.\n\n"
|
||||
+ "Set error handling to \"Ask each time\" in Settings > Download/Decrypt before testing.",
|
||||
"Test bad book dialog",
|
||||
MessageBoxButtons.YesNo,
|
||||
MessageBoxIcon.Question,
|
||||
MessageBoxDefaultButton.Button1);
|
||||
|
||||
if (confirm != DialogResult.Yes)
|
||||
return;
|
||||
|
||||
ProcessQueue.QueueSimulatedBadBookFailures(books);
|
||||
setQueueCollapseState(false);
|
||||
}
|
||||
}
|
||||
#endif
|
||||
@@ -212,6 +212,7 @@ public partial class MainVM
|
||||
try
|
||||
{
|
||||
var (totalProcessed, newAdded) = await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
autoScanRunner?.OnManualScanSucceeded();
|
||||
|
||||
// this is here instead of ScanEnd so that the following is only possible when it's user-initiated, not automatic loop
|
||||
if (Configuration.Instance.ShowImportedStats && newAdded > 0)
|
||||
@@ -231,6 +232,14 @@ public partial class MainVM
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
webViewEx);
|
||||
}
|
||||
else if (NonJsonResponseExceptionExtensions.TryFindInTree(ex, out var htmlEx) && htmlEx is not null)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
MainWindow,
|
||||
htmlEx.GetExplainerBody(),
|
||||
NonJsonResponseExceptionExtensions.LibraryScanFailedCaption,
|
||||
htmlEx);
|
||||
}
|
||||
else
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
|
||||
@@ -2,6 +2,8 @@
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase;
|
||||
using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
@@ -11,45 +13,37 @@ namespace LibationAvalonia.ViewModels;
|
||||
|
||||
partial class MainVM
|
||||
{
|
||||
private readonly InterruptableTimer autoScanTimer = new InterruptableTimer(TimeSpan.FromMinutes(5));
|
||||
private readonly InterruptableTimer autoScanTimer = new(TimeSpan.FromMinutes(5));
|
||||
private AutoScanRunner? autoScanRunner;
|
||||
|
||||
private void Configure_ScanAuto()
|
||||
{
|
||||
// subscribe as async/non-blocking. I'd actually rather prefer blocking but real-world testing found that caused a deadlock in the AudibleAPI
|
||||
autoScanTimer.Elapsed += async (_, __) =>
|
||||
{
|
||||
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
|
||||
var accounts = persister.AccountsSettings
|
||||
.GetAll()
|
||||
.Where(a => a.LibraryScan)
|
||||
.ToArray();
|
||||
autoScanRunner = new AutoScanRunner(
|
||||
isAutoScanEnabled: () => Configuration.Instance.AutoScan,
|
||||
pauseTimer: () => autoScanTimer.Stop(),
|
||||
resumeTimer: () => autoScanTimer.PerformNow(),
|
||||
notifyAuthRequired: notifyAutoScanAuthRequiredAsync);
|
||||
|
||||
// in autoScan, new books SHALL NOT show dialog
|
||||
try
|
||||
{
|
||||
await Task.Run(() => LibraryCommands.ImportAccountAsync(accounts));
|
||||
}
|
||||
catch (OperationCanceledException)
|
||||
{
|
||||
Serilog.Log.Information("Audible login attempt cancelled by user");
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Error invoking auto-scan");
|
||||
}
|
||||
};
|
||||
autoScanTimer.Elapsed += async (_, __) => await autoScanRunner.RunAsync();
|
||||
|
||||
// if enabled: begin on load
|
||||
MainWindow.Loaded += startAutoScan;
|
||||
|
||||
// if new 'default' account is added, run autoscan
|
||||
AccountsSettingsPersister.Saving += accountsPreSave;
|
||||
AccountsSettingsPersister.Saved += accountsPostSave;
|
||||
|
||||
// when autoscan setting is changed, update menu checkbox and run autoscan
|
||||
Configuration.Instance.PropertyChanged += startAutoScan;
|
||||
}
|
||||
|
||||
private async Task notifyAutoScanAuthRequiredAsync()
|
||||
{
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
"Libation could not refresh your Audible library because your login session expired.\n\n"
|
||||
+ "Background auto-scan has been paused. Use Import > Scan Library to log in again to resume periodic scans.",
|
||||
"Auto-scan paused - login required",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
|
||||
private List<(string AccountId, string LocaleName)>? preSaveDefaultAccounts;
|
||||
private List<(string AccountId, string LocaleName)> getDefaultAccounts()
|
||||
@@ -75,6 +69,7 @@ partial class MainVM
|
||||
[PropertyChangeFilter(nameof(Configuration.AutoScan))]
|
||||
private void startAutoScan(object? sender = null, EventArgs? e = null)
|
||||
{
|
||||
autoScanRunner?.OnAutoScanSettingChanged();
|
||||
AutoScanChecked = Configuration.Instance.AutoScan;
|
||||
if (AutoScanChecked)
|
||||
autoScanTimer.PerformNow();
|
||||
|
||||
@@ -1,7 +1,9 @@
|
||||
using Avalonia.Controls;
|
||||
using LibationFileManager;
|
||||
using LibationUiBase.Forms;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Reactive;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace LibationAvalonia.ViewModels;
|
||||
@@ -10,8 +12,12 @@ partial class MainVM
|
||||
{
|
||||
public string FindBetterQualityBooksTip => Configuration.GetHelpText("FindBetterQualityBooks");
|
||||
public bool MenuBarVisible { get => field; set => this.RaiseAndSetIfChanged(ref field, value); } = !Configuration.IsMacOs;
|
||||
public ReactiveCommand<Unit, Unit> LaunchHangover { get; private set; } = null!;
|
||||
|
||||
private void Configure_Settings()
|
||||
{
|
||||
LaunchHangover = ReactiveCommand.CreateFromTask(LaunchHangoverAsync);
|
||||
|
||||
if (App.Current is Avalonia.Application app &&
|
||||
NativeMenu.GetMenu(app)?.Items[0] is NativeMenuItem aboutMenu)
|
||||
aboutMenu.Command = ReactiveCommand.Create(ShowAboutAsync);
|
||||
@@ -23,15 +29,21 @@ partial class MainVM
|
||||
public Task ShowTrashBinAsync() => new LibationAvalonia.Dialogs.TrashBinDialog().ShowDialog(MainWindow);
|
||||
public Task ShowFindBetterQualityBooksAsync() => new LibationAvalonia.Dialogs.FindBetterQualityBooksDialog().ShowDialog(MainWindow);
|
||||
|
||||
public void LaunchHangover()
|
||||
private async Task LaunchHangoverAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
System.Diagnostics.Process.Start("Hangover" + (Configuration.IsWindows ? ".exe" : ""));
|
||||
HangoverLauncher.Launch();
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, "Failed to launch Hangover");
|
||||
await MessageBox.Show(
|
||||
MainWindow,
|
||||
HangoverLauncher.GetLaunchFailureMessage(ex),
|
||||
"Launch Hangover",
|
||||
MessageBoxButtons.OK,
|
||||
MessageBoxIcon.Warning);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -271,7 +271,11 @@ public class ProductsDisplayViewModel : ViewModelBase
|
||||
episodeEntry = new LibraryBookEntry(episodeBook, seriesEntry);
|
||||
seriesEntry.Children.Add(episodeEntry);
|
||||
seriesEntry.Children.Sort((c1, c2) => c1.SeriesIndex.CompareTo(c2.SeriesIndex));
|
||||
var seriesBook = dbBooks.Single(lb => lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId);
|
||||
var seriesBook = dbBooks.FindSeriesParent(episodeBook)
|
||||
?? dbBooks.FirstOrDefault(lb =>
|
||||
lb.Book.AudibleProductId == seriesEntry.LibraryBook.Book.AudibleProductId
|
||||
&& lb.Account == seriesEntry.LibraryBook.Account)
|
||||
?? seriesEntry.LibraryBook;
|
||||
seriesEntry.UpdateLibraryBook(seriesBook);
|
||||
}
|
||||
|
||||
@@ -443,6 +447,14 @@ public class ProductsDisplayViewModel : ViewModelBase
|
||||
WebView2LoginErrorMessage.Caption,
|
||||
webViewEx);
|
||||
}
|
||||
else if (NonJsonResponseExceptionExtensions.TryFindInTree(ex, out var htmlEx) && htmlEx is not null)
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
null,
|
||||
htmlEx.GetExplainerBody(),
|
||||
NonJsonResponseExceptionExtensions.LibraryScanFailedCaption,
|
||||
htmlEx);
|
||||
}
|
||||
else
|
||||
{
|
||||
await MessageBox.ShowAdminAlert(
|
||||
|
||||
@@ -0,0 +1,247 @@
|
||||
using ApplicationServices;
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleUtilities;
|
||||
using LibationFileManager;
|
||||
using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Collections.ObjectModel;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace LibationAvalonia.ViewModels.Settings;
|
||||
|
||||
public class AudiobookshelfSettingsVM : ViewModelBase
|
||||
{
|
||||
private readonly Configuration config;
|
||||
private bool enabled;
|
||||
private string serverUrl = "";
|
||||
private string apiToken = "";
|
||||
private string statusText = "";
|
||||
private bool statusIsError;
|
||||
private bool plaintextWarningVisible;
|
||||
private List<AudiobookshelfApiService.Library> libraries = [];
|
||||
private int selectedLibraryIndex = -1;
|
||||
private int selectedFolderIndex = -1;
|
||||
private bool isConnecting;
|
||||
private ObservableCollection<string> libraryNames = new();
|
||||
private ObservableCollection<string> folderNames = new();
|
||||
|
||||
public AudiobookshelfSettingsVM(Configuration config)
|
||||
{
|
||||
this.config = config;
|
||||
enabled = config.AudiobookshelfEnabled;
|
||||
serverUrl = config.AudiobookshelfServerUrl ?? "";
|
||||
apiToken = AudiobookshelfTokenStorage.DecryptToken(config.AudiobookshelfApiToken) ?? "";
|
||||
|
||||
var osSecretStoreAvailable = IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out _);
|
||||
bool encryptingButNoProtector = config.TokenStorageMethod == TokenStorageMethod.Encrypted && IdentityTokenStorage.Protector is null;
|
||||
PlaintextWarningVisible = (osSecretStoreAvailable && config.TokenStorageMethod == TokenStorageMethod.Plaintext) || encryptingButNoProtector;
|
||||
|
||||
ConnectCommand = ReactiveCommand.CreateFromTask(ConnectAsync);
|
||||
_ = RestoreLibrariesAsync();
|
||||
}
|
||||
|
||||
public bool Enabled
|
||||
{
|
||||
get => enabled;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref enabled, value);
|
||||
this.RaisePropertyChanged(nameof(CanConnect));
|
||||
}
|
||||
}
|
||||
|
||||
public string ServerUrl
|
||||
{
|
||||
get => serverUrl;
|
||||
set => this.RaiseAndSetIfChanged(ref serverUrl, value);
|
||||
}
|
||||
|
||||
public string ApiToken
|
||||
{
|
||||
get => apiToken;
|
||||
set => this.RaiseAndSetIfChanged(ref apiToken, value);
|
||||
}
|
||||
|
||||
public string StatusText
|
||||
{
|
||||
get => statusText;
|
||||
set => this.RaiseAndSetIfChanged(ref statusText, value);
|
||||
}
|
||||
|
||||
public bool StatusIsError
|
||||
{
|
||||
get => statusIsError;
|
||||
set => this.RaiseAndSetIfChanged(ref statusIsError, value);
|
||||
}
|
||||
|
||||
public bool PlaintextWarningVisible
|
||||
{
|
||||
get => plaintextWarningVisible;
|
||||
private set => this.RaiseAndSetIfChanged(ref plaintextWarningVisible, value);
|
||||
}
|
||||
|
||||
public List<AudiobookshelfApiService.Library> Libraries
|
||||
{
|
||||
get => libraries;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref libraries, value);
|
||||
UpdateLibraryNames();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<string> LibraryNames
|
||||
{
|
||||
get => libraryNames;
|
||||
set => this.RaiseAndSetIfChanged(ref libraryNames, value);
|
||||
}
|
||||
|
||||
public int SelectedLibraryIndex
|
||||
{
|
||||
get => selectedLibraryIndex;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref selectedLibraryIndex, value);
|
||||
UpdateFolders();
|
||||
}
|
||||
}
|
||||
|
||||
public ObservableCollection<string> FolderNames
|
||||
{
|
||||
get => folderNames;
|
||||
set => this.RaiseAndSetIfChanged(ref folderNames, value);
|
||||
}
|
||||
|
||||
public int SelectedFolderIndex
|
||||
{
|
||||
get => selectedFolderIndex;
|
||||
set => this.RaiseAndSetIfChanged(ref selectedFolderIndex, value);
|
||||
}
|
||||
|
||||
public bool IsConnecting
|
||||
{
|
||||
get => isConnecting;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref isConnecting, value);
|
||||
this.RaisePropertyChanged(nameof(CanConnect));
|
||||
}
|
||||
}
|
||||
|
||||
public bool CanConnect => Enabled && !IsConnecting;
|
||||
|
||||
public ICommand ConnectCommand { get; }
|
||||
|
||||
// Labels from Configuration descriptions
|
||||
public string EnabledText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfEnabled));
|
||||
public string ServerUrlText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfServerUrl));
|
||||
public string ApiTokenText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfApiToken));
|
||||
public string LibraryText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfLibraryId));
|
||||
public string FolderText { get; } = Configuration.GetDescription(nameof(Configuration.AudiobookshelfFolderId));
|
||||
public string PlaintextWarningText { get; } = "Warning: The API token is stored as plaintext in Settings.json.";
|
||||
public string ConnectButtonText { get; } = "Connect / Refresh";
|
||||
|
||||
private void UpdateLibraryNames()
|
||||
{
|
||||
LibraryNames.Clear();
|
||||
foreach (var lib in Libraries)
|
||||
LibraryNames.Add($"{lib.Name} ({lib.Id})");
|
||||
}
|
||||
|
||||
private void UpdateFolders()
|
||||
{
|
||||
FolderNames.Clear();
|
||||
SelectedFolderIndex = -1;
|
||||
|
||||
if (SelectedLibraryIndex < 0 || SelectedLibraryIndex >= Libraries.Count)
|
||||
return;
|
||||
|
||||
var library = Libraries[SelectedLibraryIndex];
|
||||
foreach (var folder in library.Folders)
|
||||
FolderNames.Add($"{folder.FullPath} ({folder.Id})");
|
||||
|
||||
if (FolderNames.Count > 0)
|
||||
SelectedFolderIndex = 0;
|
||||
}
|
||||
|
||||
private async Task ConnectAsync()
|
||||
{
|
||||
IsConnecting = true;
|
||||
StatusText = "Connecting...";
|
||||
StatusIsError = false;
|
||||
|
||||
try
|
||||
{
|
||||
var result = await AudiobookshelfApiService.ConnectAsync(ServerUrl, ApiToken);
|
||||
|
||||
if (!string.IsNullOrEmpty(result.NormalizedServerUrl))
|
||||
ServerUrl = result.NormalizedServerUrl;
|
||||
|
||||
StatusText = result.StatusMessage;
|
||||
StatusIsError = !result.Success;
|
||||
|
||||
if (!result.Success)
|
||||
return;
|
||||
|
||||
Libraries = result.Libraries.ToList();
|
||||
SelectedLibraryIndex = 0;
|
||||
}
|
||||
finally
|
||||
{
|
||||
IsConnecting = false;
|
||||
}
|
||||
}
|
||||
|
||||
private async Task RestoreLibrariesAsync()
|
||||
{
|
||||
try
|
||||
{
|
||||
if (string.IsNullOrWhiteSpace(config.AudiobookshelfServerUrl) || string.IsNullOrWhiteSpace(config.AudiobookshelfApiToken))
|
||||
return;
|
||||
|
||||
var libs = await AudiobookshelfApiService.GetLibrariesAsync(config.AudiobookshelfServerUrl, config.AudiobookshelfApiToken);
|
||||
Libraries = libs;
|
||||
|
||||
var savedLibIndex = libs.FindIndex(l => l.Id == config.AudiobookshelfLibraryId);
|
||||
if (savedLibIndex >= 0)
|
||||
{
|
||||
SelectedLibraryIndex = savedLibIndex;
|
||||
var library = libs[savedLibIndex];
|
||||
var savedFolderIndex = library.Folders.FindIndex(f => f.Id == config.AudiobookshelfFolderId);
|
||||
if (savedFolderIndex >= 0)
|
||||
SelectedFolderIndex = savedFolderIndex;
|
||||
}
|
||||
}
|
||||
catch
|
||||
{
|
||||
// Silently fail; user can manually reconnect
|
||||
}
|
||||
}
|
||||
|
||||
public void SaveSettings(Configuration config)
|
||||
{
|
||||
config.AudiobookshelfEnabled = Enabled;
|
||||
config.AudiobookshelfServerUrl = AudiobookshelfApiService.TryNormalizeServerUrlForSave(ServerUrl);
|
||||
ServerUrl = config.AudiobookshelfServerUrl ?? "";
|
||||
config.AudiobookshelfApiToken = AudiobookshelfTokenStorage.EncryptToken(ApiToken.Trim());
|
||||
|
||||
if (SelectedLibraryIndex >= 0 && Libraries.Count > SelectedLibraryIndex)
|
||||
config.AudiobookshelfLibraryId = Libraries[SelectedLibraryIndex].Id;
|
||||
else
|
||||
config.AudiobookshelfLibraryId = null;
|
||||
|
||||
if (SelectedLibraryIndex >= 0 && SelectedFolderIndex >= 0)
|
||||
{
|
||||
var library = Libraries[SelectedLibraryIndex];
|
||||
if (library.Folders.Count > SelectedFolderIndex)
|
||||
config.AudiobookshelfFolderId = library.Folders[SelectedFolderIndex].Id;
|
||||
else
|
||||
config.AudiobookshelfFolderId = null;
|
||||
}
|
||||
else
|
||||
config.AudiobookshelfFolderId = null;
|
||||
}
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
using AudibleApi.Authorization;
|
||||
using AudibleUtilities;
|
||||
using Dinah.Core;
|
||||
using FileManager;
|
||||
using LibationFileManager;
|
||||
@@ -6,6 +8,8 @@ using ReactiveUI;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using System.Windows.Input;
|
||||
|
||||
namespace LibationAvalonia.ViewModels.Settings;
|
||||
|
||||
@@ -13,6 +17,13 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
{
|
||||
private EnumDisplay<Configuration.Theme> themeVariant;
|
||||
private readonly Configuration config;
|
||||
private bool encryptTokens;
|
||||
private bool plaintextWarningVisible;
|
||||
private bool convertButtonEnabled;
|
||||
private bool osStoreUnavailableVisible;
|
||||
private string osStoreUnavailableMessage = "";
|
||||
private TokenStorageMethod initialTokenStorageMethod;
|
||||
private bool osSecretStoreAvailable;
|
||||
|
||||
public ImportantSettingsVM(Configuration config)
|
||||
{
|
||||
@@ -29,8 +40,35 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
GridFontScaleFactor = scaleFactorToLinearRange(config.GridFontScaleFactor);
|
||||
|
||||
themeVariant = Themes.Single(v => v.Value == config.ThemeVariant);
|
||||
|
||||
initialTokenStorageMethod = config.TokenStorageMethod;
|
||||
osSecretStoreAvailable = IdentityTokenStorageWiring.IsOsSecretStoreAvailable(out var unavailableReason);
|
||||
if (!osSecretStoreAvailable)
|
||||
{
|
||||
OsStoreUnavailableVisible = true;
|
||||
OsStoreUnavailableMessage = TokenStorageSettingsUi.OsStoreUnavailableMessage(unavailableReason);
|
||||
encryptTokens = false;
|
||||
CanEditEncryptTokens = false;
|
||||
ExportButtonEnabled = false;
|
||||
}
|
||||
else
|
||||
{
|
||||
OsStoreUnavailableVisible = false;
|
||||
encryptTokens = TokenStorageSettingsUi.CheckboxFromMethod(config.TokenStorageMethod);
|
||||
CanEditEncryptTokens = true;
|
||||
ExportButtonEnabled = true;
|
||||
}
|
||||
|
||||
RefreshTokenStorageUiState();
|
||||
ConvertExistingTokensCommand = ReactiveCommand.CreateFromTask(ConvertExistingTokensAsync);
|
||||
}
|
||||
|
||||
public async Task<TokenStorageSaveDecision> PromptOnSaveAsync(object? owner)
|
||||
=> await TokenStorageSettingsUi.PromptOnPreferenceSaveAsync(owner, initialTokenStorageMethod, SelectedTokenStorageMethod);
|
||||
|
||||
public async Task<bool> ConvertAfterSaveAsync(object? owner)
|
||||
=> await TokenStorageSettingsUi.RunConversionAsync(owner, SelectedTokenStorageMethod);
|
||||
|
||||
public void SaveSettings(Configuration config)
|
||||
{
|
||||
config.Books = GetBooksDirectory();
|
||||
@@ -40,6 +78,9 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
config.LastWriteTime = LastWriteTime.Value;
|
||||
config.UseWebView = UseWebView;
|
||||
config.LogLevel = LoggingLevel;
|
||||
config.TokenStorageMethod = SelectedTokenStorageMethod;
|
||||
initialTokenStorageMethod = SelectedTokenStorageMethod;
|
||||
RefreshTokenStorageUiState();
|
||||
}
|
||||
|
||||
public LongPath GetBooksDirectory()
|
||||
@@ -63,6 +104,24 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
Go.To.Folder(Configuration.Instance.LibationFiles.Location.ShortPathName);
|
||||
}
|
||||
|
||||
private async Task ConvertExistingTokensAsync()
|
||||
{
|
||||
var converted = await TokenStorageSettingsUi.ConfirmAndConvertAsync(null, SelectedTokenStorageMethod);
|
||||
if (converted)
|
||||
RefreshTokenStorageUiState();
|
||||
}
|
||||
|
||||
private void RefreshTokenStorageUiState()
|
||||
{
|
||||
var method = SelectedTokenStorageMethod;
|
||||
PlaintextWarningVisible = osSecretStoreAvailable && method == TokenStorageMethod.Plaintext;
|
||||
var alignment = AccountTokenStorage.GetAccountsAlignment(method);
|
||||
ConvertButtonEnabled = TokenStorageSettingsUi.IsConvertButtonEnabled(alignment);
|
||||
}
|
||||
|
||||
private TokenStorageMethod SelectedTokenStorageMethod
|
||||
=> TokenStorageSettingsUi.MethodFromCheckbox(EncryptTokens);
|
||||
|
||||
public List<Configuration.KnownDirectories> KnownDirectories { get; } = new()
|
||||
{
|
||||
Configuration.KnownDirectories.LibationFiles,
|
||||
@@ -95,6 +154,15 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
.Select(v => new EnumDisplay<Configuration.Theme>(v))
|
||||
.ToArray();
|
||||
|
||||
public string EncryptTokensText { get; } = TokenStorageSettingsUi.CheckboxText;
|
||||
public string PlaintextWarningText { get; } = TokenStorageSettingsUi.PlaintextWarningText;
|
||||
public string ConvertButtonText { get; } = TokenStorageSettingsUi.ConvertButtonText;
|
||||
public string ConvertButtonToolTip { get; } = TokenStorageSettingsUi.ConvertButtonToolTip;
|
||||
public string ExportButtonText { get; } = TokenStorageSettingsUi.ExportButtonText;
|
||||
public string ExportButtonToolTip { get; } = TokenStorageSettingsUi.ExportButtonToolTip;
|
||||
public bool CanEditEncryptTokens { get; }
|
||||
public bool ExportButtonEnabled { get; }
|
||||
|
||||
public string BooksDirectory { get; set; }
|
||||
public bool SavePodcastsToParentFolder { get; set; }
|
||||
public bool OverwriteExisting { get; set; }
|
||||
@@ -105,6 +173,42 @@ public class ImportantSettingsVM : ViewModelBase
|
||||
public bool UseWebView { get; set; }
|
||||
public Serilog.Events.LogEventLevel LoggingLevel { get; set; }
|
||||
|
||||
public bool EncryptTokens
|
||||
{
|
||||
get => encryptTokens;
|
||||
set
|
||||
{
|
||||
this.RaiseAndSetIfChanged(ref encryptTokens, value);
|
||||
RefreshTokenStorageUiState();
|
||||
}
|
||||
}
|
||||
|
||||
public bool PlaintextWarningVisible
|
||||
{
|
||||
get => plaintextWarningVisible;
|
||||
private set => this.RaiseAndSetIfChanged(ref plaintextWarningVisible, value);
|
||||
}
|
||||
|
||||
public bool ConvertButtonEnabled
|
||||
{
|
||||
get => convertButtonEnabled;
|
||||
private set => this.RaiseAndSetIfChanged(ref convertButtonEnabled, value);
|
||||
}
|
||||
|
||||
public bool OsStoreUnavailableVisible
|
||||
{
|
||||
get => osStoreUnavailableVisible;
|
||||
private set => this.RaiseAndSetIfChanged(ref osStoreUnavailableVisible, value);
|
||||
}
|
||||
|
||||
public string OsStoreUnavailableMessage
|
||||
{
|
||||
get => osStoreUnavailableMessage;
|
||||
private set => this.RaiseAndSetIfChanged(ref osStoreUnavailableMessage, value);
|
||||
}
|
||||
|
||||
public ICommand ConvertExistingTokensCommand { get; }
|
||||
|
||||
public EnumDisplay<Configuration.Theme> ThemeVariant
|
||||
{
|
||||
get => themeVariant;
|
||||
|
||||
Loaded 100 of 197 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user