Compare commits

..

86 Commits

Author SHA1 Message Date
Robert
e3ad4a2c32 incr ver 2026-01-02 16:20:46 -05:00
rmcrackan
2b1c772df7 Merge pull request #1527 from Mbucari/master
Add feature to scan library for higher quality books
2026-01-02 16:02:01 -05:00
Mbucari
9b3e4f8762 Merge branch 'rmcrackan:master' into master 2026-01-02 13:13:12 -07:00
MBucari
396d2c8a95 Address rmcrackan comments and refactor 2026-01-02 13:04:35 -07:00
rmcrackan
91090b74ab Merge pull request #1526 from rmcrackan/rmcrackan/fix-export-columns
Bug fix #1524 - Fix export columns
2026-01-02 09:52:33 -05:00
Robert
32979b5905 Bug fix #1524 - Fix export columns 2026-01-02 09:52:17 -05:00
Michael Bucari-Tovo
f6b96fc210 Add feature to scan for better quality audiobooks
Add AccessibleDataGridViewColumn which can apply Accessability names and descriptions from the designer.

Create reusable SortBindingList<T> for basic sorting of data-bound items.
2025-12-31 16:31:52 -07:00
Michael Bucari-Tovo
09e610fe08 Sanitize contributor names (#1518) 2025-12-31 13:09:47 -07:00
Michael Bucari-Tovo
e50d8c74de Add UseWindowsForms to csproj 2025-12-31 11:28:45 -07:00
Michael Bucari-Tovo
2b1ca13249 Prevent migrations from running more than once 2025-12-31 11:25:43 -07:00
Michael Bucari-Tovo
7d30a3036d Move viewmodel into UiBase 2025-12-30 15:56:47 -07:00
Michael Bucari-Tovo
bb8b435810 Improve Find Better Quality Audiobooks scanner
Try to load best audio format from actual liberated audiobook files
Allow re-scanning after completing scanning.
2025-12-30 14:49:58 -07:00
MBucari
e850465ec1 Add more null safety
Enable project-wide nullable on LibationUiBase and LibationAvalonia

Explicitly parallelize unit tests
2025-12-30 13:17:11 -07:00
MBucari
29a5c943cb Auto-scroll process queue 2025-12-29 21:52:36 -07:00
Mbucari
31087c0855 Add feature to scan library for higher quality books 2025-12-29 19:30:47 -07:00
rmcrackan
c91d359017 Merge pull request #1517 from Mbucari/master
Fix mp3 conversion of liberated AC-4 files and add metadata tags
2025-12-27 22:21:10 -05:00
MBucari
7dfdc0688a Add some more useful tags
AUDIBLE_ACR, AUDIBLE_DRM_TYPE, and AUDIBLE_LOCALE
2025-12-27 15:40:57 -07:00
MBucari
c6c36c74f1 Allow converting already downloaded AC-4 to mp3 2025-12-27 14:40:10 -07:00
rmcrackan
d932b57853 Merge pull request #1512 from radiorambo/fix-1508
Fix 1508 | auto redirect old docs links to new docs, add new development section to docs
2025-12-27 09:47:04 -05:00
radiorambo
a29da7318b remove routex packgage 2025-12-27 13:04:37 +05:30
radiorambo
678c3e6bcd add Documentation folder with old pages but with only link to new docs 2025-12-27 13:03:55 +05:30
rmcrackan
a8466e38d4 Merge pull request #1515 from Mbucari/master
Add CFBundleShortVersionString to Info.plist
2025-12-26 16:41:03 -05:00
Mbucari
802ccf25e8 Add recent contributors 2025-12-26 14:08:59 -07:00
Mbucari
c243b9c913 Add CFBundleShortVersionString to Info.plist 2025-12-26 13:41:38 -07:00
radiorambo
4d47ab3ebe update old docs links 2025-12-25 15:53:01 +05:30
Umesh
8dea6200ce Merge branch 'master' into fix-1508 2025-12-24 13:38:24 +05:30
radiorambo
a578777352 fix broken links 2025-12-24 11:17:43 +05:30
radiorambo
e58e5165cf update readme 2025-12-24 11:17:29 +05:30
radiorambo
87c2cb6e19 add separete section for development related 2025-12-24 11:11:48 +05:30
Robert
4f44c26b57 incr ver 2025-12-23 09:52:25 -05:00
rmcrackan
03534773ab Merge pull request #1510 from rmcrackan/dependabot/github_actions/actions/configure-pages-5
Bump actions/configure-pages from 4 to 5
2025-12-23 09:39:59 -05:00
rmcrackan
37f223fb77 Merge pull request #1509 from rmcrackan/dependabot/github_actions/actions/upload-pages-artifact-4
Bump actions/upload-pages-artifact from 3 to 4
2025-12-23 09:31:01 -05:00
radiorambo
cf932bd66c redirect old doc links to new doc links 2025-12-23 19:58:14 +05:30
rmcrackan
f0dc33a01e Merge pull request #1511 from rmcrackan/dependabot/github_actions/actions/checkout-6
Bump actions/checkout from 5 to 6
2025-12-23 09:11:11 -05:00
dependabot[bot]
315d76e061 Bump actions/checkout from 5 to 6
Bumps [actions/checkout](https://github.com/actions/checkout) from 5 to 6.
- [Release notes](https://github.com/actions/checkout/releases)
- [Changelog](https://github.com/actions/checkout/blob/main/CHANGELOG.md)
- [Commits](https://github.com/actions/checkout/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:06:01 +00:00
dependabot[bot]
6e78145adc Bump actions/configure-pages from 4 to 5
Bumps [actions/configure-pages](https://github.com/actions/configure-pages) from 4 to 5.
- [Release notes](https://github.com/actions/configure-pages/releases)
- [Commits](https://github.com/actions/configure-pages/compare/v4...v5)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:05:51 +00:00
dependabot[bot]
200a334f86 Bump actions/upload-pages-artifact from 3 to 4
Bumps [actions/upload-pages-artifact](https://github.com/actions/upload-pages-artifact) from 3 to 4.
- [Release notes](https://github.com/actions/upload-pages-artifact/releases)
- [Commits](https://github.com/actions/upload-pages-artifact/compare/v3...v4)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-23 14:05:45 +00:00
rmcrackan
4dd4a1495a Update docker.md 2025-12-22 17:46:10 -05:00
rmcrackan
b3ce0e0af0 Update docker.md
Add LIBATION_CONNECTION_STRING
2025-12-22 17:45:15 -05:00
Mbucari
1299d91d08 Restrict workflow runs to specific paths 2025-12-22 12:54:58 -07:00
rmcrackan
ad3a767057 Merge pull request #1504 from Youssef1313/mtp
Migrate from VSTest to MTP
2025-12-22 14:45:19 -05:00
Michael Bucari-Tovo
a59c73caf8 Add win-arm64 release identfier 2025-12-22 12:37:22 -07:00
rmcrackan
442a688b85 Update README.md 2025-12-22 10:28:21 -05:00
rmcrackan
0c85ea4d11 Merge pull request #1498 from radiorambo/new-documentation-website
add new documentation website
2025-12-22 09:50:45 -05:00
Youssef1313
03ed8e6b57 Migrate from VSTest to MTP 2025-12-21 15:03:52 +01:00
rmcrackan
3eca508a26 Merge pull request #1501 from Mbucari/master
Add support for decoding Windows Arm64 and AC-4 audio files
2025-12-19 08:21:26 -05:00
radiorambo
770adf33f3 add gitHub actions workflow for vitePress deployment to gitHub pages. 2025-12-19 14:09:21 +05:30
MBucari
1087ffb150 Add support for converting AC-4 files to mp3 2025-12-19 00:18:06 -07:00
radiorambo
f620234e7d add docs overview in homepage and in nav bar links 2025-12-18 14:28:46 +05:30
radiorambo
2b6b5d082e fix nav link increase website width in tablet view 2025-12-18 14:27:17 +05:30
Michael Bucari-Tovo
cbbc45c3c5 Add Windows arm64 build 2025-12-17 11:00:29 -07:00
rmcrackan
28de1a6cb6 Merge pull request #1500 from Mbucari/master
Update AAXClean
2025-12-17 06:45:13 -05:00
Michael Bucari-Tovo
1615c6ef77 Update AAXClean 2025-12-16 23:44:04 -07:00
radiorambo
6961bd72fa rename 'report issues' button to 'issues & requests' and simple installation routes 2025-12-16 13:22:56 +05:30
radiorambo
68846a90e5 fix dead links 2025-12-16 13:11:56 +05:30
radiorambo
d60ec0702c update nav and homepage buttons 2025-12-16 13:07:55 +05:30
radiorambo
1c55c8533a improve docs 2025-12-16 13:06:44 +05:30
radiorambo
6fa69b603e rename files 2025-12-15 22:08:01 +05:30
radiorambo
3df8a97463 configure config for clean urls 2025-12-15 21:36:39 +05:30
rmcrackan
0bd7bd80b9 Merge pull request #1496 from rmcrackan/dependabot/github_actions/actions/download-artifact-7
Bump actions/download-artifact from 6 to 7
2025-12-15 09:17:03 -05:00
rmcrackan
13bb4238b4 Merge pull request #1497 from rmcrackan/dependabot/github_actions/actions/upload-artifact-6
Bump actions/upload-artifact from 5 to 6
2025-12-15 09:16:49 -05:00
dependabot[bot]
d5021e4f74 Bump actions/upload-artifact from 5 to 6
Bumps [actions/upload-artifact](https://github.com/actions/upload-artifact) from 5 to 6.
- [Release notes](https://github.com/actions/upload-artifact/releases)
- [Commits](https://github.com/actions/upload-artifact/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 14:06:46 +00:00
dependabot[bot]
5e1458cfb4 Bump actions/download-artifact from 6 to 7
Bumps [actions/download-artifact](https://github.com/actions/download-artifact) from 6 to 7.
- [Release notes](https://github.com/actions/download-artifact/releases)
- [Commits](https://github.com/actions/download-artifact/compare/v6...v7)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-15 14:06:41 +00:00
radiorambo
e1d4533887 use github syntax for admonitions in homepage 2025-12-15 18:27:37 +05:30
radiorambo
c1bd1d983b make documentation development section in readme concise 2025-12-15 17:50:39 +05:30
radiorambo
b567c38a98 update syntax, adjust heading levels, reformat tables, and apply minor text and capitalization fixes across documentation. 2025-12-15 17:45:34 +05:30
radiorambo
348ec22465 update package-lock.json 2025-12-15 17:43:46 +05:30
radiorambo
90bb4d9176 fix favicon and logo not visible 2025-12-15 16:05:02 +05:30
radiorambo
7944154ea6 add icons 2025-12-15 15:37:19 +05:30
radiorambo
01fc7f3fb9 rename documentation files for simple routing paths and reorganise for better navigation 2025-12-15 15:37:07 +05:30
radiorambo
b70f973994 update .gitignore for vitepress 2025-12-15 15:34:37 +05:30
radiorambo
98d3f85579 install vitepress and configure 2025-12-15 15:28:05 +05:30
Robert
bdae155af6 incr ver 2025-12-11 16:41:36 -05:00
rmcrackan
c8b44193ac Merge pull request #1490 from Mbucari/master
Two bugfixes
2025-12-09 13:39:14 -05:00
Mbucari
9545b3a874 Invoke MessageBox on UI thread 2025-12-06 18:55:38 -07:00
Mbucari
e932c9fab9 Merge branch 'rmcrackan:master' into master 2025-12-06 18:02:38 -07:00
Robert
c8f4c1e751 Merge branch 'master' of https://github.com/rmcrackan/Libation 2025-12-06 14:48:26 -05:00
Robert
0303db153f update audibleapi 2025-12-06 14:48:24 -05:00
Mbucari
a7e9479eab Fix file utility modifying file extension using replacement character
The file extension should not be subject to renaming. #1483
2025-12-06 10:40:02 -07:00
Mbucari
d339dbc906 Update macOS install instructions. 2025-12-05 21:06:02 -07:00
Robert
5fe6f931ad incr ver 2025-12-04 20:54:08 -05:00
rmcrackan
ca9fe9fc32 Merge pull request #1479 from Mbucari/master
Two minor bug fixes
2025-12-04 20:52:19 -05:00
MBucari
986dbd678f Don't throw exceptions from failure to delete db-wal and db-shm files (#1478) 2025-12-03 22:09:35 -07:00
MBucari
ea3716f48a Fix books dialog not saving or updating properly (#1477) 2025-12-03 22:03:14 -07:00
rmcrackan
426d5a87b4 Merge pull request #1476 from rmcrackan/dependabot/github_actions/apple-actions/import-codesign-certs-6
Bump apple-actions/import-codesign-certs from 5 to 6
2025-12-03 09:48:46 -05:00
dependabot[bot]
c893bbe52e Bump apple-actions/import-codesign-certs from 5 to 6
Bumps [apple-actions/import-codesign-certs](https://github.com/apple-actions/import-codesign-certs) from 5 to 6.
- [Release notes](https://github.com/apple-actions/import-codesign-certs/releases)
- [Commits](https://github.com/apple-actions/import-codesign-certs/compare/v5...v6)

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

Signed-off-by: dependabot[bot] <support@github.com>
2025-12-03 14:06:28 +00:00
253 changed files with 6149 additions and 1805 deletions

View File

@@ -75,7 +75,7 @@ jobs:
artifact=$(ls ./bundle)
echo "artifact=${artifact}" >> "${GITHUB_OUTPUT}"
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}

View File

@@ -34,7 +34,7 @@ jobs:
RUNTIME_ID: "osx-${{ inputs.architecture }}"
WAIT_FOR_NOTARIZE: ${{ vars.WAIT_FOR_NOTARIZE == 'true' }}
steps:
- uses: apple-actions/import-codesign-certs@v5
- uses: apple-actions/import-codesign-certs@v6
if: ${{ inputs.sign-app }}
with:
p12-file-base64: ${{ secrets.DISTRIBUTION_SIGNING_CERT }}
@@ -96,7 +96,7 @@ jobs:
xcrun stapler staple "./bundle/${{ steps.bundle.outputs.artifact }}"
fi
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.bundle.outputs.artifact }}
path: ./bundle/${{ steps.bundle.outputs.artifact }}

View File

@@ -21,16 +21,21 @@ on:
jobs:
build:
name: "Windows-${{ matrix.release_name }}-x64"
name: "Windows-${{ matrix.release_name }}-${{ matrix.architecture }} (${{ matrix.ui }})"
runs-on: windows-latest
strategy:
matrix:
ui: [Avalonia]
architecture: [x64]
release_name: [chardonnay]
include:
- ui: WinForms
- architecture: x64
ui: WinForms
release_name: classic
prefix: Classic-
- architecture: arm64
ui: Avalonia
release_name: chardonnay
steps:
- uses: actions/checkout@v6
@@ -48,7 +53,7 @@ jobs:
working-directory: ./Source
run: |
$PUBLISH_ARGS=@(
"--runtime", "win-x64",
"--runtime", "win-${{ matrix.architecture }}",
"--configuration", "Release",
"--output", "../bin",
"-p:PublishProtocol=FileSystem",
@@ -70,11 +75,11 @@ 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 }}-x64.zip"
$artifact="${{ matrix.prefix }}Libation.${{ inputs.libation-version }}-windows-${{ matrix.release_name }}-${{ matrix.architecture }}.zip"
"artifact=$artifact" >> $env:GITHUB_OUTPUT
Compress-Archive -Path * -DestinationPath "$artifact"
- uses: actions/upload-artifact@v5
- uses: actions/upload-artifact@v6
with:
name: ${{ steps.zip.outputs.artifact }}
path: ./bin/${{ steps.zip.outputs.artifact }}

67
.github/workflows/deploy.yml vendored Normal file
View File

@@ -0,0 +1,67 @@
name: Deploy VitePress site to Pages
on:
# Runs on pushes targeting the `main` branch. Change this to `master` if you're
# using the `master` branch as the default branch.
push:
branches: [master]
paths:
- .github/workflows/deploy.yml
- docs/**
# Allows you to run this workflow manually from the Actions tab
workflow_dispatch:
# Sets permissions of the GITHUB_TOKEN to allow deployment to GitHub Pages
permissions:
contents: read
pages: write
id-token: write
# Allow only one concurrent deployment, skipping runs queued between the run in-progress and latest queued.
# However, do NOT cancel in-progress runs as we want to allow these production deployments to complete.
concurrency:
group: pages
cancel-in-progress: false
jobs:
# Build job
build:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v6
with:
fetch-depth: 0 # Not needed if lastUpdated is not enabled
# - uses: pnpm/action-setup@v4 # Uncomment this block if you're using pnpm
# with:
# 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
with:
node-version: 24
cache: npm # or pnpm / yarn
- name: Setup Pages
uses: actions/configure-pages@v5
- name: Install dependencies
run: npm ci # or pnpm install / yarn install / bun install
- name: Build with VitePress
run: npm run docs:build # or pnpm docs:build / yarn docs:build / bun run docs:build
- name: Upload artifact
uses: actions/upload-pages-artifact@v4
with:
path: .vitepress/dist
# Deployment job
deploy:
environment:
name: github-pages
url: ${{ steps.deployment.outputs.page_url }}
needs: build
runs-on: ubuntu-latest
name: Deploy
steps:
- name: Deploy to GitHub Pages
id: deployment
uses: actions/deploy-pages@v4

View File

@@ -42,7 +42,7 @@ jobs:
runs-on: ubuntu-latest
steps:
- name: Download artifacts
uses: actions/download-artifact@v6
uses: actions/download-artifact@v7
with:
path: artifacts
pattern: "*(Classic-)Libation.*"

View File

@@ -6,8 +6,14 @@ name: validate
on:
push:
branches: [master]
paths:
- Source/**
- .github/workflows/**
pull_request:
branches: [master]
paths:
- Source/**
- .github/workflows/**
jobs:
get_version:

7
.gitignore vendored
View File

@@ -376,4 +376,9 @@ FodyWeavers.xsd
.DS_Store
# JetBrains Rider Settings
**/.idea/
**/.idea/
# VitePress
node_modules
.vitepress/cache
.vitepress/dist

View File

@@ -1,6 +1,7 @@
{
"WindowsClassic": "Classic-Libation\\.\\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",
"LinuxAvalonia_RPM": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-linux-chardonnay-amd64\\.rpm",
"MacOSAvalonia": "Libation\\.\\d+\\.\\d+\\.\\d+(?:\\.\\d+)?-macOS-chardonnay-x64\\.dmg",

109
.vitepress/config.js Normal file
View File

@@ -0,0 +1,109 @@
import { defineConfig } from "vitepress";
// https://vitepress.dev/reference/site-config
export default defineConfig({
title: "Libation",
description:
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
cleanUrls: true,
themeConfig: {
// https://vitepress.dev/reference/default-theme-config
logo: {
light: "/libation_logo_light.svg",
dark: "/libation_logo_dark.svg",
},
footer: {
message: "Released under the GPLv3 License",
},
editLink: {
pattern: "https://github.com/rmcrackan/Libation/edit/main/:path",
},
lastUpdated: true,
nav: [
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "Docs", link: "/docs/index" },
{
text: "Download",
link: "https://github.com/rmcrackan/Libation/releases/latest",
},
{
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
],
sidebar: [
{
items: [
{ text: "Overview", link: "/docs/index" },
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "FAQ", link: "/docs/frequently-asked-questions" },
{
text: "Issues & Requests",
link: "https://github.com/rmcrackan/Libation/issues",
},
{ text: "Donate", link: "https://www.paypal.com/paypalme/mcrackan" },
],
},
{
text: "Installation",
collapsed: false,
items: [
{ text: "Linux", link: "/docs/installation/linux" },
{ text: "Mac", link: "/docs/installation/mac" },
{ text: "Docker", link: "/docs/installation/docker" },
],
},
{
text: "Features",
collapsed: false,
items: [
{
text: "Audio File Formats",
link: "/docs/features/audio-file-formats",
},
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
{
text: "Searching & Filtering",
link: "/docs/features/searching-and-filtering",
},
],
},
{
text: "Advanced",
collapsed: false,
items: [{ text: "Advanced Topics", link: "/docs/advanced/advanced" }],
},
{
text: "Development",
collapsed: false,
items: [
{
text: "Getting Started",
link: "/docs/development/getting-started",
},
{ text: "Contribute", link: "/docs/development/contribute" },
{ text: "Website & Docs", link: "/docs/development/website" },
{ text: "Linux Setup (Nix)", link: "/docs/development/nix-linux-setup" },
],
},
],
outline: {
level: "deep",
},
socialLinks: [
{ icon: "github", link: "https://github.com/rmcrackan/Libation" },
],
search: {
provider: "local",
},
},
});

View File

@@ -0,0 +1,15 @@
/* Custom styles for Libation documentation */
/* 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 */
/* Hide "Issues & Requests" and "Donate" links on tablet */
.VPNav .VPNavBar .nav .VPNavBarMenu .VPMenu:nth-child(3) {
display: none;
}
/* Alternative: Use a more specific selector if needed */
.VPNavBarMenuLink[href*="issues"] {
display: none;
}
}

View File

@@ -0,0 +1,4 @@
import DefaultTheme from 'vitepress/theme'
import './custom.css'
export default DefaultTheme

5
Directory.Build.props Normal file
View File

@@ -0,0 +1,5 @@
<Project>
<PropertyGroup>
<EnableMSTestRunner>true</EnableMSTestRunner>
</PropertyGroup>
</Project>

View File

@@ -1,164 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Advanced: Table of Contents
- [Files and folders](#files-and-folders)
- [Settings](#settings)
- [Custom File Naming](NamingTemplates.md)
- [Custom Theme Colors](#custom-theme-colors) (Chardonnay Only)
- [Command Line Interface](#command-line-interface)
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](AudioFileFormats.md)
### Files and folders
To make upgrades and reinstalls easier, Libation separates all of its responsibilities to a few different folders. If you don't want to mess with this stuff: ignore it. Read on if you like a little more control over your files.
* In Libation's initial folder are the files that make up the program. Since nothing else is here, just copy new files here to upgrade the program. Delete this folder to delete Libation.
* In a separate folder, Libation keeps track of all of the files it creates like settings and downloaded images. After an upgrade, Libation might think that's its being run for the first time. Just click ADVANCED SETUP and point to this folder. Libation will reload your library and settings.
* The last important folder is the "books location." This is where Libation looks for your downloaded and decrypted books. This is how it knows which books still need to be downloaded. The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Settings
* Allow Libation to fix up audiobook metadata. After decrypting a title, Libation attempts to fix details like chapters and cover art. Some power users and/or control freaks prefer to manage this themselves. By unchecking this setting, Libation will only decrypt the book and will leave metadata as-is, warts and all.
In addition to the options that are enabled if you allow Libation to "fix up" the audiobook, it does the following:
* Adds the `TCOM` (`@wrt` in M4B files) metadata tag for the narrators.
* Sets the `©gen` metadata tag for the genres.
* Unescapes the copyright symbol (replace `&#169;` with `©`)
* Replaces the recording copyright `(P)` string with `℗`
* Replaces the chapter markers embedded in the aax file with the chapter markers retrieved from Audible's API.
* Sets the embedded cover art image with the 500x500 px cover art retrieved from Audible
### Custom Theme Colors
In Libation Chardonnay (not Classic), you may adjust the app colors using the built-in theme editor. Open the Settings window (from the menu bar: Settings > Settings). On the "Important" settings tab, click "Edit Theme Colors".
#### Theme Editor Window
The theme editor has a list of style names and their currently assigned colors. To change a style color, click on the color swatch in the left-hand column to open the color editor for that style. Observe the color changes in real-time on the built-in preview panel on the right-hand side of the theme editor.
You may import or export themes using the buttons at the bottom-left of the theme editor.
"Cancel" or closing the window will revert any changes you've made in the theme editor.
"Reset" will reset any changes you've made in the theme editor.
"Defaults" will restore the application default colors for the active theme ("Light" or "Dark")
"Save" will save the theme colors to the ChardonnayTheme.json file and close the editor.
Note: you may only edit the currently applied theme ("Light" or "Dark").
#### Video Walkthrough
The below video demonstrates using the theme editor to make changes to the Dark theme color pallet.
[](https://github.com/user-attachments/assets/05c0cb7f-578f-4465-9691-77d694111349)
### Command Line Interface
Libationcli.exe allows limited access to Libation's functionalities as a CLI.
Warnings about relying solely on on the CLI:
* CLI will not perform any upgrades.
* It will show that there is an upgrade, but that will likely scroll by too fast to notice.
* It will not perform all post-upgrade migrations. Some migrations are only be possible by launching GUI.
#### Help
```console
libationcli --help
```
#### Verb-Specific Help
```console
libationcli scan --help
```
#### Scan All Libraries
```console
libationcli scan
```
#### Scan Only Libraries for Specific Accounts
```console
libationcli scan nickname1 nickname2
```
#### Convert All m4b Files to mp3
```console
libationcli convert
```
#### Liberate All Books and Pdfs
```console
libationcli liberate
```
#### Liberate Pdfs Only
```console
libationcli liberate --pdf
libationcli liberate -p
```
#### Force Book(s) to Re-Liberate
```console
libationcli liberate --force
libationcli liberate -f
```
#### Liberate using a license file from the `get-license` command
```console
libationcli liberate --license /path/to/license.lic
libationcli liberate --license - < /path/to/license.lic
```
#### List Libation Settings
```console
libationcli get-setting
libationcli get-setting -b
libationcli get-setting FileDownloadQuality
```
#### Override Libation Settings for the Command
```console
libationcli liberate B017V4IM1G -override FileDownloadQuality=Normal
libationcli liberate B017V4IM1G -o FileDownloadQuality=normal -o UseWidevine=true Request_xHE_AAC=true -f
```
#### Copy the Local SQLite Database to Postgres
```console
libationcli copydb --connectionString "my postgres connection string"
libationcli copydb -c "my postgres connection string"
```
#### Export Library to File
```console
libationcli export --path "C:\foo\bar\my.json" --json
libationcli export -p "C:\foo\bar\my.json" -j
libationcli export -p "C:\foo\bar\my.csv" --csv
libationcli export -p "C:\foo\bar\my.csv" -c
libationcli export -p "C:\foo\bar\my.xlsx" --xlsx
libationcli export -p "C:\foo\bar\my.xlsx" -x
```
#### Set Download Status
Set download statuses throughout library based on whether each book's audio file can be found.
Must include at least one flag: --downloaded , --not-downloaded.
Downloaded: If the audio file can be found, set download status to 'Downloaded'.
Not Downloaded: If the audio file cannot be found, set download status to 'Not Downloaded'
UI: Visible Books \> Set 'Downloaded' status automatically. Visible books. Prompts before saving changes
CLI: Full library. No prompt
```console
libationcli set-status -d
libationcli set-status -n
libationcli set-status -d -n
```
#### Get a Content License Without Downloading
```console
libationcli get-license B017V4IM1G
```
#### Example Powershell Script to Download Four Differenf Versions f the Same Book
```powershell
$asin="B017V4IM1G"
$xHE_AAC=@('true', 'false')
$Qualities=@('Normal', 'High')
foreach($q in $Qualities){
foreach($x in $xHE_AAC){
$license = ./libationcli get-license $asin --override FileDownloadQuality=$q --override Request_xHE_AAC=$x
echo $($license | ConvertFrom-Json).ContentMetadata.content_reference
echo $license | ./libationcli liberate --force --license -
}
}
```
# This page has been moved to https://getlibation.com/docs/advanced/advanced

View File

@@ -1,104 +1 @@
# Audio Formats Produced by Libation
Libation will download audio in a number of different audio formats, depending on the settings you choose within Libation and the per-title availability of audio formats from Audible. The Libation settings which affect the format downloaded by Libation are shown in the Settings menu screenshot below.
Notes:
- Audiobook file extensions are either `.m4b` or `.mp3`. Libation uses the `.m4b` file extension for all non-MP3 files, regardless of the audio codec contained therein. Some media players don't recognize the `.m4b` file extension and may require the extension be changed to `.m4a` or `.mp4`.
- Most (but not all) podcasts are delivered by Audible as native MP3 files. None of the following audio formats and settings discussions pertain to those podcasts because MP3s have no DRM, and those episodes are copied directly to their output folders.
![Audio format settings menu](images/AudioFormatSettings.png)
## Settings Summary
### Audio quality to request from Audible
Audiobooks can be requested from Audible as "Normal" quality or "High" quality, matching the settings in the Audible mobile apps. This setting affects the audio bitrate and, sometimes, the number of audio channels. This setting has no effect on the _audio codec_.
### Use Widevine DRM
When this setting is disabled, all audiobooks will be downloaded using Audible's in-house DRM (AAX(C)) in the [AAC-LC](#aac-lc) format.
When this setting is enabled, Libation will request audio files protected by Google's Widevine Digital Rights Managements scheme, and two additional settings will be unlocked: [Request xHE-AAC Codec](#request-xhe-aac-codec) and [Request Spatial Audio](#request-spatial-audio) (explained further below).
If you don't enable either of those additional options, then enabling 'Use Widevine DRM' will have no pratcical effect in nearly all circumstances. Audiobooks will be downloaded in the same [AAC-LC](#aac-lc) format with the same bitrate and the same number of audio channels. On rare occasions, enabling 'Use Widevine DRM' without the other two options will result in audio files with a different bitrate.
### Request xHE-AAC Codec
Enable this setting to request audiobooks in the [xHE-AAC](#xhe-aac) format. This codec is generally better quality than the [AAC-LC](#aac-lc) codec at the same bitrate, but it isn't as commonly supported by media players, so you may have some difficulty playing these audiobooks. The highest bitrate version of some audiobooks is only available as [xHE-AAC](#xhe-aac).
### Request Spatial Audio
Enable this setting to request audiobooks in a "spatial" ([Dolby Atmos](#dolby-atmos)) audio format. If an audiobook is not available in a spatial format, it will instead be downloaded in the [xHE-AAC codec](#xhe-aac).
### Spatial audio codec
Choose whether spatial audiobooks are downloaded in the [E-AC-3](#e-ac-3) or [AC-4](#ac-4) format.
### Download my books in the original audio format (Lossless)
If selected, Audiobooks will be downloaded and saved in the format delivered by audible (which depends on the settings explained above). Libation will not change the audio.
### Download my books as .MP3 files (transcode if necessary).
If selected, Libation will decode [AAC-LC](#aac-lc), [xHE-AAC](#xhe-aac), and [E-AC-3](#e-ac-3) audiobooks and re-encode them as MP3s using the MP3 encoder settings ([read about LAME MP3 encoder settings](https://lame.sourceforge.io/lame_ui_example.php)). Note that Libation cannot convert [AC-4](#ac-4) audio to MP3.
# Audio Formats
## Traditional Mono and Stereo Formats
### AAC-LC
#### _Full Name_
Advanced Audio Coding - Low Complexity
#### _Description_
This is the base profile for AAC audio and has existed since AAC's initial release in 1997. It enjoys wide support on nearly every conceivable platform capable of playing digital audio, as ubiquitous as MP3.
If Widevine support is not enabled, or if the book is not available in the more high-definition formats, Libation will download audiobooks in this format.
### MP3
#### _Full Name_
MPEG-1 Audio Layer III or MPEG-2 Audio Layer III
#### _Description_
An older (released in 1991) but still nearly universally supported audio codec. Its audio quality is generally worse than AAC-LC at similar bitrates. Audible delivers some podcasts in MP3 format, but no audiobooks are natively availble as MP3. Libation supports converting Audiobooks delivered in other audio formats to MP3. Note that the MP3 format supports a maximum of two audio channels, so multichannel E-AC-3 audio will be downsampled to stereo or mono (depending on the Libation's settings). [AC-4](#ac-4) cannot be converted to MP3.
### xHE-AAC
#### _Full Name_
Extended High-Efficiency Advanced Audio Coding
#### _Description_
This is a proprietary codec created by the [Fraunhofer Institute for Integrated Circuits IIS](https://www.iis.fraunhofer.de/en/ff/amm/broadcast-streaming/xheaac.html). It combines features of the HE-AAC v2 and the baseline USAC (Unified Speech and Audio Coding) profiles with the parts of the MPEG-D DRC Loudness Control Profile or Dynamic Range Control Profile. Therefore, USAC and xHE-AAC are not synonymous and should not be used interchangeably. A player capable of decoding USAC will not necessarily be able to decode xHE-AAC.
xHE-AAC boasts significantly higher quality audio at low bitrates. Though it has existed since at least 2016, playback support is still quite limited. FFmpeg has recently added partial decoder support for the USAC profiles, but it is insufficient to decode the xHE-AAC audio files acquired from Audible (due to FFmpeg's lack of support for MPEG Surround for Mono to Stereo Upmixing; ISO 23003-3:2012 §7.11)
Note that the xHE-AAC files authored by Audible have some USAC conformance errors including:
- Number of samples per frame not matching the UsacConfig coreCoderFrameLength value.
- Disagreement between stts and UsacFrame usacIndependencyFlag value.
- Stts indicating a frame is an immediate play-out frame, but USAC AudioPreRoll is absent.
## Dolby Atmos
Atmos is a surround sound technology that expands on existing surround sound systems by adding height channels as well as free-moving sound objects. Audible delivers Dolby Atmos in two formats: E-AC-3 and AC-4.
Your device's ability to play audio from these formats does not necessarily mean that the audio you are hearing is Atmos (spatial). For instance, downloading the AC-4 codec for Windows ([links in the [Supported media Players](#supported-media-players) section) will enable you to play AC-4 audiobooks, but you'll still need to download [Dolby Access](https://apps.microsoft.com/detail/9n0866fs04w8?hl=en-US&gl=US) and pay $15 to enable _Dolby Atmos For Headphones_. Please refer to [this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524) for additional context.
### E-AC-3
#### _Full Name_
Dolby Digital Plus (a.k.a Enhanced AC-3, DDP, DD+, and EC-3)
#### _Description_
A proprietary digital audio compression scheme developed by Dolby Digital for the transport and storage of multichannel audio. This format can be extended to add support for Atmos, making the codec _Dolby Digital Plus Atmos_. _Dolby Digital Plus Atmos_ is backwards compatible with Dolby Digital Plus, so any media player capable of playing Dolby Digital Plus can play _Dolby Digital Plus Atmos_. Audible spatial audiobooks downloaded in the E-AC-3 format are _Dolby Digital Plus Atmos_. If they are played by a media player that supports Atmos, they will play as Atmos audio. If they are played by a media player that does not support Atmos, they will be played as traditional 5.1 surround audio.
### AC-4
#### _Full Name_
Dolby AC-4
#### _Description_
A proprietary audio compression technology developed by Dolby Digital for the transport and storage of audio channels and/or audio objects. Audible spatial audiobooks downloaded in the AC-4 format are 2-channel AC-4 Immersive Stereo (AC4-IMS) audio, intended for playback in headphones or earbuds (though apparently [not supported on Apple devices](https://github.com/rmcrackan/Libation/issues/996#issuecomment-3169574514)).
# Supported Media Players
Below is an incomplete matrix of codec support across various media players and platforms.
| Player | [AAC-LC](#aac-lc) | [xHE-AAC](#xhe-aac) | [E-AC-3](#e-ac-3) | [AC-4](#ac-4) |
| :--- | :---: | :---: | :---: | :---: |
|Windows Native Support|Yes|Yes<sup>1</sup>|Yes<sup>2,3</sup>|Yes<sup>4</sup>|
|macOS Native Support|Yes|Yes|Yes<sup>3</sup>| |
|Android Native Support<sup>5</sup>|Yes|Yes| | |
|FFmpeg (all platforms)|Yes|Yes<sup>6</sup>|Yes<sup>3</sup>||
|[VLC](https://www.videolan.org/vlc/) (Windows)|Yes| |Yes<sup>3</sup> | |
|[foobar2000](https://www.foobar2000.org/components) (Windows and Mac)|Yes|Yes<sup>7</sup> | | |
|[PotPlayer](https://potplayer.daum.net/) (Windows)|Yes|Yes|Yes<sup>3</sup>| |
|[Samsung Media Player](https://play.google.com/store/apps/details?id=com.sec.android.app.music)<sup>8</sup> (Samsung devices) |Yes|Yes|Yes|Yes|
1. Windows 11 22H2 and later
2. On Windows [prior to Windows 11, version 24H2](https://support.microsoft.com/en-us/windows/codecs-in-media-player-d5c2cdcd-83a2-4805-abb0-c6888138e456). You can still get the codec by running the following command from a Windows PowerShell console: `winget install --id 9nvjqjbdkn97`
3. As mentioned in the [Dolby Atmos](#dolby-atmos) section, just because a media player can play a file does not mean it's rendering Atmos. _Dolby Digital Plus Atmos_ is backwards compatible with _Dolby Digital Plus_, so media players which only support _Dolby Digital Plus_ will play E-AC-3 audio files as regular 5.1 surround without rendering the Atmos spatial qualities. Additional software or hardware support may be required for Dolby Atmos playback.
4. You can download the AC-4 codec for Windows from 3rd party sites like [Major Geeks](https://www.majorgeeks.com/files/details/dolby_ac_3ac_4_installer.html) and [Free-Codecs](https://www.free-codecs.com/dolby-ac-4-decoder_download.htm). Once you install the codec bundle from one of those sources, the Windows store app will keep it updated. Read more about the process [in this comment](https://github.com/rmcrackan/Libation/pull/1331#discussion_r2268660524).
5. All Android devices will support AAC-LC and xHE-AAC. Some manufactures (such as Samsung) will include Dolby codecs for playing E-AC-3 and AC-4 audio.
6. requires FFmpeg to be [built with fdk-aac](https://trac.ffmpeg.org/wiki/Encode/AAC#fdk_aac). You will almost certainly not find pre-build binaries in the wild due to licensing restrictions.
7. Requires the [fdk-aac plugin](https://www.foobar2000.org/components/view/foo_pd_aac) (Windows only)
8. Requires audio file extensions to be `.m4a` or `.mp4`. Libation sets the file extensions to `.m4b`, so you must manually change it to `.m4a` by renaming the audio file.
# This page has been moved to https://getlibation.com/docs/features/audio-file-formats

View File

@@ -1,76 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
> [!WARNING]
> ## Breaking Changes
> * The docker image now runs as user 1001 and group 1001. Make sure that the permissions on your volumes allow user 1001 to read and write to them or see the User section below for other options, or if you're not sure.
> * `SLEEP_TIME` is now set to `-1` by default. This means the image will run once and exit. If you were relying on the previous default, you'll need to explicitly set the `SLEEP_TIME` environment variable to `30m` to replicate the previous behavior.
> * The docker image now ignores the values in `Settings.json` for `Books` and `InProgress`. You can now change the folder that books are saved to by using the `LIBATION_BOOKS_DIR` environment variable.
# Disclaimer
The docker image is provided as-is. We hope it can be useful to you but it is not officially supported.
### Configuration
Configuration in Libation is handled by two files, `AccountsSettings.json` and `Settings.json`. These files can usually be found in the Libation folder in your user's home directory. The easiest way to configure these is to run the desktop version of Libation and then copy them into a folder, such as `/opt/libation/config`, that you'll volume mount into the image. `Settings.json` is technically optional, and, if not provided, Libation will run using the default settings. Additionally, the `Books` and `InProgress` settings in `Settings.json` will be ignored and the image will instead substitute it's own values.
### Running
Once the configuration files are copied, the docker image can be run with the following command.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
--name libation \
--restart=always \
rmcrackan/libation:latest
```
By default the container will scan for new books once and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. For example, if you pass in `10m` it will keep running, scan for new books, and download them every 10 minutes.
```
sudo docker run -d \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
-e SLEEP_TIME='10m' \
--name libation \
--restart=always \
rmcrackan/libation:latest
```
### Environment Variables
| Env Var | Default | Description |
| -------- | ------- | ----------- |
| SLEEP_TIME | -1 | Length of time to sleep before doing another scan/download. Set to -1 to run one. |
| LIBATION_BOOKS_DIR | /data | Folder where books will be saved |
| LIBATION_CONFIG_DIR | /config | Folder to read configuration from. |
| LIBATION_DB_DIR | /db | Optional folder to load database from. If not mounted, will load database from `LIBATION_CONFIG_DIR`. |
| LIBATION_DB_FILE | | Name of database file to load. By default it will look for all `.db` files and load one if there is only one present. |
| LIBATION_CREATE_DB | true | Whether or not the image should create a database file if none are found. |
### User
This docker image runs as user `1001`. In order for the image to function properly, user `1001` must be able to read and write the volumes that are mounted in. If they are not, you will see errors, including [sqlite error](#1060), [Microsoft.Data.Sqlite.SqliteException](#1110), [unable to open database file](#1113), [Microsoft.EntityFrameworkCore.DbUpdateException](#1049)
If you're not sure what your user number is, check the output of the `id` command. Docker should normally run with the number of the user who configured and ran it.
If you want to change the user the image runs as, you can specify `-u <uid>:<gid>`. For example, to run it as user `2000` and group `3000`, you could do the following:
```
sudo docker run -d \
-u 2000:3000 \
-v /opt/libation/config:/config \
-v /opt/libation/books:/data \
--name libation \
--restart=always \
rmcrackan/libation:latest
```
If the user it's running as is correct, and it still cannot write, be sure to check whether the files and/or folders might be owned by the wrong user. You can use the `chown` command to change the owner of the file to the correct user and group number, for example: `chown -R 1001:1001 /mnt/audiobooks /mnt/libation-config`
### Advanced Database Options
The docker image supports an optional database mount location defined by `LIBATION_DB_DIR`. This allows the database to be mounted as read/write, while allowing the rest of the configuration files to be mounted as read only. This is specifically useful if running in Kubernetes where you can use Configmaps and Secrets to define the configuration. If the `LIBATION_DB_DIR` is mounted, it will be used, otherwise it will look for the database in `LIBATION_CONFIG_DIR`. If it does not find the database in the expected location, it will attempt to make an empty database there.
### Getting help
As mentioned above: docker is not officially supported. I'm adding this at the bottom of the page for anyone serious enough to have read this far. If you've tried everything above and would still like help, you can open an [issue](https://github.com/rmcrackan/Libation/issues). Please include `[docker]` in the title. There are also some docker folks who have offered occasional assistance who you can tag within your issue: `@ducamagnifico` , `@wtanksleyjr` , `@CLHatch`.
**Reminder** that these are just friendly users who are sometimes around. They're *not* our customer support.
# This page has been moved to https://getlibation.com/docs/installation/docker

View File

@@ -1,56 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Frequently Asked Questions
## Q: Where can I get help for my specific problem?
**A:** [You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
## Q: What's the difference between 'Classic' and 'Chardonnay'?
**A:** First and most importantly: Classic and Chardonnay have the exact same features.
* **Classic** is Windows only. Its older 'grey boxes' look has a compact design which allows for more information on the screen. Notably, Classic was written using an older, more mature technology which has built-in support for screenreaders.
* **Chardonnay** is available for Windows, Mac, and Linux. Its modern design has a more open look and feel.
## Q: Now that I've downloaded my books, how can I listen to them?
**A:** You can use any app which plays m4b files (or mp3 files if you used that setting). Here are just a few ideas. Disclaimer: I have no affiliation with any of these companies:
* iOS: [BookPlayer](https://apps.apple.com/us/app/bookplayer/id1138219998)
* iOS: [Bound](https://apps.apple.com/us/app/bound-audiobook-player/id1041727137)
* Android: [Smart AudioBook Player](https://play.google.com/store/apps/details?id=ak.alizandro.smartaudiobookplayer&hl=en_US&gl=US)
* Android: [Listen](https://play.google.com/store/apps/details?id=ru.litres.android.audio&hl=en_US&gl=US)
* Desktop: [VLC](https://www.videolan.org/)
* Windows Desktop: [Audibly](https://github.com/rstewa/Audibly) -- a desktop player build specifically for audiobooks
Self-hosting online:
* [audiobookshelf](https://www.audiobookshelf.org). On [reddit](https://www.reddit.com/r/audiobookshelf/)
* [plex](https://www.plex.tv/). Listen with [Prologue](https://prologue.audio/) (iOS)
## Q: I'm having trouble playing my non-spatial audiobook, how can I fix this?
**A:** If you enabled the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings, then the audiobook is being downloaded in the [xHE-AAC codec](AudioFileFormats.md#xhe-aac) which isn't widely supported. You have two options:
1. Use a media player which supports the xHE-AAC codec. [See an incomplete list of media players which support xHE-AAC](AudioFileFormats.md#supported-media-players).
2. Disable the [Request xHE-AAC Codec](AudioFileFormats.md#request-xhe-aac-codec) option in settings and re-download the audiobook. This will cause Libation to download audiobooks in the [AAC-LC codec](AudioFileFormats.md#aac-lc), which enjoys near-universal media player support.
## Q: I'm having trouble playing my book with 4D, spatial audio, or Dolby Atmos, how can I fix this?
**A:** Spatial audiobooks are delivered in two formats: [E-AC-3](AudioFileFormats.md#e-ac-3) and [AC-4](AudioFileFormats.md#ac-4). [See an incomplete list of media players which support those codecs](AudioFileFormats.md#supported-media-players).
## Q: I'm having trouble loggin into my Brazil account.
**A:** For reasons known only to Jeff Bezos and God, amazon and audible brazil handle logins slightly differently. The external browser login option is not possible for Brazil. [See this ticket for more details.](https://github.com/rmcrackan/Libation/issues/1103)
## Q: How do I use Libation with a South Africa account?
**A:** Like many countries, amazon gives South Africa it's own amazon site. [Unlike many other regions](https://www.audible.com/ep/country-selector) there is not South Africa specific audible site. Use `US` for your region -- ie: audible.com.
(Not exactly a *frequently* asked question but it's come up more than once)
# This page has been moved to https://getlibation.com/docs/frequently-asked-questions

View File

@@ -1,155 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Getting started: Table of Contents
- [Download Libation](#download-libation-1)
- [Installation](#installation)
- [Create Accounts](#create-accounts)
- [Import your library](#import-your-library)
- [Download your books -- DRM-free!](#download-your-books----drm-free)
- [Download PDF attachments](#download-pdf-attachments)
- [Details of downloaded files](#details-of-downloaded-files)
- [Export your library](#export-your-library)
- [I still need help](#i-still-need-help)
### [Download Libation](https://github.com/rmcrackan/Libation/releases)
##### Which version? Chardonnay vs Classic
Nearly 100% of the difference is look and feel -- it's a matter of preference.
Chardonnay has an updated look and will work and look the same on Windows, Mac, and Linux.
Classic is Windows only. It has an older look because it's built with older, duller, and more mature technology. This tech has built into it better support for things like accessibility for screen readers.
### Installation
* Windows
Extract the zip file to a folder and then run `Libation.exe` from inside of that folder. Do not put it in Program Files. The inability to edit files from there causes problems with configuration and updating.
* [Linux](InstallOnLinux.md)
* [MacOS](InstallOnMac.md)
### Create Accounts
Create your account(s):
![Create your accounts, menu](images/v40_accounts.png)
New locale options include many more regions including old audible accounts which pre-date the amazon acquisition
![Choose your account locales](images/v40_locales.png)
### Import your library
Be default, Libation will periodically scan the accounts you added above with a checkbox next to them. Nothing for you to do. You can also scan manually.
Select Import > Scan Library:
![Import step 1](images/Import1.png)
Or if you have multiple accounts, you'll get to choose whether to scan all accounts or just the ones you select:
![Import which accounts](images/v40_import.png)
If this is a new installation, or you're scanning an account you haven't scanned before, you'll be prompted to enter your password for the Audible account.
![Login password](images/alt-login1.png)
Enter the password and click Submit. Audible will prompt you with a CAPTCHA image.
![Login captcha](images/alt-login2.png)
Enter the CAPTCHA answer characters and click Submit. If all has gone well, Libation will start scanning the account.
In rare instances, the Captcha image/response will fail in an endless loop. If this happens, delete the problem account, and then click Save. Re-add the account and click Save again. Now try to scan the account again. This time, instead of typing your password, click the link that says "Or click here". This will open the Audible External Login dialog shown below.
![Login alternative setup](images/alt-login3.png)
You can either copy the URL shown and paste it into your browser or launch the browser directly by clicking Launch in Browser. Audible will display its standard login page. Login, including answering the CAPTCHA on the next page. In some cases, you might have to approve the login from the email account associated with that login, but once the login is successful, you'll see an error message.
![Login alternative login result](images/alt-login4.png)
This actually means you've successfully logged in. Copy the entire URL shown in your browser and return to Libation. Paste that URL into the text box at the bottom of the Audible External Login window and click Submit.
You'll see this window while it's scanning:
![Import step 2](images/Import2.png)
Success! We see how many new titles are imported:
![Import step 3](images/Import3.png)
### Download your books -- DRM-free!
Automatically download some or all of your audible books. This shows you how much of your library is not yet downloaded and decrypted:
The stoplights will tell you a title's status:
* Green: downloaded and decrypted
* Yellow: downloaded but still encrypted with DRM
* Red: not downloaded
* PDF icon without arrow: downloaded
* PDF with arrow: not downloaded
Or hover over the button to see the status.
![Liberate book step 1](images/LiberateBook1.png)
Select Liberate > Begin Book Backups
You can also click on the stop light to download only that title and its PDF
![Liberate book step 2](images/LiberateBook2.png)
First the original book with DRM is downloaded
![Liberate book step 3](images/LiberateBook3.png)
Then it's decrypted so you can use it on any device you choose. The very first time you decrypt a book, this step will take a while. Every other book will go much faster. The first time, Libation has to figure out the special decryption key which allows your personal books to be unlocked.
![Liberate book step 4](images/LiberateBook4.png)
And voila! If you have multiple books not yet liberated, Libation will automatically move on to the next.
![Liberate book step 5](images/LiberateBook5.png)
The Audible id must be somewhere in the book's file or folder name for Libation to detect your downloaded book.
### Download PDF attachments
For books which include PDF downloads, Libation can download these for you as well and will attempt to store them with the book. "Book backup" will already download an available PDF. This additional option is useful when Audible adds a PDF to your book after you've already backed it up.
Select Liberate > Begin PDF Backups
![PDF download step 2](images/PdfDownload2.png)
The downloads work just like with books, only with no additional decryption needed.
![PDF download step 3](images/PdfDownload3.png)
### Details of downloaded files
![Post download](images/PostDownload.png)
When you set up Libation, you'll specify a Books directory. Libation looks inside that directory and all subdirectories to look for files or folders with each library book's audible id. This way, organization is completely up to you. When you download + decrypt a book, you get several files
* .m4b: your audiobook in m4b format. This is the most pure version of your audiobook and retains the highest quality. Now that it's decrypted, you can play it on any audio player and put it on any device. If you'd like, you can also use 3rd party tools to turn it into an mp3. The freedom to do what you want with your files was the original inspiration for Libation.
* .cue: this is a file which logs where chapter breaks occur. Many tools are able to use this if you want to split your book into files along chapter lines.
### Export your library
![Export](images/Export.png)
Export your library to Excel, CSV, or JSON
### I still need help
[You can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
# This page has been moved to https://getlibation.com/docs/getting-started

View File

@@ -1,67 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Packaging status
[![Packaging status](https://repology.org/badge/vertical-allrepos/libation.svg)](https://repology.org/project/libation/versions)
New Libation releases are automatically packed into `.deb` and `.rpm` package and are available from the [Libation repository's releases page](https://github.com/rmcrackan/Libation/releases).
Run these commands in your terminal to download and install Libation. **Make sure you replace** `X.X.X` with the latest Libation version and `ARCH` with your CPU's architechture (either `amd64` or `arm64`).
### Debian
```Console
wget -O libation.deb https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.deb
sudo apt install ./libation.deb
```
### Redhat and CentOS
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
sudo yum install ./libation.rpm
```
### Fedora
```Console
wget -O libation.rpm https://github.com/rmcrackan/Libation/releases/download/vX.X.X/Libation.X.X.X-linux-chardonnay-ARCH.rpm
sudo dnf5 install ./libation.rpm
```
---
### Arch Linux
```Console
yay -S libation
```
This package is available on [Arch User Repository](https://aur.archlinux.org/packages/libation), install via your choice of [AUR helpers](https://wiki.archlinux.org/title/AUR_helpers).
Thanks to [mhdi](https://aur.archlinux.org/account/mhdi) for taking care of AUR package maintenance.
### NixOS
- Install via `nix-shell`
```Console
nix-shell -p libation
```
A `nix-shell` will temporarily modify your $PATH environment variable. This can be used to try a piece of software before deciding to permanently install it.
- Install via NixOS configuration
```Console
environment.systemPackages = [
pkgs.libation
];
```
Add the following Nix code to your NixOS Configuration, usually located in `/etc/nixos/configuration.nix`
- On NixOS via via `nix-env`
```Console
nix-env -iA nixos.libation
```
- On Non NixOS via `nix-env`
```Console
nix-env -iA nixpkgs.libation
```
Warning: Using `nix-env` permanently modifies a local profile of installed packages. This must be updated and maintained by the user in the same way as with a traditional package manager.
Thanks to [TomaSajt](https://github.com/tomasajt) for taking care of Nix package maintenance.
If your desktop uses gtk, you should now see Libation among your applications.
Additionally, you may launch Libation, LibationCli, and Hangover (the Libation recovery app) via the command line using 'libation, libationcli', and 'hangover' aliases respectively.
Report bugs to https://github.com/rmcrackan/Libation/issues
# This page has been moved to https://getlibation.com/docs/installation/linux

View File

@@ -1,82 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Run Libation on MacOS
This walkthrough should get you up and running with Libation on your Mac.
## Supports macOS 13 (Ventura) and above
## Install Libation
- Download the file from the latest release and extract it.
- Apple Silicon (M1, M2, ...): `Libation.x.x.x-macOS-chardonnay-`**arm64**`.tgz`
- Intel: `Libation.x.x.x-macOS-chardonnay-`**x64**`.tgz`
- Move the extracted Libation app bundle to your applications folder.
- Right-click on Libation and then click on open
- The first time, it will not immediately show you an option to open it. Just dismiss the dialog and do the same thing again (right-click -> open) then you will get an option to run the unsigned application. This takes about 10 seconds.
## If this doesn't work
You can add Libation as a safe app without touching Gatekeeper.
- Copy/paste/run the following command. Adjust the file path to the Libation.app on your computer if necessary.
```Console
xattr -r -d com.apple.quarantine ~/Downloads/Libation.app
```
- Close the terminal and use Libation!
## If this still doesn't work
- Copy/paste/run the following command (you'll be prompted to enter your Mac password)
```Console
sudo spctl --master-disable && sudo spctl --add --label "Libation" /Applications/Libation.app && open /Applications/Libation.app && sudo spctl --master-enable
```
* Close the terminal and use Libation!
## "Apple can't check app for malicious software"
From: [How to Open Anyway](https://support.apple.com/guide/mac-help/apple-cant-check-app-for-malicious-software-mchleab3a043/mac):
* On your Mac, choose Apple menu > System Settings, then click Privacy & Security in the sidebar. (You may need to scroll down.)
* Go to Security, then click Open.
* Click Open Anyway. This button is available for about an hour after you try to open the app.
* Enter your login password, then click OK.
## Troubleshooting
If Libation fails to start after completing the above steps, try the following:
1. Right-click the Libation app in your applications folder and select _Show Package Contents_
2. Open the `Contents` folder and then the `MacOS` folder.
3. Find the file named `Libation`, right-click it, and then select _Open_.
Libation _should_ launch, and you should now be able to open Libation by just double-clicking the app bundle in your applications folder.
## Running Hangover
Libation comes with a recovery app called Hangover. You can start it by running this command:
```Console
open /Applications/Libation.app --args hangover
```
## Running LibationCli
Libation comes with a command-line interface. Unfortunately, due to the way apps are sandboxed on mac, its use is somewhat limited. To open a new sandboxed terminal in LibationCli's directory, run the following command:
```Console
open /Applications/Libation.app --args cli
```
To use LibationCli from an unsandboxed terminal, you must disable gatekeeper again and run the program directly at `/Applications/Libation.app/Contents/MacOS/LibationCli`
Then use `./LibationCli` to execute a command.
## Get Libation running on Mac
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/219271379-a922e4e1-48a0-48e4-bd81-48aa1226a4f5.mp4)
# This page has been moved to https://getlibation.com/docs/installation/mac

View File

@@ -1,64 +1 @@
# Development Environment Setup using Nix or Nix Flakes on Linux x86_64
[Nix flakes](https://nixos.wiki/wiki/Flakes) can be used to provide version controlled reproducible and cross-platform development environments. The key files are:
- `flake.nix`: Defines the flake inputs and outputs, including development shells.
- `shell.nix`: This file defines the dependencies and additionally adds support for the Impure `nix-shell` method. This is used by the flake to create the dev environment.
- `flake.lock`: Locks the versions of inputs for reproducibility.
---
## Prerequisites
- [Nix](https://nixos.org/download.html) the package manager or NixOs installed on Linux (x86_64-linux)
- Optional: flakes support enabled.
---
## Using the Development Shell
You have two primary ways to enter the development shell with Nix:
### 1. Using `nix develop` (flake-native command)
This is the recommended way if you have Nix with flakes support. Flake guarantee the versions of the dependencies and can be controlled through `flake.nix` and `flake.lock`.
```
nix develop
```
This will open a shell with all dependencies and environment configured as per the `flake.nix` for (`x86_64-linux`) systems only at this time.
---
### 2. Using `nix-shell` (that's why shell.nix is a separate file)
If you want to use traditional `nix-shell` tooling which uses the nixpkgs version of your system:
```
nix-shell
```
This will drop you into the shell environment defined in `shell.nix`. Note that this is not flake-native method and does not use the locked nixpkgs in `flake.lock` so exact versions of the dependancies is not guaranteed.
---
## Whats inside the dev shell?
- The environment variables and packages configured in `shell.nix` will be available.
- The package set (`pkgs`) used aligns with the versions locked in `flake.lock` to ensure reproducibility.
---
## Example Workflow using flakes
```
# Navigate to the project root folder which contains the flake.nix, flake.lock and shell.nix files.
cd /home/user/dev/Libation
# Enter the flake development shell (Linux x86_64)
nix develop
# run VSCode or VSCodium from the current shell environment
code .
# Run or Debug using VSCode and VSCodium using the linux Launch configuration.
```
![Debug using VSCode and VSCodium](./images/StartingDebuggingInVSCode.png)
You can also Build and run your application inside the shell.
```
dotnet build ./Source/LibationAvalonia/LibationAvalonia.csproj -p:TargetFrameworks=net9.0 -p:TargetFramework=net9.0 -p:RuntimeIdentifier=linux-x64
```
---
## Notes
- Leaving the current shell environemnt will drop all added dependancies and you will not be able to run or debug the program unless your system has those dependancies defined globally.
- To exit the shell environment voluntarily use `exit` inside the shell.
- Ensure you have no conflicting `nix.conf` or `global.json` that might affect SDK versions or runtime identifiers.
- Keep your `flake.lock` file committed to ensure builds are reproducible for all collaborators.
---
## References
- [Nix Flakes - NixOS Wiki](https://nixos.wiki/wiki/Flakes)
- [Nix.dev - Introduction to Nix flakes](https://nix.dev/manual/nix/2.28/command-ref/new-cli/nix3-flake-init)
- [Nix-shell Manual](https://nixos.org/manual/nix/stable/command-ref/nix-shell.html)
# This page has been moved to https://getlibation.com/docs/development/nix-linux-setup

View File

@@ -1,178 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Naming Templates
File and Folder names can be customized using Libation's built-in tag template naming engine. To edit how folder and file names are created, go to Settings \> Download/Decrypt and edit the naming templates. If you're splitting your audiobook into multiple files by chapter, you can also use a custom template to set each chapter's title metadata tag by editing the template in Settings \> Audio File Options.
These templates apply to both GUI and CLI.
# Table of Contents
- [Template Tags](#template-tags)
- [Property Tags](#property-tags)
- [Conditional Tags](#conditional-tags)
- [Tag Formatters](#tag-formatters)
- [Text Formatters](#text-formatters)
- [Series Formatters](#series-formatters)
- [Series List Formatters](#series-list-formatters)
- [Name Formatters](#name-formatters)
- [Name List Formatters](#name-list-formatters)
- [Number Formatters](#number-formatters)
- [Date Formatters](#date-formatters)
# Template Tags
These are the naming template tags currently supported by Libation.
## Property Tags
These tags will be replaced in the template with the audiobook's values.
|Tag|Description|Type|
|-|-|-|
|\<id\> **†**|Audible book ID (ASIN)|Text|
|\<title\>|Full title with subtitle|[Text](#text-formatters)|
|\<title short\>|Title. Stop at first colon|[Text](#text-formatters)|
|\<audible title\>|Audible's title (does not include subtitle)|[Text](#text-formatters)|
|\<audible subtitle\>|Audible's subtitle|[Text](#text-formatters)|
|\<author\>|Author(s)|[Name List](#name-list-formatters)|
|\<first author\>|First author|[Name](#name-formatters)|
|\<narrator\>|Narrator(s)|[Name List](#name-list-formatters)|
|\<first narrator\>|First narrator|[Name](#name-formatters)|
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|\<first series\>|First series|[Series](#series-formatters)|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|\<locale\>|Region/country|[Text](#text-formatters)|
|\<year\>|Year published|[Number](#number-formatters)|
|\<language\>|Book's language|[Text](#text-formatters)|
|\<language short\> **†**|Book's language abbreviated. Eg: ENG|Text|
|\<file date\>|File creation date/time.|[DateTime](#date-formatters)|
|\<pub date\>|Audiobook publication date|[DateTime](#date-formatters)|
|\<date added\>|Date the book added to your Audible account|[DateTime](#date-formatters)|
|\<ch count\> **‡**|Number of chapters|[Number](#number-formatters)|
|\<ch title\> **‡**|Chapter title|[Text](#text-formatters)|
|\<ch#\> **‡**|Chapter number|[Number](#number-formatters)|
|\<ch# 0\> **‡**|Chapter number with leading zeros|[Number](#number-formatters)|
**†** Does not support custom formatting
**‡** Only valid for Chapter Filename and Chapter Tile Metadata
To change how these properties are displayed, [read about custom formatters](#tag-formatters)
## Conditional Tags
Anything between the opening tag (`<tagname->`) and closing tag (`<-tagname>`) will only appear in the name if the condition evaluates to true.
|Tag|Description|Type|
|-|-|-|
|\<if series-\>...\<-if series\>|Only include if part of a book series or podcast|Conditional|
|\<if podcast-\>...\<-if podcast\>|Only include if part of a podcast|Conditional|
|\<if bookseries-\>...\<-if bookseries\>|Only include if part of a book series|Conditional|
|\<if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is a podcast series parent|Conditional|
|\<has PROPERTY-\>...\<-has\>|Only include if the PROPERTY has a value (i.e. not null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
For example, `<if podcast-><series><-if podcast>` will evaluate to the podcast's series name if the file is a podcast. For audiobooks that are not podcasts, that tag will be blank.
You can invert the condition (instead of displaying the text when the condition is true, display the text when it is false) by playing a `!` symbol before the opening tag name.
|Inverted Tag|Description|Type|
|-|-|-|
|\<!if series-\>...\<-if series\>|Only include if *not* part of a book series or podcast|Conditional|
|\<!if podcast-\>...\<-if podcast\>|Only include if *not* part of a podcast|Conditional|
|\<!if bookseries-\>...\<-if bookseries\>|Only include if *not* part of a book series|Conditional|
|\<!if podcastparent-\>...\<-if podcastparent\>**†**|Only include if item is *not* a podcast series parent|Conditional|
|\<!has PROPERTY-\>...\<-has\>|Only include if the PROPERTY *does not* have a value (i.e. is null or empty)|Conditional|
**†** Only affects the podcast series folder naming if "Save all podcast episodes to the series parent folder" option is checked.
As an example, this folder template will place all Liberated podcasts into a "Podcasts" folder and all liberated books (not podcasts) into a "Books" folder.
`<if podcast->Podcasts<-if podcast><!if podcast->Books<-if podcast>\<title>`
This example will add a number if the `<series#\>` tag has a value:
`<has series#><series#><-has>`
This example will put non-series books in a "Standalones" folder:
`<!if series->Standalones/<-if series>`
And this example will customize the title based on whether the book has a subtitle:
`<audible title><has audible subtitle->-<audible subtitle><-has>`
# Tag Formatters
**Text**, **Name List**, **Number**, and **DateTime** tags can be optionally formatted using format text in square brackets after the tag name. Below is a list of supported formatters for each tag type.
## Text Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|L|Converts text to lowercase|\<title[L]\>|a study in scarlet a sherlock holmes novel|
|U|Converts text to uppercase|\<title short[U]\>|A STUDY IN SCARLET|
## Series Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{N \| # \| ID\}|Formats the series using<br>the series part tags.<br>\{N\} = Series Name<br>\{#\} = Number order in series<br>\{#:[Number_Formatter](#number-formatters)\} = Number order in series, formatted<br>\{ID\} = Audible Series ID<br><br>Default is \{N\}|`<first series>`<hr>`<first series[{N}]>`<hr>`<first series[{N}, {#}, {ID}]>`<hr>`<first series[{N}, {ID}, {#:00.0}]>`|Sherlock Holmes<hr>Sherlock Holmes<hr>Sherlock Holmes, 1-6, B08376S3R2<hr>Sherlock Holmes, B08376S3R2, 01.0-06.0|
## Series List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join<br>multiple series names.<br><br>Default is ", "|`<series[separator(; )]>`|Sherlock Holmes; Some Other Series|
|format(\{N \| # \| ID\})|Formats the series properties<br>using the name series tags.<br>See [Series Formatter Usage](#series-formatters) above.|`<series[format({N}, {#})`<br>`separator(; )]>`<hr>`<series[format({ID}-{N}, {#:00.0})]>`|Sherlock Holmes, 1-6; Book Collection, 1<hr>B08376S3R2-Sherlock Holmes, 01.0-06.0, B000000000-Book Collection, 01.0|
|max(#)|Only use the first # of series<br><br>Default is all series|`<series[max(1)]>`|Sherlock Holmes|
## Name Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\{T \| F \| M \| L \| S \| ID\}|Formats the human name using<br>the name part tags.<br>\{T\} = Title (e.g. "Dr.")<br>\{F\} = First name<br>\{M\} = Middle name<br>\{L\} = Last Name<br>\{S\} = Suffix (e.g. "PhD")<br>\{ID\} = Audible Contributor ID<br><br>Default is \{P\} \{F\} \{M\} \{L\} \{S\}|`<first narrator[{L}, {F}]>`<hr>`<first author[{L}, {F} _{ID}_]>`|Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
## Name List Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|separator()|Speficy the text used to join<br>multiple people's names.<br><br>Default is ", "|`<author[separator(; )]>`|Arthur Conan Doyle; Stephen Fry|
|format(\{T \| F \| M \| L \| S \| ID\})|Formats the human name using<br>the name part tags.<br>See [Name Formatter Usage](#name-formatters) above.|`<author[format({L}, {F})`<br>`separator(; )]>`<hr>`<author[format({L}, {F}`<br>`_{ID}_) separator(; )]>`|Doyle, Arthur; Fry, Stephen<hr>Doyle, Arthur \_B000AQ43GQ\_;<br>Fry, Stephen \_B000APAGVS\_|
|sort(F \| M \| L)|Sorts the names by first, middle,<br>or last name<br><br>Default is unsorted|`<author[sort(M)]>`|Stephen Fry, Arthur Conan Doyle|
|max(#)|Only use the first # of names<br><br>Default is all names|`<author[max(1)]>`|Arthur Conan Doyle|
## Number Formatters
For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-numeric-format-strings).
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|\[integer\]|Zero-pads the number|\<bitrate\[4\]\><br>\<series#\[3\]\><br>\<samplerate\[6\]\>|0128<br>001<br>044100|
|0|Replaces the zero with the corresponding digit if one<br>is present; otherwise, zero appears in the result string.|\<series#\[000.0\]\>|001.0|
|#|Replaces the "#" symbol with the corresponding digit if one<br> is present; otherwise, no digit appears in the result string|\<series#\[00.##\]\>|01|
## Date Formatters
Form more standard formatters, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/standard-date-and-time-format-strings).
### Standard DateTime Formatters
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|s|Sortable date/time pattern.|\<file date[s]\>|2023-02-14T13:45:30|
|Y|Year month pattern.|\<file date[Y]\>|February 2023|
### Custom DateTime Formatters
You can use custom formatters to construct customized DateTime string. For more custom formatters and examples, [see this guide from Microsoft](https://learn.microsoft.com/en-us/dotnet/standard/base-types/custom-date-and-time-format-strings).
|Formatter|Description|Example Usage|Example Result|
|-|-|-|-|
|yyyy|4-digit year|\<file date[yyyy]\>|2023|
|yy|2-digit year|\<file date[yy]\>|23|
|MM|2-digit month|\<file date[MM]\>|02|
|dd|2-digit day of the month|\<file date[yyyy-MM-dd]\>|2023-02-14|
|HH<br>mm|The hour, using a 24-hour clock from 00 to 23<br>The minute, from 00 through 59.|\<file date[HH:mm]\>|14:45|
# This page has been moved to https://getlibation.com/docs/features/naming-templates

View File

@@ -1,79 +1 @@
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
# Searching and filtering: Table of Contents
- [Tags](#tags)
- [Searches](#searches)
- [Search examples](#search-examples)
- [Filters](#filters)
### Tags
To add tags to a title, click the tags button
![Tags step 1](images/Tags1.png)
Add as many tags as you'd like. Tags are separated by a space. Each tag can contain letters, numbers, and underscores
![Tags step 2](images/Tags2.png)
Tags are saved non-case specific for easy search. There is one special tag "hidden" which will also grey-out the book
![Tags step 3](images/Tags3.png)
To edit tags, just click the button again.
### Searches
Libation's advanced searching is built on the powerful Lucene search engine. Simple searches are effortless and powerful searches are simple. To search, just type and click Filter or press enter
* Type anything in the search box to search common fields: title, authors, narrators, and the book's audible id
* Use Lucene's "Query Parser Syntax" for advanced searching.
* Easy tutorial: http://www.lucenetutorial.com/lucene-query-syntax.html
* Full official guide: https://lucene.apache.org/core/2_9_4/queryparsersyntax.html
* Tons of search fields, specific to audiobooks
* Synonyms so you don't have to memorize magic words. Eg: author and author**s** will both work
* Click [?] button for a full list of search fields and synonyms ![Filter options](images/FilterOptionsButton.png)
* Search by tag like \[this\]
* When tags have an underscore you can use part of the tag. This is useful for quick categories. The below examples make this more clear.
### Search examples
Search for anything with the word potter
![Search example: potter](images/SearchExamplePotter.png)
If you only want to see Harry Potter
![Search example: "harry potter"](images/SearchExampleHarryPotter.png)
If you only want to see potter except for Harry Potter. You can also use "-" instead of "NOT"
![Search example: "potter NOT harry"](images/SearchExamplePotterNotHarry.png)
![Search example: "potter -harry"](images/SearchExamplePotterNotHarry2.png)
To see only books written by Neil Gaiman where he also narrates his own book. (If you don't include AND, you'll see everything written by Neil Gaiman and also all books in your library which are self-narrated.)
![Search example: author:gaiman AND authornarrated](images/SearchExampleGaimanAuthorNarrated.png)
I tagged autobiographies as auto_bio and biographies written by someone else as bio. I can get only autobiographies with \[auto_bio\] or get both by searching \[bio\]
![Search example: \[bio\]](images/SearchExampleBio.png)
![Search example: \[auto_bio\]](images/SearchExampleAutoBio.png)
### Filters
If you have a search you want to save, click Add To Quick Filters to save it in your Quick Filters list. To use it again, select it from the Quick Filters list.
To edit this list go to Quick Filters > Edit quick filters. Here you can re-order the list, delete filters, double-click a filter to edit it, or double-click the bottom blank box to add a new filter.
Check "Quick Filters > Start Libation with 1st filter Default" to have your top filter automatically applied when Libation starts. In this top example, I want to always start without these: at books I've tagged hidden, books I've tagged as free_audible_originals, and books which I have rated.
![default filters](images/FiltersDefault.png)
# This page has been moved to https://getlibation.com/docs/features/searching-and-filtering

View File

@@ -1,75 +1,55 @@
# Libation: Liberate your Library
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
**Libation** is a free, open-source application for downloading and managing your Audible audiobooks. It decrypts your library, removes DRM, and lets you own your audiobooks forever.
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PayPal.me](https://paypal.me/mcrackan?locale.x=en_us)
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
## Features
- **Unlock Your Library**: Download and remove DRM from your audiobooks.
- **Cross-Platform**: Fully supported on Windows, macOS, and Linux.
- **Region Support**: Works with Audible regions US, UK, Canada, Germany, France, Australia, Japan, India, and Spain.
- **Advanced Organization**: Search, filter, and tag your books.
- **Fast & Efficient**: Powered by AAXClean for fast decryption without heavy dependencies like ffmpeg.
- **Import**: Easily import your existing library, including cover art.
## Getting started with Libation
# Table of Contents
- [Audible audiobook manager](#audible-audiobook-manager)
- [The good](#the-good)
- [The bad](#the-bad)
- [The ugly](#the-ugly)
- [Getting started](Documentation/GettingStarted.md)
- [Download Libation](Documentation/GettingStarted.md#download-libation-1)
- [Installation](Documentation/GettingStarted.md#installation)
- [Create Accounts](Documentation/GettingStarted.md#create-accounts)
- [Import your library](Documentation/GettingStarted.md#import-your-library)
- [Download your books -- DRM-free!](Documentation/GettingStarted.md#download-your-books----drm-free)
- [Download PDF attachments](Documentation/GettingStarted.md#download-pdf-attachments)
- [Details of downloaded files](Documentation/GettingStarted.md#details-of-downloaded-files)
- [Export your library](Documentation/GettingStarted.md#export-your-library)
- If you still need help, [you can open an issue here](https://github.com/rmcrackan/Libation/issues) for bug reports, feature requests, or specialized help.
- [Searching and filtering](Documentation/SearchingAndFiltering.md)
- [Tags](Documentation/SearchingAndFiltering.md#tags)
- [Searches](Documentation/SearchingAndFiltering.md#searches)
- [Search examples](Documentation/SearchingAndFiltering.md#search-examples)
- [Filters](Documentation/SearchingAndFiltering.md#filters)
- [Advanced](Documentation/Advanced.md)
- [Files and folders](Documentation/Advanced.md#files-and-folders)
- [Settings](Documentation/Advanced.md#settings)
- [Custom File Naming](Documentation/NamingTemplates.md)
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
- [Custom Theme Colors](Documentation/Advanced.md#custom-theme-colors) (Chardonnay Only)
- [Audio Formats (Dolby Atmos, Widevine, Spacial Audio)](Documentation/AudioFileFormats.md)
- [Docker](Documentation/Docker.md)
- [Frequently Asked Questions](Documentation/FrequentlyAskedQuestions.md)
## Getting started
All documentation has been moved to our new site: [getlibation.com](https://getlibation.com). Or jump to the important bits:
* [Getting Started](https://getlibation.com/docs/getting-started)
* [Download](https://github.com/rmcrackan/Libation/releases/latest)
* [Step-by-step walk-through](Documentation/GettingStarted.md)
* [Issues, bugs, and requests](https://github.com/rmcrackan/Libation/issues)
* [Documentation](https://getlibation.com/docs/index)
## Audible audiobook manager
## Development
### The good
Grab the latest release for your platform from the [Releases Page](https://github.com/rmcrackan/Libation/releases/latest).
* Import library from audible, including cover art
* Download and remove DRM from all books
* Download accompanying PDFs
* Add tags to books for better organization
* Powerful advanced search built on the Lucene search engine
* Customizable saved filters for common searches
* Open source
* Supports most regions: US, UK, Canada, Germany, France, Australia, Japan, India, and Spain
* Fully supported in Windows, Mac, and Linux
## Documentation
<a name="theBad"/>
Comprehensive documentation is available in the `docs` directory and on our [Documentation Site](https://getlibation.com/docs).
### The bad
- [Getting Started](https://getlibation.com/docs/getting-started)
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
* Large file size
* Made by a programmer, not a designer so the goals are function rather than beauty. And it shows
## Development & Contributing
### The ugly
We welcome contributions!
* Documentation? Yer lookin' at it
* This is a single-developer personal passion project. Support, response, updates, enhancements, bug fixes etc are as my free time allows
* I have a full-time job, a life, and a finite attention span. Therefore a lot of time can potentially go by with no improvements of any kind
- **[Development Getting Started](https://getlibation.com/docs/development/getting-started)**: Setup your environment.
- **[Contribute](https://getlibation.com/docs/development/contribute)**: How to contribute code.
- **[Website & Docs](https://getlibation.com/docs/development/website)**: How to run and improve the documentation.
- **[Linux Setup (Nix)](https://getlibation.com/docs/development/nix-linux-setup)**: Nix-based environment setup.
Disclaimer: I've made every good-faith effort to include nothing insecure, malicious, anti-privacy, or destructive. That said: use at your own risk.
## Community & Support
I made this for myself and I want to share it with the great programming and audible/audiobook communities which have been so generous with their time and help.
- **[Issues](https://github.com/rmcrackan/Libation/issues)**: Report bugs or request features.
- **[PayPal](https://paypal.me/mcrackan?locale.x=en_us)**: Support the project if you find it useful.
## License
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
...or just tell more friends. As long as I'm maintaining this software, it will remain free and open source.

View File

@@ -13,7 +13,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AAXClean.Codecs" Version="2.1.0.1" />
<PackageReference Include="AAXClean.Codecs" Version="2.1.2.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -25,12 +25,19 @@ namespace AaxDecrypter
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
{
if (AaxFile is null)
throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}.");
if (DownloadOptions.LameConfig is null)
throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}.");
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
chapters: null);
}
}
/*

View File

@@ -31,12 +31,19 @@ namespace AaxDecrypter
{
//Finishing configuring lame encoder.
if (DownloadOptions.OutputFormat == OutputFormat.Mp3)
{
if (AaxFile is null)
throw new InvalidOperationException($"AaxFile is null during {nameof(OnInitialized)} in {nameof(AaxcDownloadConvertBase)}.");
if (DownloadOptions.LameConfig is null)
throw new InvalidOperationException($"LameConfig is null during {nameof(OnInitialized)} in {nameof(DownloadOptions)}.");
MpegUtil.ConfigureLameOptions(
AaxFile,
DownloadOptions.LameConfig,
DownloadOptions.Downsample,
DownloadOptions.MatchSourceBitrate,
DownloadOptions.ChapterInfo);
}
}
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()

View File

@@ -2,7 +2,9 @@
using AAXClean.Codecs;
using NAudio.Lame;
using System;
using System.Linq;
#nullable enable
namespace AaxDecrypter
{
public static class MpegUtil
@@ -13,7 +15,7 @@ namespace AaxDecrypter
LameConfig lameConfig,
bool downsample,
bool matchSourceBitrate,
ChapterInfo chapters)
ChapterInfo? chapters)
{
double bitrateMultiple = 1;
@@ -50,20 +52,22 @@ namespace AaxDecrypter
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SUBTITLE") is string subtitle)
lameConfig.ID3.Subtitle = subtitle;
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "LANGUAGE") is string lang)
lameConfig.ID3.UserDefinedText.Add("LANGUAGE", lang);
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "SERIES") is string series)
lameConfig.ID3.UserDefinedText.Add("SERIES", series);
if (mp4File.AppleTags.AppleListBox.GetFreeformTagString(TagDomain, "PART") is string part)
lameConfig.ID3.UserDefinedText.Add("PART", part);
if (chapters?.Count > 0)
{
var cue = Cue.CreateContents(lameConfig.ID3.Title + ".mp3", chapters);
lameConfig.ID3.UserDefinedText.Add("CUESHEET", cue);
}
//Copy over all other freeform tags
foreach (var t in mp4File.AppleTags.AppleListBox.Tags.OfType<Mpeg4Lib.Boxes.FreeformTagBox>())
{
if (t.Name?.Name is string name &&
t.Mean?.ReverseDnsDomain is string domain &&
!lameConfig.ID3.UserDefinedText.ContainsKey(name) &&
mp4File.AppleTags.AppleListBox.GetFreeformTagString(domain, name) is string tagStr &&
!string.IsNullOrWhiteSpace(tagStr))
lameConfig.ID3.UserDefinedText.Add(name, tagStr);
}
}
}
}

View File

@@ -2,7 +2,7 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Version>12.8.0.1</Version>
<Version>13.1.0.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -24,7 +24,8 @@ namespace AppScaffolding
LinuxAvalonia = OS.Linux | Variety.Chardonnay | Architecture.X64,
MacOSAvalonia = OS.MacOS | Variety.Chardonnay | Architecture.X64,
LinuxAvalonia_Arm64 = OS.Linux | Variety.Chardonnay | Architecture.Arm64,
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64
MacOSAvalonia_Arm64 = OS.MacOS | Variety.Chardonnay | Architecture.Arm64,
WindowsAvalonia_Arm64 = OS.Windows | Variety.Chardonnay | Architecture.Arm64,
}
// I know I'm taking the wine metaphor a bit far by naming this "Variety", but I don't know what else to call it
@@ -105,20 +106,43 @@ namespace AppScaffolding
/// <summary>
/// Delete shared memory and write-ahead log SQLite database files which may prevent access to the database.
/// These file may or may not cause libation to hang on CreateContext,
/// so try our luck by swallowing any exceptions and continuing.
/// </summary>
private static void DeleteOpenSqliteFiles(Configuration config)
{
var walFile = SqliteStorage.DatabasePath + "-wal";
var shmFile = SqliteStorage.DatabasePath + "-shm";
if (File.Exists(walFile))
FileManager.FileUtility.SaferDelete(walFile);
{
try
{
FileManager.FileUtility.SaferDelete(walFile);
}
catch(Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {@WalFile}", walFile);
}
}
if (File.Exists(shmFile))
FileManager.FileUtility.SaferDelete(shmFile);
{
try
{
FileManager.FileUtility.SaferDelete(shmFile);
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {@ShmFile}", shmFile);
}
}
}
static bool migrationsRun = false;
/// <summary>Initialize logging. Wire-up events. Run after migration</summary>
public static void RunPostMigrationScaffolding(Variety variety, Configuration config)
{
if (System.Threading.Interlocked.CompareExchange(ref migrationsRun, true, false))
return;
Variety = Enum.IsDefined(variety) ? variety : Variety.None;
var releaseID = (ReleaseIdentifier)((int)variety | (int)Configuration.OS | (int)RuntimeInformation.ProcessArchitecture);

View File

@@ -376,8 +376,8 @@ namespace ApplicationServices
#endregion
#region remove/restore books
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static int removeBooks(IEnumerable<LibraryBook> removeLibraryBooks)
public static Task<int> RemoveBooksAsync(this IEnumerable<LibraryBook?>? idsToRemove) => Task.Run(() => removeBooks(idsToRemove));
private static int removeBooks(IEnumerable<LibraryBook?>? removeLibraryBooks)
{
if (removeLibraryBooks is null || !removeLibraryBooks.Any())
return 0;
@@ -385,7 +385,7 @@ namespace ApplicationServices
return DoDbSizeChangeOperation(ctx =>
{
// Entry() NoTracking entities before SaveChanges()
foreach (var lb in removeLibraryBooks)
foreach (var lb in removeLibraryBooks.OfType<LibraryBook>())
{
lb.IsDeleted = true;
ctx.Entry(lb).State = Microsoft.EntityFrameworkCore.EntityState.Modified;
@@ -417,8 +417,8 @@ namespace ApplicationServices
}
}
public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook> idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook> libraryBooks)
public static Task<int> PermanentlyDeleteBooksAsync(this IEnumerable<LibraryBook?>? idsToRemove) => Task.Run(() => permanentlyDeleteBooks(idsToRemove));
private static int permanentlyDeleteBooks(this IEnumerable<LibraryBook?>? libraryBooks)
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
@@ -426,8 +426,8 @@ namespace ApplicationServices
{
return DoDbSizeChangeOperation(ctx =>
{
ctx.LibraryBooks.RemoveRange(libraryBooks);
ctx.Books.RemoveRange(libraryBooks.Select(lb => lb.Book));
ctx.LibraryBooks.RemoveRange(libraryBooks.OfType<LibraryBook>());
ctx.Books.RemoveRange(libraryBooks.OfType<LibraryBook>().Select(lb => lb.Book));
});
}
catch (Exception ex)
@@ -514,7 +514,7 @@ namespace ApplicationServices
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
public static async Task<int> UpdateBookStatusAsync(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat? audioFormat, string audioVersion)
=> await lb.UpdateUserDefinedItemAsync(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static async Task<int> UpdateBookStatusAsync(this LibraryBook libraryBook, LiberatedStatus bookStatus)
@@ -529,27 +529,31 @@ namespace ApplicationServices
public static async Task<int> UpdateTagsAsync(this LibraryBook libraryBook, string tags)
=> await libraryBook.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string tags)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags);
public static async Task<int> UpdateTagsAsync(this IEnumerable<LibraryBook> libraryBooks, string? tags)
=> await libraryBooks.UpdateUserDefinedItemAsync(udi => udi.Tags = tags ?? string.Empty);
public static async Task<int> UpdateUserDefinedItemAsync(this LibraryBook libraryBook, Action<UserDefinedItem> action)
=> await UpdateUserDefinedItemAsync([libraryBook], action);
public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
public static Task<int> UpdateUserDefinedItemAsync(this IEnumerable<LibraryBook?>? libraryBooks, Action<UserDefinedItem> action)
=> Task.Run(() => libraryBooks.updateUserDefinedItem(action));
private static int updateUserDefinedItem(this IEnumerable<LibraryBook> libraryBooks, Action<UserDefinedItem> action)
private static int updateUserDefinedItem(this IEnumerable<LibraryBook?>? libraryBooks, Action<UserDefinedItem> action)
{
try
{
if (libraryBooks is null || !libraryBooks.Any())
return 0;
int qtyChanges;
var nonNullBooks = libraryBooks.OfType<LibraryBook>();
if (!nonNullBooks.Any())
return 0;
int qtyChanges;
using (var context = DbContexts.GetContext())
{
// Entry() instead of Attach() due to possible stack overflow with large tables
foreach (var book in libraryBooks)
foreach (var book in nonNullBooks)
{
action?.Invoke(book.Book.UserDefinedItem);
@@ -563,7 +567,7 @@ namespace ApplicationServices
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
BookUserDefinedItemCommitted?.Invoke(null, nonNullBooks);
return qtyChanges;
}

View File

@@ -108,19 +108,22 @@ namespace ApplicationServices
[Name("Language")]
public string Language { get; set; }
[Name("LastDownloaded")]
[Name("Last Downloaded")]
public DateTime? LastDownloaded { get; set; }
[Name("LastDownloadedVersion")]
[Name("Last Downloaded Version")]
public string LastDownloadedVersion { get; set; }
[Name("IsFinished")]
[Name("Is Finished?")]
public bool IsFinished { get; set; }
[Name("IsSpatial")]
[Name("Is Spatial?")]
public bool IsSpatial { get; set; }
[Name("Last Downloaded File Version")]
[Name("Included Until")]
public DateTime? IncludedUntil { get; set; }
[Name("Last Downloaded File Version")]
public string LastDownloadedFileVersion { get; set; }
[Ignore /* csv ignore */]
@@ -177,6 +180,7 @@ namespace ApplicationServices
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
IsFinished = a.Book.UserDefinedItem.IsFinished,
IsSpatial = a.Book.IsSpatial,
IncludedUntil = a.IncludedUntil,
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
}).ToList();
@@ -248,6 +252,7 @@ namespace ApplicationServices
nameof(ExportDto.LastDownloadedVersion),
nameof(ExportDto.IsFinished),
nameof(ExportDto.IsSpatial),
nameof(ExportDto.IncludedUntil),
nameof(ExportDto.LastDownloadedFileVersion),
nameof(ExportDto.CodecString),
nameof(ExportDto.SampleRate),
@@ -305,7 +310,8 @@ namespace ApplicationServices
row.Cell(col++).Value = dto.LastDownloadedVersion;
row.Cell(col++).Value = dto.IsFinished;
row.Cell(col++).Value = dto.IsSpatial;
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.IncludedUntil;
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.CodecString;
row.Cell(col++).Value = dto.SampleRate;
row.Cell(col++).Value = dto.ChannelCount;

View File

@@ -79,7 +79,7 @@ namespace AudibleUtilities
// more common naming convention alias for internal collection
public IReadOnlyList<Account> GetAll() => Accounts;
public Account Upsert(string accountId, string locale)
public Account Upsert(string accountId, string? locale)
{
var acct = GetAccount(accountId, locale);

View File

@@ -1,16 +1,17 @@
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Channels;
using System.Threading.Tasks;
using System.Diagnostics;
using AudibleApi;
using AudibleApi;
using AudibleApi.Common;
using Dinah.Core;
using LibationFileManager;
using Newtonsoft.Json.Linq;
using Polly;
using Polly.Retry;
using System;
using System.Collections.Generic;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using LibationFileManager;
using System.Threading.Channels;
using System.Threading.Tasks;
#nullable enable
namespace AudibleUtilities
@@ -82,6 +83,23 @@ namespace AudibleUtilities
return policy.ExecuteAsync(() => getItemsAsync(libraryOptions));
}
/// <summary>
/// A debugging method used to simulate a library scan from a LibraryScans.zip json file.
/// Simply replace the Api call to GetLibraryItemsPagesAsync() with a call to this method.
/// </summary>
private static async IAsyncEnumerable<Item[]> GetItemsFromJsonFile()
{
var libraryScanJsonPath = @"Path/to/libraryscan.json";
using var jsonFile = System.IO.File.OpenText(libraryScanJsonPath);
var json = await JToken.ReadFromAsync(new Newtonsoft.Json.JsonTextReader(jsonFile));
if (json?["Items"] is not JArray items)
yield break;
foreach (var batch in items.Select(i => Item.FromJson(i as JObject)).Chunk(BatchSize))
yield return batch;
}
private async Task<List<Item>> getItemsAsync(LibraryOptions libraryOptions)
{
Serilog.Log.Logger.Debug("Beginning library scan.");
@@ -162,6 +180,7 @@ namespace AudibleUtilities
Serilog.Log.Logger.Information("Completed indexing series episodes after {elappsed_ms} ms.", sw.ElapsedMilliseconds);
Serilog.Log.Logger.Information($"Completed library scan in {totalTime.TotalMilliseconds:F0} ms.");
Array.ForEach(ISanitizer.GetAllSanitizers(), s => s.Sanitize(items));
var allExceptions = IValidator.GetAllValidators().SelectMany(v => v.Validate(items)).ToList();
if (allExceptions?.Count > 0)
throw new ImportValidationException(items, allExceptions);

View File

@@ -0,0 +1,33 @@
using AudibleApi.Common;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace AudibleUtilities;
public interface ISanitizer
{
void Sanitize(IEnumerable<Item> items);
public static ISanitizer[] GetAllSanitizers() => [
new ContributorSanitizer()
];
}
public class ContributorSanitizer : ISanitizer
{
public void Sanitize(IEnumerable<Item> items)
{
foreach (var item in items)
{
item.Authors = SanitizePersonArray(item.Authors);
item.Narrators = SanitizePersonArray(item.Narrators);
}
}
private static Person[]? SanitizePersonArray(Person?[]? contributors)
=> contributors
?.OfType<Person>()
.Where(c => !string.IsNullOrWhiteSpace(c.Asin) && !string.IsNullOrWhiteSpace(c.Name))
.ToArray();
}

View File

@@ -9,17 +9,21 @@ namespace AudibleUtilities
{
IEnumerable<Exception> Validate(IEnumerable<Item> items);
public static IValidator[] GetAllValidators()
=> new IValidator[]
{
new LibraryValidator(),
new BookValidator(),
new CategoryValidator(),
new ContributorValidator(),
new SeriesValidator(),
};
public static IValidator[] GetAllValidators() => [
new LibraryValidator(),
new BookValidator(),
new CategoryValidator(),
new SeriesValidator(),
];
}
/// <summary>
/// To be used when no validation is desired
/// </summary>
public class ClearValidator : IValidator
{
public IEnumerable<Exception> Validate(IEnumerable<Item> items) => [];
}
public class LibraryValidator : IValidator
{
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
@@ -68,20 +72,6 @@ namespace AudibleUtilities
return exceptions;
}
}
public class ContributorValidator : IValidator
{
public IEnumerable<Exception> Validate(IEnumerable<Item> items)
{
var exceptions = new List<Exception>();
if (items.GetAuthorsDistinct().Any(a => string.IsNullOrWhiteSpace(a.Name)))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Authors)} with null {nameof(Person.Name)}", nameof(items)));
if (items.GetNarratorsDistinct().Any(a => string.IsNullOrWhiteSpace(a.Name)))
exceptions.Add(new ArgumentException($"Collection contains {nameof(Item.Narrators)} with null {nameof(Person.Name)}", nameof(items)));
return exceptions;
}
}
public class SeriesValidator : IValidator
{
public IEnumerable<Exception> Validate(IEnumerable<Item> items)

View File

@@ -5,8 +5,8 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="10.1.1.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.1" />
<PackageReference Include="AudibleApi" Version="10.1.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,11 +1,10 @@
using System;
using System.Collections.Generic;
using Dinah.Core;
#nullable enable
namespace DataLayer
{
/// <summary>Parameterless ctor and setters should be used by EF only. Everything else should treat it as immutable</summary>
public class Rating : ValueObject_Static<Rating>, IComparable<Rating>, IComparable
public record Rating : IComparable<Rating>, IComparable
{
public float OverallRating { get; private set; }
public float PerformanceRating { get; private set; }
@@ -31,23 +30,17 @@ namespace DataLayer
StoryRating = storyRating;
}
protected override IEnumerable<object> GetEqualityComponents()
{
yield return OverallRating;
yield return PerformanceRating;
yield return StoryRating;
}
public override string ToString() => $"Overall={OverallRating} Perf={PerformanceRating} Story={StoryRating}";
public int CompareTo(Rating other)
public int CompareTo(Rating? other)
{
var compare = OverallRating.CompareTo(other.OverallRating);
if (other is null) return 1;
var compare = OverallRating.CompareTo(other.OverallRating);
if (compare != 0) return compare;
compare = PerformanceRating.CompareTo(other.PerformanceRating);
if (compare != 0) return compare;
return StoryRating.CompareTo(other.StoryRating);
}
public int CompareTo(object obj) => obj is Rating second ? CompareTo(second) : -1;
public int CompareTo(object? obj) => obj is Rating second ? CompareTo(second) : 1;
}
}

View File

@@ -4,6 +4,7 @@ using System.Linq;
using System.Text.RegularExpressions;
using Dinah.Core;
#nullable enable
namespace DataLayer
{
/// <summary>
@@ -31,17 +32,17 @@ namespace DataLayer
/// <summary>
/// Version of Libation used the last time the audio file was downloaded.
/// </summary>
public Version LastDownloadedVersion { get; private set; }
public Version? LastDownloadedVersion { get; private set; }
/// <summary>
/// Audio format of the last downloaded audio file.
/// </summary>
public AudioFormat LastDownloadedFormat { get; private set; }
public AudioFormat? LastDownloadedFormat { get; private set; }
/// <summary>
/// Version of the audio file that was last downloaded.
/// </summary>
public string LastDownloadedFileVersion { get; private set; }
public string? LastDownloadedFileVersion { get; private set; }
public void SetLastDownloaded(Version libationVersion, AudioFormat audioFormat, string audioVersion)
public void SetLastDownloaded(Version? libationVersion, AudioFormat? audioFormat, string? audioVersion)
{
if (LastDownloadedVersion != libationVersion)
{
@@ -71,9 +72,13 @@ namespace DataLayer
OnItemChanged(nameof(LastDownloaded));
}
}
private UserDefinedItem()
{
// for EF
Book = null!;
}
private UserDefinedItem() { }
internal UserDefinedItem(Book book)
internal UserDefinedItem(Book book)
{
ArgumentValidator.EnsureNotNull(book, nameof(book));
Book = book;
@@ -162,7 +167,7 @@ namespace DataLayer
/// Occurs when <see cref="Tags"/>, <see cref="BookStatus"/>, or <see cref="PdfStatus"/> values change.
/// This signals the change of the in-memory value; it does not ensure that the new value has been persisted.
/// </summary>
public static event EventHandler<string> ItemChanged;
public static event EventHandler<string>? ItemChanged;
private void OnItemChanged(string e)
{

View File

@@ -86,7 +86,11 @@ public class MockLibraryBook : LibraryBook
string localeName = "us",
bool isAbridged = false,
bool isSpatial = false,
string language = "English")
string language = "English",
LiberatedStatus bookStatus = LiberatedStatus.Liberated,
LiberatedStatus? pdfStatus = null,
AudioFormat? lastDlFormat = null,
Version? lastDlVersion = null)
{
var book = new Book(
new AudibleProductId(CalculateAsin(title + subtitle)),
@@ -99,6 +103,12 @@ public class MockLibraryBook : LibraryBook
[new Contributor(firstNarrator, CalculateAsin(firstNarrator))],
localeName);
lastDlFormat ??= new AudioFormat(Codec.AAC_LC, 128, 44100, 2);
lastDlVersion ??= new Version(13, 0);
book.UserDefinedItem.SetLastDownloaded(lastDlVersion, lastDlFormat, "1");
book.UserDefinedItem.PdfStatus = pdfStatus;
book.UserDefinedItem.BookStatus = bookStatus;
book.UpdateBookDetails(isAbridged, isSpatial, datePublished ?? DateTime.Now, language);
return new MockLibraryBook(

View File

@@ -10,7 +10,7 @@ namespace DtoImporterService
{
public class ContributorImporter : ItemsImporterBase
{
protected override IValidator Validator => new ContributorValidator();
protected override IValidator Validator => new ClearValidator();
public Dictionary<string, Contributor> Cache { get; private set; } = new();

View File

@@ -12,7 +12,7 @@ using System.IO;
namespace AaxDecrypter;
/// <summary> Read audio codec, bitrate, sample rate, and channel count from MP4 and MP3 audio files. </summary>
internal static class AudioFormatDecoder
public static class AudioFormatDecoder
{
public static AudioFormat FromMpeg4(string filename)
{

View File

@@ -63,10 +63,6 @@ namespace FileLiberator
using var m4bFileStream = File.Open(entry.m4bPath, FileMode.Open, FileAccess.Read, FileShare.Read);
var m4bBook = new Mp4File(m4bFileStream);
//AAXClean.Codecs only supports decoding AAC and E-AC-3 audio.
if (m4bBook.AudioSampleEntry.Esds is null && m4bBook.AudioSampleEntry.Dec3 is null)
continue;
OnTitleDiscovered(m4bBook.AppleTags.Title);
OnAuthorsDiscovered(m4bBook.AppleTags.FirstAuthor);
OnNarratorsDiscovered(m4bBook.AppleTags.Narrator);

View File

@@ -254,6 +254,11 @@ namespace FileLiberator
tags.Year ??= pubDate.Year.ToString();
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
}
const string tagDomain = "org.libation";
aaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_ACR", tags.Acr);
aaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_DRM_TYPE", options.DrmType.ToString());
aaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "AUDIBLE_LOCALE", options.LibraryBook.Book.Locale);
}
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)

View File

@@ -125,7 +125,7 @@ public partial class DownloadOptions
//try to request a widevine content license using the user's audio settings
var aacCodecChoice = config.Request_xHE_AAC ? Codecs.xHE_AAC : Codecs.AAC_LC;
//Always use the ec+3 codec if converting to mp3
var spatialCodecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 && !config.DecryptToLossy ? Codecs.AC_4 : Codecs.EC_3;
var spatialCodecChoice = config.SpatialAudioCodec is Configuration.SpatialCodec.AC_4 ? Codecs.AC_4 : Codecs.EC_3;
var contentLic
= await api.GetDownloadLicenseAsync(

View File

@@ -74,7 +74,7 @@ namespace FileLiberator
//If DrmType is not Adrm or Widevine, the delivered file is an unencrypted mp3.
OutputFormat
= licInfo.DrmType is not AudibleApi.Common.DrmType.Adrm and not AudibleApi.Common.DrmType.Widevine ||
(config.AllowLibationFixup && config.DecryptToLossy && licInfo.ContentMetadata.ContentReference.Codec != AudibleApi.Codecs.AC_4)
(config.AllowLibationFixup && config.DecryptToLossy)
? OutputFormat.Mp3
: OutputFormat.M4b;

View File

@@ -56,15 +56,18 @@ namespace FileManager
fileExtension = GetStandardizedExtension(fileExtension);
// remove invalid chars
path = GetSafePath(path, replacements);
var pathStr = removeInvalidWhitespace(path.Path);
var pathWithoutExtension = pathStr.EndsWithInsensitive(fileExtension)
? pathStr[..^fileExtension.Length]
: path.Path;
// remove invalid chars, but leave file extension untouched
pathWithoutExtension = GetSafePath(pathWithoutExtension, replacements);
// ensure uniqueness and check lengths
var dir = Path.GetDirectoryName(path)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var dir = Path.GetDirectoryName(pathWithoutExtension)?.TruncateFilename(LongPath.MaxDirectoryLength) ?? string.Empty;
var fileName = Path.GetFileName(path);
var extIndex = fileName.LastIndexOf(fileExtension, StringComparison.OrdinalIgnoreCase);
var filenameWithoutExtension = extIndex >= 0 ? fileName.Remove(extIndex, fileExtension.Length) : fileName;
var filenameWithoutExtension = Path.GetFileName(pathWithoutExtension);
var fileStem
= Path.Combine(dir, filenameWithoutExtension.TruncateFilename(LongPath.MaxFilenameLength - fileExtension.Length))
.TruncateFilename(LongPath.MaxPathLength - fileExtension.Length);

View File

@@ -51,7 +51,7 @@ public class NamingTemplate
/// <param name="template">The template string to parse</param>
/// <param name="tagCollections">A collection of <see cref="TagCollection"/> with
/// properties registered to match to the <paramref name="template"/></param>
public static NamingTemplate Parse(string template, IEnumerable<TagCollection> tagCollections)
public static NamingTemplate Parse(string? template, IEnumerable<TagCollection> tagCollections)
{
var namingTemplate = new NamingTemplate(tagCollections);
try

View File

@@ -2,9 +2,6 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:local="using:HangoverAvalonia"
x:Class="HangoverAvalonia.App">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Styles>
<FluentTheme>

View File

@@ -1,30 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using HangoverAvalonia.ViewModels;
using System;
namespace HangoverAvalonia
{
public class ViewLocator : IDataTemplate
{
public Control Build(object data)
{
var name = data.GetType().FullName!.Replace("ViewModel", "View");
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
else
{
return new TextBlock { Text = "Not Found: " + name };
}
}
public bool Match(object data)
{
return data is ViewModelBase;
}
}
}

View File

@@ -6,10 +6,6 @@
x:Class="LibationAvalonia.App"
Name="Libation">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Resources>
<ResourceDictionary>

View File

@@ -21,7 +21,6 @@ using System.IO;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia;
public class App : Application

View File

@@ -5,9 +5,9 @@ using Avalonia.Media.Imaging;
using Avalonia.VisualTree;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia
{
internal static class AvaloniaUtils
@@ -23,7 +23,9 @@ namespace LibationAvalonia
? dialogWindow.ShowDialog<DialogResult>(window)
: Task.FromResult(DialogResult.None);
public static Window? GetParentWindow(this Control control) => control.GetVisualRoot() as Window;
public static Window GetParentWindow(this Control control)
=> control.GetVisualRoot() as Window ?? App.MainWindow
?? throw new InvalidOperationException("Cannot find parent window.");
private static Bitmap? defaultImage;

View File

@@ -22,6 +22,6 @@ namespace LibationAvalonia.Controls
public class CheckBoxViewModel : ViewModelBase
{
public bool IsChecked { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public object Item { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public object? Item { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
}
}

View File

@@ -5,11 +5,11 @@ namespace LibationAvalonia.Controls
{
public class DataGridCheckBoxColumnExt : DataGridCheckBoxColumn
{
protected override Control GenerateEditingElementDirect(DataGridCell cell, object dataItem)
protected override Control? GenerateEditingElementDirect(DataGridCell cell, object dataItem)
{
//Only SeriesEntry types have three-state checks, individual LibraryEntry books are binary.
var ele = base.GenerateEditingElementDirect(cell, dataItem) as CheckBox;
ele.IsThreeState = dataItem is SeriesEntry;
ele?.IsThreeState = dataItem is SeriesEntry;
return ele;
}
}

View File

@@ -9,17 +9,19 @@ namespace LibationAvalonia.Controls
{
internal static class DataGridContextMenus
{
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs> CellContextMenuStripNeeded;
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs>? CellContextMenuStripNeeded;
private static readonly ContextMenu ContextMenu = new();
private static readonly AvaloniaList<Control> MenuItems = new();
public static readonly AvaloniaList<Control> MenuItems = new();
private static readonly PropertyInfo OwningColumnProperty;
private static readonly PropertyInfo OwningGridProperty;
static DataGridContextMenus()
{
ContextMenu.ItemsSource = MenuItems;
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic);
OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic);
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Could not find OwningColumn property on DataGridCell");
OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic)
?? throw new InvalidOperationException("Could not find OwningGrid property on DataGridColumn");
}
public static void AttachContextMenu(this DataGridCell cell)
@@ -31,7 +33,7 @@ namespace LibationAvalonia.Controls
}
}
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
private static void Cell_ContextRequested(object? sender, ContextRequestedEventArgs e)
{
if (sender is DataGridCell cell &&
cell.DataContext is GridEntry clickedEntry &&
@@ -74,7 +76,8 @@ namespace LibationAvalonia.Controls
private static readonly MethodInfo GetCellValueMethod;
static DataGridCellContextMenuStripNeededEventArgs()
{
GetCellValueMethod = typeof(DataGridColumn).GetMethod("GetCellValue", BindingFlags.NonPublic | BindingFlags.Instance);
GetCellValueMethod = typeof(DataGridColumn).GetMethod("GetCellValue", BindingFlags.NonPublic | BindingFlags.Instance)
?? throw new InvalidOperationException("Could not find GetCellValue method on DataGridColumn");
}
private static string GetCellValue(DataGridColumn column, object item)
@@ -96,7 +99,7 @@ namespace LibationAvalonia.Controls
Grid.Columns
.Where(c => c.IsVisible)
.OrderBy(c => c.DisplayIndex)
.Select(c => RemoveLineBreaks(c.Header.ToString())));
.Select(c => RemoveLineBreaks(c.Header.ToString() ?? "")));
private static string RemoveLineBreaks(string text)
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
@@ -111,7 +114,6 @@ namespace LibationAvalonia.Controls
public required DataGridColumn Column { get; init; }
public required GridEntry[] GridEntries { get; init; }
public required ContextMenu ContextMenu { get; init; }
public AvaloniaList<Control> ContextMenuItems
=> ContextMenu.ItemsSource as AvaloniaList<Control>;
public AvaloniaList<Control> ContextMenuItems => DataGridContextMenus.MenuItems;
}
}

View File

@@ -9,8 +9,8 @@ namespace LibationAvalonia.Controls
{
public class DataGridMyRatingColumn : DataGridBoundColumn
{
[AssignBinding] public IBinding BackgroundBinding { get; set; }
[AssignBinding] public IBinding OpacityBinding { get; set; }
[AssignBinding] public IBinding? BackgroundBinding { get; set; }
[AssignBinding] public IBinding? OpacityBinding { get; set; }
private static Rating DefaultRating => new Rating(0, 0, 0);
public DataGridMyRatingColumn()
{

View File

@@ -9,7 +9,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Controls
{
public partial class DirectoryOrCustomSelectControl : UserControl

View File

@@ -13,27 +13,27 @@ namespace LibationAvalonia.Controls
{
public class KnownDirectoryConverter : IValueConverter
{
public object Convert(object value, Type targetType, object parameter, CultureInfo culture)
public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture)
{
if (value is Configuration.KnownDirectories dir)
return dir.GetDescription();
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
public object ConvertBack(object value, Type targetType, object parameter, CultureInfo culture)
public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture)
{
return new BindingNotification(new InvalidCastException(), BindingErrorType.Error);
}
}
public class KnownDirectoryPath : IMultiValueConverter
{
public object Convert(IList<object> values, Type targetType, object parameter, CultureInfo culture)
public object? Convert(IList<object?> values, Type targetType, object? parameter, CultureInfo culture)
{
if (values?.Count == 2 && values[0] is Configuration.KnownDirectories kdir && kdir is not Configuration.KnownDirectories.None)
{
var subdir = values[1] as string ?? "";
var path = kdir is Configuration.KnownDirectories.AppDir ? Configuration.AppDir_Absolute : Configuration.GetKnownDirectoryPath(kdir);
return Path.Combine(path, subdir);
return path is null ? "" : Path.Combine(path, subdir);
}
return "";
}

View File

@@ -58,7 +58,7 @@ namespace LibationAvalonia.Controls
Tapped += LinkLabel_Tapped;
}
private void LinkLabel_Tapped(object sender, TappedEventArgs e)
private void LinkLabel_Tapped(object? sender, TappedEventArgs e)
{
Foreground = ForegroundVisited;
if (IsEffectivelyEnabled)
@@ -87,7 +87,7 @@ namespace LibationAvalonia.Controls
}
protected override bool IsEnabledCore => base.IsEnabledCore && _commandCanExecute;
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception error)
protected override void UpdateDataValidation(AvaloniaProperty property, BindingValueType state, Exception? error)
{
base.UpdateDataValidation(property, state, error);
if (property == CommandProperty)

View File

@@ -61,18 +61,20 @@ namespace LibationAvalonia.Controls
public void Panel_PointerExited(object sender, Avalonia.Input.PointerEventArgs e)
{
var panel = sender as Panel;
if (sender is not Panel panel)
return;
var stackPanel = panel.Children.OfType<StackPanel>().Single();
//Restore defaults
foreach (TextBlock child in stackPanel.Children)
child.Text = (string)child.Tag;
child.Text = child.Tag as string;
}
public void Star_PointerEntered(object sender, Avalonia.Input.PointerEventArgs e)
{
var thisTbox = sender as TextBlock;
var stackPanel = thisTbox.Parent as StackPanel;
if (thisTbox?.Parent is not StackPanel stackPanel)
return;
var star = SOLID_STAR;
foreach (TextBlock child in stackPanel.Children)
@@ -89,7 +91,8 @@ namespace LibationAvalonia.Controls
var story = Rating.StoryRating;
var thisTbox = sender as TextBlock;
var stackPanel = thisTbox.Parent as StackPanel;
if (thisTbox?.Parent is not StackPanel stackPanel)
return;
int newRatingValue = 0;
foreach (var tbox in stackPanel.Children)

View File

@@ -92,7 +92,6 @@
Margin="5,0,0,0"
Grid.Column="1"
VerticalAlignment="Center"
SelectionChanged="SpatialCodec_SelectionChanged"
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
</Grid>

View File

@@ -5,7 +5,6 @@ using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationFileManager.Templates;
using LibationUiBase.Forms;
using ReactiveUI;
using System.Linq;
using System.Threading.Tasks;
@@ -13,7 +12,7 @@ namespace LibationAvalonia.Controls.Settings
{
public partial class Audio : UserControl
{
private AudioSettingsVM _viewModel => DataContext as AudioSettingsVM;
private AudioSettingsVM? _viewModel => DataContext as AudioSettingsVM;
public Audio()
{
InitializeComponent();
@@ -23,15 +22,6 @@ namespace LibationAvalonia.Controls.Settings
}
}
private void SpatialCodec_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (_viewModel.SpatialAudioCodec.Value is Configuration.SpatialCodec.AC_4 && _viewModel.DecryptToLossy)
{
_viewModel.SpatialAudioCodec = _viewModel.SpatialAudioCodecs[0];
_viewModel.RaisePropertyChanged(nameof(AudioSettingsVM.SpatialAudioCodec));
}
}
private async void UseWidevine_IsCheckedChanged(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (sender is CheckBox cbox && cbox.IsChecked is true)
@@ -65,12 +55,12 @@ namespace LibationAvalonia.Controls.Settings
}
}
_viewModel.UseWidevine = false;
_viewModel?.UseWidevine = false;
}
}
else
{
_viewModel.Request_xHE_AAC = _viewModel.RequestSpatial = false;
_viewModel?.Request_xHE_AAC = _viewModel.RequestSpatial = false;
}
}
@@ -82,7 +72,7 @@ namespace LibationAvalonia.Controls.Settings
_viewModel.ChapterTitleTemplate = newTemplate;
}
private async Task<string> editTemplate(ITemplateEditor template)
private async Task<string?> editTemplate(ITemplateEditor template)
{
var form = new EditTemplateDialog(template);
if (await form.ShowDialog<DialogResult>(this.GetParentWindow()) == DialogResult.OK)

View File

@@ -1,4 +1,5 @@
using Avalonia.Controls;
using FileManager;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
@@ -10,7 +11,7 @@ namespace LibationAvalonia.Controls.Settings
{
public partial class DownloadDecrypt : UserControl
{
private DownloadDecryptSettingsVM _viewModel => DataContext as DownloadDecryptSettingsVM;
private DownloadDecryptSettingsVM? _viewModel => DataContext as DownloadDecryptSettingsVM;
public DownloadDecrypt()
{
InitializeComponent();
@@ -22,24 +23,24 @@ namespace LibationAvalonia.Controls.Settings
public async void EditFolderTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_viewModel is null) return;
var newTemplate = await editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(_viewModel.Config.Books, _viewModel.FolderTemplate));
if (_viewModel is null || _viewModel.Config.Books is not LongPath books) return;
var newTemplate = await editTemplate(TemplateEditor<Templates.FolderTemplate>.CreateFilenameEditor(books, _viewModel.FolderTemplate));
if (newTemplate is not null)
_viewModel.FolderTemplate = newTemplate;
}
public async void EditFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_viewModel is null) return;
var newTemplate = await editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(_viewModel.Config.Books, _viewModel.FileTemplate));
if (_viewModel is null || _viewModel.Config.Books is not LongPath books) return;
var newTemplate = await editTemplate(TemplateEditor<Templates.FileTemplate>.CreateFilenameEditor(books, _viewModel.FileTemplate));
if (newTemplate is not null)
_viewModel.FileTemplate = newTemplate;
}
public async void EditChapterFileTemplateButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_viewModel is null) return;
var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(_viewModel.Config.Books, _viewModel.ChapterFileTemplate));
if (_viewModel is null || _viewModel.Config.Books is not LongPath books) return;
var newTemplate = await editTemplate(TemplateEditor<Templates.ChapterFileTemplate>.CreateFilenameEditor(books, _viewModel.ChapterFileTemplate));
if (newTemplate is not null)
_viewModel.ChapterFileTemplate = newTemplate;
}
@@ -52,7 +53,7 @@ namespace LibationAvalonia.Controls.Settings
}
private async Task<string> editTemplate(ITemplateEditor template)
private async Task<string?> editTemplate(ITemplateEditor template)
{
var form = new EditTemplateDialog(template);
if (await form.ShowDialog<DialogResult>(this.GetParentWindow()) == DialogResult.OK)

View File

@@ -1,13 +1,10 @@
using Avalonia.Controls;
using Avalonia.Controls.ApplicationLifetimes;
using Dinah.Core;
using FileManager;
using LibationAvalonia.Dialogs;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Controls.Settings
{
public partial class Important : UserControl

View File

@@ -43,7 +43,7 @@ public partial class ThemePreviewControl : UserControl
QueuedBook.AddDownloadPdf();
WorkingBook.AddDownloadPdf();
typeof(ProcessBookViewModel).GetProperty(nameof(ProcessBookViewModel.Progress)).SetValue(WorkingBook, 50);
typeof(ProcessBookViewModel).GetProperty(nameof(ProcessBookViewModel.Progress))!.SetValue(WorkingBook, 50);
ProductsDisplay = new ProductsDisplayViewModel();
_ = ProductsDisplay.BindToGridAsync(sampleEntries);

View File

@@ -26,11 +26,11 @@ namespace LibationAvalonia.Dialogs
var mainWindow = Owner as Views.MainWindow;
var upgrader = new Upgrader();
upgrader.DownloadProgress += async (_, e) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => mainWindow.ViewModel.DownloadProgress = e.ProgressPercentage);
upgrader.DownloadCompleted += async (_, _) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => mainWindow.ViewModel.DownloadProgress = null);
upgrader.DownloadProgress += async (_, e) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => mainWindow?.ViewModel?.DownloadProgress = e.ProgressPercentage);
upgrader.DownloadCompleted += async (_, _) => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => mainWindow?.ViewModel?.DownloadProgress = null);
_viewModel.CanCheckForUpgrade = false;
Version latestVersion = null;
Version? latestVersion = null;
await upgrader.CheckForUpgradeAsync(OnUpgradeAvailable);
_viewModel.CanCheckForUpgrade = latestVersion is null;

View File

@@ -21,7 +21,7 @@ namespace LibationAvalonia.Dialogs
{
public IReadOnlyList<Locale> Locales => AccountsDialog.Locales;
public bool LibraryScan { get; set; } = true;
public string AccountId
public string? AccountId
{
get => field;
set
@@ -31,8 +31,8 @@ namespace LibationAvalonia.Dialogs
}
}
public Locale SelectedLocale { get; set; }
public string AccountName { get; set; }
public Locale? SelectedLocale { get; set; }
public string? AccountName { get; set; }
public bool IsDefault => string.IsNullOrEmpty(AccountId);
public AccountDto() { }
@@ -65,7 +65,7 @@ namespace LibationAvalonia.Dialogs
addBlankAccount();
}
private void Accounts_CollectionChanged(object sender, NotifyCollectionChangedEventArgs e)
private void Accounts_CollectionChanged(object? sender, NotifyCollectionChangedEventArgs e)
{
if (e.Action is NotifyCollectionChangedAction.Add && e.NewItems?.Count > 0)
{
@@ -81,13 +81,13 @@ namespace LibationAvalonia.Dialogs
private void addBlankAccount() => Accounts.Insert(Accounts.Count, new AccountDto());
private void AccountDto_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
private void AccountDto_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (!Accounts.Any(a => a.IsDefault))
addBlankAccount();
}
public void DeleteButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public void DeleteButton_Clicked(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (e.Source is Button expBtn && expBtn.DataContext is AccountDto acc)
Accounts.Remove(acc);
@@ -200,9 +200,9 @@ namespace LibationAvalonia.Dialogs
}
// upsert each. validation occurs through Account and AccountsSettings
foreach (var dto in Accounts)
foreach (var dto in Accounts.Where(a => a.AccountId is not null))
{
var acct = accountsSettings.Upsert(dto.AccountId, dto.SelectedLocale?.Name);
var acct = accountsSettings.Upsert(dto.AccountId!, dto.SelectedLocale?.Name);
acct.LibraryScan = dto.LibraryScan;
acct.AccountName
= string.IsNullOrWhiteSpace(dto.AccountName)

View File

@@ -17,26 +17,27 @@ namespace LibationAvalonia.Dialogs
{
public partial class BookDetailsDialog : DialogWindow
{
private BookDetailsDialogViewModel _viewModel;
public LibraryBook LibraryBook
private BookDetailsDialogViewModel? _viewModel;
public LibraryBook? LibraryBook
{
get => field;
set
{
field = value;
Title = field.Book.TitleWithSubtitle;
DataContext = _viewModel = new BookDetailsDialogViewModel(field);
Title = field?.Book.TitleWithSubtitle;
if (field is not null)
DataContext = _viewModel = new BookDetailsDialogViewModel(field);
}
}
public string NewTags => _viewModel.Tags;
public LiberatedStatus BookLiberatedStatus => _viewModel.BookLiberatedSelectedItem.Status;
public LiberatedStatus? PdfLiberatedStatus => _viewModel.PdfLiberatedSelectedItem?.Status;
public string? NewTags => _viewModel?.Tags;
public LiberatedStatus BookLiberatedStatus => _viewModel?.BookLiberatedSelectedItem?.Status ?? default;
public LiberatedStatus? PdfLiberatedStatus => _viewModel?.PdfLiberatedSelectedItem?.Status;
public BookDetailsDialog()
{
InitializeComponent();
ControlToFocusOnShow = this.Find<TextBox>(nameof(tagsTbox));
ControlToFocusOnShow = tagsTbox;
if (Design.IsDesignMode)
{
@@ -60,14 +61,15 @@ namespace LibationAvalonia.Dialogs
protected override async Task SaveAndCloseAsync()
{
await LibraryBook.UpdateUserDefinedItemAsync(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
if (LibraryBook is not null)
await LibraryBook.UpdateUserDefinedItemAsync(NewTags, bookStatus: BookLiberatedStatus, pdfStatus: PdfLiberatedStatus);
await base.SaveAndCloseAsync();
}
public void BookStatus_SelectionChanged(object sender, SelectionChangedEventArgs e)
{
if (sender is not WheelComboBox { SelectedItem: liberatedComboBoxItem { Status: LiberatedStatus.Error } } &&
_viewModel.BookLiberatedItems.SingleOrDefault(s => s.Status == LiberatedStatus.Error) is liberatedComboBoxItem errorItem)
_viewModel?.BookLiberatedItems.SingleOrDefault(s => s.Status == LiberatedStatus.Error) is liberatedComboBoxItem errorItem)
{
_viewModel.BookLiberatedItems.Remove(errorItem);
}
@@ -78,8 +80,8 @@ namespace LibationAvalonia.Dialogs
public class liberatedComboBoxItem
{
public LiberatedStatus Status { get; set; }
public string Text { get; set; }
public override string ToString() => Text;
public string? Text { get; set; }
public override string? ToString() => Text;
}
public class BookDetailsDialogViewModel : ViewModelBase
@@ -92,8 +94,8 @@ namespace LibationAvalonia.Dialogs
public bool HasPDF => PdfLiberatedItems?.Count > 0;
public AvaloniaList<liberatedComboBoxItem> BookLiberatedItems { get; } = new();
public List<liberatedComboBoxItem> PdfLiberatedItems { get; } = new();
public liberatedComboBoxItem PdfLiberatedSelectedItem { get; set; }
public liberatedComboBoxItem BookLiberatedSelectedItem { get; set; }
public liberatedComboBoxItem? PdfLiberatedSelectedItem { get; set; }
public liberatedComboBoxItem? BookLiberatedSelectedItem { get; set; }
public ICommand OpenInAudibleCommand { get; }
public BookDetailsDialogViewModel(LibraryBook libraryBook)

View File

@@ -24,6 +24,7 @@ namespace LibationAvalonia.Dialogs
public BookRecordsDialog()
{
InitializeComponent();
libraryBook = MockLibraryBook.CreateBook();
if (Design.IsDesignMode)
{
@@ -43,7 +44,7 @@ namespace LibationAvalonia.Dialogs
Loaded += BookRecordsDialog_Loaded;
}
private async void BookRecordsDialog_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
private async void BookRecordsDialog_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
try
{
@@ -211,8 +212,8 @@ namespace LibationAvalonia.Dialogs
public string Created => Record.Created.ToString(DateFormat);
public string Modified => Record is IAnnotation annotation ? annotation.Created.ToString(DateFormat) : string.Empty;
public string End => Record is IRangeAnnotation range ? formatTimeSpan(range.End) : string.Empty;
public string Note => Record is IRangeAnnotation range ? range.Text : string.Empty;
public string Title => Record is Clip range ? range.Title : string.Empty;
public string Note => (Record as IRangeAnnotation)?.Text ?? string.Empty;
public string Title => (Record as Clip)?.Title ?? string.Empty;
public BookRecordEntry(IRecord record) => Record = record;
private static string formatTimeSpan(TimeSpan timeSpan)

View File

@@ -3,7 +3,6 @@ using Avalonia.Controls;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class DescriptionDisplayDialog : Window

View File

@@ -14,7 +14,7 @@ namespace LibationAvalonia.Dialogs
protected bool CancelOnEscape { get; set; } = true;
protected bool SaveOnEnter { get; set; } = true;
public bool SaveAndRestorePosition { get; set; }
public Control ControlToFocusOnShow { get; set; }
public Control? ControlToFocusOnShow { get; set; }
protected override Type StyleKeyOverride => typeof(DialogWindow);
public DialogResult DialogResult { get; private set; } = DialogResult.None;
@@ -39,7 +39,7 @@ namespace LibationAvalonia.Dialogs
}
}
private void DialogWindow_Loaded(object sender, Avalonia.Interactivity.RoutedEventArgs e)
private void DialogWindow_Loaded(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (!CanResize)
this.HideMinMaxBtns();
@@ -57,20 +57,20 @@ namespace LibationAvalonia.Dialogs
}
}
private void DialogWindow_Initialized(object sender, EventArgs e)
private void DialogWindow_Initialized(object? sender, EventArgs e)
{
this.WindowStartupLocation = WindowStartupLocation.CenterOwner;
if (SaveAndRestorePosition)
this.RestoreSizeAndLocation(Configuration.Instance);
}
private void DialogWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
private void DialogWindow_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
if (SaveAndRestorePosition)
this.SaveSizeAndLocation(Configuration.Instance);
}
private void DialogWindow_Opened(object sender, EventArgs e)
private void DialogWindow_Opened(object? sender, EventArgs e)
{
ControlToFocusOnShow?.Focus();
}
@@ -86,7 +86,7 @@ namespace LibationAvalonia.Dialogs
protected virtual void CancelAndClose() => Close(DialogResult.Cancel);
protected virtual async Task CancelAndCloseAsync() => await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(CancelAndClose);
private async void DialogWindow_KeyDown(object sender, Avalonia.Input.KeyEventArgs e)
private async void DialogWindow_KeyDown(object? sender, Avalonia.Input.KeyEventArgs e)
{
if (CancelOnEscape && e.Key == Avalonia.Input.Key.Escape)
await CancelAndCloseAsync();

View File

@@ -13,13 +13,13 @@ namespace LibationAvalonia.Dialogs
public class Filter : ViewModels.ViewModelBase
{
public string Name
public string? Name
{
get => field;
set => this.RaiseAndSetIfChanged(ref field, value);
}
public string FilterString
public string? FilterString
{
get => field;
set
@@ -33,7 +33,7 @@ namespace LibationAvalonia.Dialogs
public bool IsTop { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public bool IsBottom { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public QuickFilters.NamedFilter AsNamedFilter() => new(FilterString, Name);
public QuickFilters.NamedFilter? AsNamedFilter() => FilterString is null ? null : new(FilterString, Name);
}
public EditQuickFilters()
@@ -76,7 +76,7 @@ namespace LibationAvalonia.Dialogs
DataContext = this;
}
private void Filter_PropertyChanged(object sender, System.ComponentModel.PropertyChangedEventArgs e)
private void Filter_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (Filters.Any(f => f.IsDefault))
return;
@@ -88,7 +88,7 @@ namespace LibationAvalonia.Dialogs
protected override void SaveAndClose()
{
QuickFilters.ReplaceAll(Filters.Where(f => !f.IsDefault).Select(x => x.AsNamedFilter()));
QuickFilters.ReplaceAll(Filters.Select(x => x.AsNamedFilter()).OfType<QuickFilters.NamedFilter>());
base.SaveAndClose();
}

View File

@@ -6,7 +6,6 @@ using ReactiveUI;
using System.Collections.Generic;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class EditReplacementChars : DialogWindow

View File

@@ -17,7 +17,7 @@ namespace LibationAvalonia.Dialogs;
public partial class EditTemplateDialog : DialogWindow
{
private EditTemplateViewModel _viewModel;
private EditTemplateViewModel? _viewModel;
public EditTemplateDialog()
{
@@ -51,18 +51,18 @@ public partial class EditTemplateDialog : DialogWindow
{
var dataGrid = sender as DataGrid;
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
var item = (dataGrid?.SelectedItem as Tuple<string, string, string>)?.Item3;
if (string.IsNullOrWhiteSpace(item)) return;
var text = userEditTbox.Text;
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.Text = text?.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
userEditTbox.CaretIndex += item.Length;
}
protected override async Task SaveAndCloseAsync()
{
if (!await _viewModel.Validate())
if (_viewModel is null || !await _viewModel.Validate())
return;
await base.SaveAndCloseAsync();
@@ -101,7 +101,7 @@ public partial class EditTemplateDialog : DialogWindow
=> Go.To.Url(@"ht" + "tps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md");
// hold the work-in-progress value. not guaranteed to be valid
public string UserTemplateText
public string? UserTemplateText
{
get => field;
set
@@ -111,7 +111,7 @@ public partial class EditTemplateDialog : DialogWindow
}
}
public string WarningText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string? WarningText { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public string Description { get; }
@@ -147,7 +147,7 @@ public partial class EditTemplateDialog : DialogWindow
// \books\author with a very <= normal line break on space between words
// long name\narrator narrator
// \title <= line break on the zero-with space we added before slashes
string slashWrap(string val) => val.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}");
string slashWrap(string? val) => val?.Replace(sing, $"{ZERO_WIDTH_SPACE}{sing}") ?? string.Empty;
WarningText
= !TemplateEditor.EditingTemplate.HasWarnings

View File

@@ -0,0 +1,94 @@
<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"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
xmlns:vm="clr-namespace:LibationUiBase;assembly=LibationUiBase"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="450"
Width="800" Height="450"
x:Class="LibationAvalonia.Dialogs.FindBetterQualityBooksDialog"
x:DataType="vm:FindBetterQualityBooksViewModel"
Title="Scan Audible for Better Quality Audiobooks">
<Grid Margin="5" RowDefinitions="*,Auto">
<DataGrid
Name="booksDataGrid"
GridLinesVisibility="All"
CanUserReorderColumns="True"
CanUserResizeColumns="True"
CanUserSortColumns="True"
AutoGenerateColumns="False"
IsReadOnly="True"
ItemsSource="{CompiledBinding Books}">
<DataGrid.Styles>
<Style x:DataType="vm:BookDataViewModel" Selector="DataGridRow">
<Setter Property="Background" Value="{CompiledBinding ScanStatus, Converter={x:Static dialogs:FindBetterQualityBooksDialog.RowConverter }}" />
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTextColumn
Width="120"
IsReadOnly="False"
Binding="{CompiledBinding Asin}"
Header="ASIN"/>
<DataGridTextColumn
Width="120"
IsReadOnly="True"
Binding="{CompiledBinding Title}"
Header="Title"/>
<DataGridTextColumn
Width="120"
IsReadOnly="True"
Binding="{CompiledBinding FoundFile}"
Header="Best Found File"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding Codec}"
Header="Existing&#xa;Codec"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
SortMemberPath="Bitrate"
Binding="{CompiledBinding BitrateString}"
Header="Existing&#xa;Bitrate"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding AvailableCodec}"
Header="Available&#xa;Codec"/>
<DataGridTextColumn
Width="90"
IsReadOnly="True"
SortMemberPath="AvailableBitrate"
Binding="{CompiledBinding AvailableBitrateString}"
Header="Available&#xa;Bitrate"/>
<DataGridCheckBoxColumn
Width="90"
IsReadOnly="True"
Binding="{CompiledBinding IsSignificant}"
Header="Significantly&#xa;Greater?"/>
</DataGrid.Columns>
</DataGrid>
<Grid Margin="0,5,0,0" Grid.Row="1"
ColumnDefinitions="Auto,Auto,*,Auto">
<CheckBox IsChecked="{Binding ScanWidevine, Mode=TwoWay}" Content="{x:Static vm:FindBetterQualityBooksViewModel.UseWidevineSboxText }" Margin="0,0,5,0" />
<Button Name="scanBtn" IsEnabled="False" Grid.Column="1" Classes="SaveButton" Content="{Binding ScanButtonText}" Click="Scan_Click" />
<TextBlock Grid.Column="2" VerticalAlignment="Center" Text="{Binding ScanCount}" Margin="10,0,0,0" />
<Button Grid.Column="3" Classes="SaveButton" Content="{Binding MarkBooksButtonText}"
IsVisible="{Binding SignificantCount}" Click="MarkBooks_Click" />
</Grid>
</Grid>
</Window>

View File

@@ -0,0 +1,159 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Data.Converters;
using Avalonia.Media;
using Avalonia.Threading;
using DataLayer;
using LibationUiBase;
using LibationUiBase.Forms;
using LibationUiBase.ProcessQueue;
using System;
using System.Linq;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs;
public partial class FindBetterQualityBooksDialog : DialogWindow
{
private FindBetterQualityBooksViewModel VM { get; }
private Task? scanTask;
public FindBetterQualityBooksDialog()
{
InitializeComponent();
if (Design.IsDesignMode)
{
var library = Enumerable.Repeat(MockLibraryBook.CreateBook(), 3);
DataContext = VM = new FindBetterQualityBooksViewModel()
{
Books = new AvaloniaList<BookDataViewModel>(library.Select(lb => new BookDataViewModel(lb)))
};
VM.Books[0].AvailableCodec = "xHE-AAC";
VM.Books[0].AvailableBitrate = 256;
VM.Books[0].ScanStatus = ProcessBookStatus.Completed;
VM.Books[1].ScanStatus = ProcessBookStatus.Failed;
VM.Books[2].ScanStatus = ProcessBookStatus.Cancelled;
VM.SignificantCount = 1;
}
else
{
DataContext = VM = new FindBetterQualityBooksViewModel();
VM.BookScanned += VM_BookScanned;
VM.PropertyChanged += VM_PropertyChanged;
Opened += Opened_LoadLibrary;
Opened += Opened_ShowInitialMessage;
Closing += FindBetterQualityBooksDialog_Closing;
}
}
private async void Opened_ShowInitialMessage(object? sender, System.EventArgs e)
{
if (!VM.ShowFindBetterQualityBooksHelp)
return;
var result = await MessageBox.Show(this, FindBetterQualityBooksViewModel.InitialMessage, Title ?? "", MessageBoxButtons.YesNo, MessageBoxIcon.Information, MessageBoxDefaultButton.Button1);
if (result == DialogResult.No)
{
VM.ShowFindBetterQualityBooksHelp = false;
}
}
private async void Opened_LoadLibrary(object? sender, System.EventArgs e)
{
var library = await Task.Run(() => DbContexts.GetLibrary_Flat_NoTracking());
VM.Books = new AvaloniaList<BookDataViewModel>(library.Where(FindBetterQualityBooksViewModel.ShouldScan).Select(lb => new BookDataViewModel(lb)));
Dispatcher.UIThread.Invoke(() => scanBtn.IsEnabled = true);
}
private void VM_BookScanned(object? sender, BookDataViewModel e)
{
Dispatcher.UIThread.Invoke(() => booksDataGrid.ScrollIntoView(e, booksDataGrid.Columns[0]));
}
private void VM_PropertyChanged(object? sender, System.ComponentModel.PropertyChangedEventArgs e)
{
if (e.PropertyName == nameof(FindBetterQualityBooksViewModel.IsScanning))
{
Dispatcher.UIThread.Invoke(() => scanBtn.IsEnabled = true);
}
}
private async void FindBetterQualityBooksDialog_Closing(object? sender, WindowClosingEventArgs e)
{
if (scanTask is not null)
{
await scanTask;
scanTask = null;
Dispatcher.UIThread.Invoke(Close);
}
}
protected override void OnClosing(WindowClosingEventArgs e)
{
if (scanTask is not null)
{
this.SaveSizeAndLocation(LibationFileManager.Configuration.Instance);
e.Cancel = true;
VM.StopScan();
}
base.OnClosing(e);
}
public void Scan_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
(sender as Button)?.IsEnabled = false;
scanTask = Task.Run(async () =>
{
try
{
if (VM.IsScanning)
VM.StopScan();
else
await VM.ScanAsync();
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to scan for better quality books");
await MessageBox.Show(this, "An error occurred while scanning for better quality books. Please see the logs for more information.", "Error Scanning Books", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
Dispatcher.UIThread.Invoke(() =>
{
VM.IsScanning = false;
(sender as Button)?.IsEnabled = true;
});
}
});
}
public async void MarkBooks_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
(sender as Button)?.IsEnabled = false;
try
{
await VM.MarkBooksAsync();
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Failed to mark books as Not Liberated");
await MessageBox.Show(this, "An error occurred while marking books as Not Liberated. Please see the logs for more information.", "Error Marking Books", MessageBoxButtons.OK, MessageBoxIcon.Error);
}
finally
{
Dispatcher.UIThread.Invoke(() => (sender as Button)?.IsEnabled = true);
}
}
public static FuncValueConverter<ProcessBookStatus, IBrush?> RowConverter { get; } = new(status =>
{
var brush = status switch
{
ProcessBookStatus.Completed => "ProcessQueueBookCompletedBrush",
ProcessBookStatus.Cancelled => "ProcessQueueBookCancelledBrush",
ProcessBookStatus.Failed => "ProcessQueueBookFailedBrush",
_ => null,
};
return brush is not null && App.Current.TryGetResource(brush, App.Current.ActualThemeVariant, out var res) ? res as Brush : null;
});
}

View File

@@ -9,8 +9,8 @@ namespace LibationAvalonia.Dialogs
{
public partial class ImageDisplayDialog : DialogWindow, INotifyPropertyChanged
{
public string PictureFileName { get; set; }
public string BookSaveDirectory { get; set; }
public string? PictureFileName { get; set; }
public string? BookSaveDirectory { get; set; }
private readonly BitmapHolder _bitmapHolder = new BitmapHolder();
@@ -50,7 +50,7 @@ namespace LibationAvalonia.Dialogs
try
{
_bitmapHolder.CoverImage.Save(selectedFile);
_bitmapHolder.CoverImage?.Save(selectedFile);
}
catch (Exception ex)
{
@@ -61,7 +61,7 @@ namespace LibationAvalonia.Dialogs
public class BitmapHolder : ViewModels.ViewModelBase
{
public Bitmap CoverImage { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
public Bitmap? CoverImage { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
}
}
}

View File

@@ -17,11 +17,11 @@ namespace LibationAvalonia.Dialogs
Configuration.KnownDirectories.MyDocs
};
public string Directory { get; set; }
public string? Directory { get; set; }
}
private readonly DirSelectOptions dirSelectOptions;
public string SelectedDirectory => dirSelectOptions.Directory;
public string? SelectedDirectory => dirSelectOptions.Directory;
public LibationFilesDialog() : base(saveAndRestorePosition: false)
{
@@ -42,7 +42,7 @@ namespace LibationAvalonia.Dialogs
public async void Save_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (!System.IO.Directory.Exists(SelectedDirectory))
if (SelectedDirectory is not null && !Directory.Exists(SelectedDirectory))
{
try
{

View File

@@ -9,21 +9,21 @@ namespace LibationAvalonia.Dialogs
private class liberatedComboBoxItem
{
public LiberatedStatus Status { get; set; }
public string Text { get; set; }
public override string ToString() => Text;
public string? Text { get; set; }
public override string? ToString() => Text;
}
public LiberatedStatus BookLiberatedStatus { get; private set; }
private liberatedComboBoxItem _selectedStatus;
public object SelectedItem
private liberatedComboBoxItem? _selectedStatus;
public object? SelectedItem
{
get => _selectedStatus;
set
{
_selectedStatus = value as liberatedComboBoxItem;
BookLiberatedStatus = _selectedStatus.Status;
BookLiberatedStatus = _selectedStatus?.Status ?? default;
}
}
@@ -36,7 +36,7 @@ namespace LibationAvalonia.Dialogs
public LiberatedStatusBatchManualDialog(bool isPdf) : this()
{
if (isPdf)
this.Title = this.Title.Replace("book", "PDF");
Title = Title?.Replace("book", "PDF");
}
public LiberatedStatusBatchManualDialog()

View File

@@ -11,9 +11,7 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class LocateAudiobooksDialog : DialogWindow

View File

@@ -9,7 +9,6 @@ using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.Dialogs.Login
{
public class AvaloniaLoginChoiceEager : ILoginChoiceEager

View File

@@ -10,9 +10,9 @@ namespace LibationAvalonia.Dialogs.Login
{
public partial class LoginExternalDialog : DialogWindow
{
public Account Account { get; }
public string ExternalLoginUrl { get; }
public string ResponseUrl { get; set; }
public Account? Account { get; }
public string? ExternalLoginUrl { get; }
public string? ResponseUrl { get; set; }
public LoginExternalDialog() : base(saveAndRestorePosition: false)
{
@@ -54,7 +54,10 @@ namespace LibationAvalonia.Dialogs.Login
public async void CopyUrlToClipboard_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await App.MainWindow.Clipboard.SetTextAsync(ExternalLoginUrl);
{
if (App.MainWindow?.Clipboard is not null)
await App.MainWindow.Clipboard.SetTextAsync(ExternalLoginUrl);
}
public void LaunchInBrowser_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> Go.To.Url(ExternalLoginUrl);

View File

@@ -22,7 +22,7 @@ namespace LibationAvalonia.Dialogs
public void Button1_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
var vm = DataContext as MessageBoxViewModel;
var dialogResult = vm.Buttons switch
var dialogResult = vm?.Buttons switch
{
MessageBoxButtons.OK => DialogResult.OK,
MessageBoxButtons.OKCancel => DialogResult.OK,
@@ -38,7 +38,7 @@ namespace LibationAvalonia.Dialogs
public void Button2_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
var vm = DataContext as MessageBoxViewModel;
var dialogResult = vm.Buttons switch
var dialogResult = vm?.Buttons switch
{
MessageBoxButtons.OKCancel => DialogResult.Cancel,
MessageBoxButtons.AbortRetryIgnore => DialogResult.Retry,
@@ -53,7 +53,7 @@ namespace LibationAvalonia.Dialogs
public void Button3_Click(object sender, Avalonia.Interactivity.RoutedEventArgs args)
{
var vm = DataContext as MessageBoxViewModel;
var dialogResult = vm.Buttons switch
var dialogResult = vm?.Buttons switch
{
MessageBoxButtons.AbortRetryIgnore => DialogResult.Ignore,
MessageBoxButtons.YesNoCancel => DialogResult.Cancel,

View File

@@ -3,7 +3,6 @@ using LibationSearchEngine;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class SearchSyntaxDialog : DialogWindow

View File

@@ -9,7 +9,7 @@ namespace LibationAvalonia.Dialogs
{
public bool IsNewUser { get; private set; }
public bool IsReturningUser { get; private set; }
public ComboBoxItem SelectedTheme { get; set; }
public ComboBoxItem? SelectedTheme { get; set; }
public SetupDialog()
{
InitializeComponent();

View File

@@ -4,7 +4,7 @@ namespace LibationAvalonia.Dialogs
{
public partial class TagsBatchDialog : DialogWindow
{
public string NewTags { get; set; }
public string? NewTags { get; set; }
public TagsBatchDialog()
{
InitializeComponent();

View File

@@ -10,7 +10,6 @@ using System.Linq;
using Avalonia.Platform.Storage;
using LibationUiBase.Forms;
#nullable enable
namespace LibationAvalonia.Dialogs;
public partial class ThemePickerDialog : DialogWindow

View File

@@ -129,7 +129,7 @@ namespace LibationAvalonia.Dialogs
}
private IDisposable tracker;
private void CheckboxPropertyChanged(Tuple<object, PropertyChangedEventArgs> e)
private void CheckboxPropertyChanged(Tuple<object?, PropertyChangedEventArgs> e)
{
if (e.Item2.PropertyName == nameof(CheckBoxViewModel.IsChecked))
CheckedBooksCount = DeletedBooks.Count(b => b.IsChecked);

View File

@@ -9,11 +9,11 @@ namespace LibationAvalonia.Dialogs
public partial class UpgradeNotificationDialog : DialogWindow
{
private const string UpdateMessage = "There is a new version available. Would you like to update?\r\n\r\nAfter you close Libation, the upgrade will start automatically.";
public string TopMessage { get; }
public string DownloadLinkText { get; }
public string ReleaseNotes { get; }
public string OkText { get; }
private string PackageUrl { get; }
public string? TopMessage { get; }
public string? DownloadLinkText { get; }
public string? ReleaseNotes { get; }
public string? OkText { get; }
private string? PackageUrl { get; }
public UpgradeNotificationDialog()
{
if (Design.IsDesignMode)

View File

@@ -7,7 +7,6 @@ using LibationFileManager;
using System;
using System.Linq;
#nullable enable
namespace LibationAvalonia
{
public static class FormSaveExtension

View File

@@ -11,6 +11,7 @@
<PublishReadyToRun>true</PublishReadyToRun>
<AppendTargetFrameworkToOutputPath>false</AppendTargetFrameworkToOutputPath>
<AppendRuntimeIdentifierToOutputPath>false</AppendRuntimeIdentifierToOutputPath>
<Nullable>enable</Nullable>
<StartupObject />
</PropertyGroup>

View File

@@ -11,9 +11,7 @@ using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Avalonia.Platform;
#nullable enable
namespace LibationAvalonia
{
public class MessageBox
@@ -90,6 +88,7 @@ namespace LibationAvalonia
/// <param name="caption">The text to display in the title bar of the message box.</param>
/// <param name="exception">Exception to log.</param>
public static async Task ShowAdminAlert(Window? owner, string text, string caption, Exception exception)
=> await Dispatcher.UIThread.InvokeAsync(async () =>
{
// for development and debugging, show me what broke!
if (System.Diagnostics.Debugger.IsAttached)
@@ -105,7 +104,7 @@ namespace LibationAvalonia
var form = new MessageBoxAlertAdminDialog(text, caption, exception);
await DisplayWindow(form, owner);
}
});
private static async Task<DialogResult> ShowCoreAsync(Window? owner, string message, string caption, MessageBoxButtons buttons, MessageBoxIcon icon, MessageBoxDefaultButton defaultButton, bool saveAndRestorePosition = true)
=> await Dispatcher.UIThread.InvokeAsync(async () =>

View File

@@ -12,9 +12,7 @@ using LibationAvalonia.Dialogs;
using Avalonia.Threading;
using FileManager;
using System.Linq;
using System.Reflection;
#nullable enable
namespace LibationAvalonia
{
static class Program

View File

@@ -11,7 +11,6 @@ using System.Linq.Expressions;
using System.Reflection;
using System.Collections.Frozen;
#nullable enable
namespace LibationAvalonia;
public class ChardonnayTheme : IUpdatable, ICloneable

View File

@@ -5,7 +5,6 @@ using LibationFileManager;
using Newtonsoft.Json;
using System;
#nullable enable
namespace LibationAvalonia.Themes;
public class ChardonnayThemePersister : JsonFilePersister<ChardonnayTheme>

View File

@@ -1,30 +0,0 @@
using Avalonia.Controls;
using Avalonia.Controls.Templates;
using LibationAvalonia.ViewModels;
using System;
namespace LibationAvalonia
{
public class ViewLocator : IDataTemplate
{
public Control Build(object data)
{
var name = data.GetType().FullName!.Replace("ViewModel", "View");
var type = Type.GetType(name);
if (type != null)
{
return (Control)Activator.CreateInstance(type)!;
}
else
{
return new TextBlock { Text = "Not Found: " + name };
}
}
public bool Match(object data)
{
return data is ViewModelBase;
}
}
}

View File

@@ -5,7 +5,7 @@ namespace LibationAvalonia.ViewModels.Dialogs
{
public class MessageBoxViewModel
{
public string Message { get => field; set => field = value; }
public string? Message { get => field; set => field = value; }
public string Caption { get; } = "Message Box";
private MessageBoxButtons _button;
private MessageBoxIcon _icon;

View File

@@ -1,6 +1,5 @@
using ReactiveUI;
#nullable enable
namespace LibationAvalonia.ViewModels
{
public class LiberateStatusButtonViewModel : ViewModelBase

View File

@@ -5,7 +5,6 @@ using ReactiveUI;
using System.Collections.Generic;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

View File

@@ -5,7 +5,6 @@ using LibationFileManager;
using System;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.ViewModels
{
partial class MainVM

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