Compare commits

..

69 Commits

Author SHA1 Message Date
Robert
db9c810c2d incr ver 2026-01-13 20:05:28 -05:00
rmcrackan
ae18ae1b8d Merge pull request #1558 from Mbucari/master
Fix export, remove spatial audio option, and add file move progress.
2026-01-13 19:56:24 -05:00
Michael Bucari-Tovo
c1298e9ff6 Update dependencies 2026-01-13 12:02:36 -07:00
Michael Bucari-Tovo
647eb8b9d9 Use MemoryPool 2026-01-13 11:35:59 -07:00
Michael Bucari-Tovo
fd64d394c2 Remove spatial audio options #1553 2026-01-13 09:43:51 -07:00
Michael Bucari-Tovo
f026d415bd Fix library export #1552 2026-01-13 09:35:08 -07:00
Michael Bucari-Tovo
3948e25c99 Add file move progress 2026-01-12 14:34:52 -07:00
Robert
6df47d4d9c incr ver 2026-01-12 16:32:16 -05:00
rmcrackan
0103c879f2 Merge pull request #1551 from Mbucari/master
Update dependencies
2026-01-12 14:17:05 -05:00
Michael Bucari-Tovo
79e2bca8fc Update dependencies 2026-01-12 10:42:04 -07:00
rmcrackan
7bbc681767 Merge pull request #1548 from Mbucari/master
Improved trash bin, new book properties, and bug fixes
2026-01-11 17:54:53 -05:00
Mbucari
c95dccd246 Add confirmation dialog when removing books from Audible 2026-01-11 15:45:56 -07:00
Mbucari
94cf665be7 Fix books not being marked absent on large imports 2026-01-11 15:40:11 -07:00
Mbucari
7d38874257 Merge branch 'rmcrackan:master' into master 2026-01-09 16:49:09 -07:00
Michael Bucari-Tovo
659f793eb8 Improve TrashBinDialog functionality
- Use the main display grid control to display deleted books
- Added search functionality for deleted books. This required creating a temporary search index in the `InProgress` folder. The products grid control now uses an instance of `ISearchEngine` to filter its grid entries.  The main grid uses a singleton instance of `MainSearchEngine`, which merely wraps `SearchEngineCommands.Search()`.  The TrashBinDialogs use `TempSearchEngine`.
- Users can now batch select `Everyting` as well as `Audible Plus Books`

Avalonia:
  - Refactor main grid context menus to no longer require reflection
2026-01-09 16:47:37 -07:00
MBucari
6f7cd4d5b5 Fix narrators with null ASINs not importing (#1545 ) 2026-01-08 19:57:47 -07:00
Michael Bucari-Tovo
068f37319f Add option to adjust minimum file duration
when splitting audiobooks into multiple files by chapter
2026-01-08 18:36:57 -07:00
Michael Bucari-Tovo
dc58a101af Add cli export option to specify Asins 2026-01-08 16:54:43 -07:00
Michael Bucari-Tovo
7b68415b02 Add more properties to search engine and library export
- Add `IsAudiblePlus` to search engine
- Add `IsAudiblePlus` and `AbsentFromLastScan` properties to library export
- Refactor library export ToXlsx method
  - Make nullable
  - Improve readability and extensability
  - Use same column header names as CSV
  - Extend export methods to accept optional list of books (future use)
2026-01-08 15:14:20 -07:00
Michael Bucari-Tovo
1514de54da Add menu option to remove Plus books from Audible 2026-01-08 13:00:47 -07:00
Michael Bucari-Tovo
804bac5c4c Add LibraryBook.IsAudiblePlus property 2026-01-07 15:50:23 -07:00
Michael Bucari-Tovo
3fa805d51f Verify correct Serilog parameters used (#1536 ) 2026-01-07 15:10:54 -07:00
rmcrackan
1eff725125 Merge pull request #1541 from rmcrackan/dependabot/npm_and_yarn/preact-10.28.2
Bump preact from 10.28.0 to 10.28.2
2026-01-07 14:35:44 -05:00
dependabot[bot]
af1b1a70ae Bump preact from 10.28.0 to 10.28.2
Bumps [preact](https://github.com/preactjs/preact) from 10.28.0 to 10.28.2.
- [Release notes](https://github.com/preactjs/preact/releases)
- [Commits](https://github.com/preactjs/preact/compare/10.28.0...10.28.2)

---
updated-dependencies:
- dependency-name: preact
  dependency-version: 10.28.2
  dependency-type: indirect
...

Signed-off-by: dependabot[bot] <support@github.com>
2026-01-07 19:29:23 +00:00
Robert
042f2e135a incr ver 2026-01-06 07:28:33 -05:00
Robert
930fc3da58 Merge branch 'master' of https://github.com/rmcrackan/Libation 2026-01-06 07:26:40 -05:00
Robert
90e8d03590 add github doc contributor 2026-01-06 07:26:35 -05:00
rmcrackan
ee908a4f13 Merge pull request #1534 from Mbucari/master
Improve LocateAudiobooksDialog and fix Classic upgrade dialog
2026-01-06 07:26:08 -05:00
rmcrackan
70ec31303b Merge pull request #1535 from niontrix/patch-1
Document Pacstall for program installation on Linux
2026-01-06 07:16:14 -05:00
Tobias Heinlein
319d547aa0 Document Pacstall for program installation on Linux
Added information about Pacstall for easier program installation.
2026-01-06 10:33:20 +01:00
Mbucari
a59e42e7c9 Merge branch 'rmcrackan:master' into master 2026-01-05 18:50:51 -07:00
Michael Bucari-Tovo
af2e89dd1e Remove UpgradeNotificationDialog form positioning
Hopefully address #1531
2026-01-05 18:50:28 -07:00
rmcrackan
b2c5884e11 Merge pull request #1533 from rmcrackan/rmcrackan/log-friendly2
Rmcrackan/log friendly2
2026-01-05 19:30:43 -05:00
Robert
11d9cdefe2 typo 2026-01-05 19:30:03 -05:00
Robert
54485ae150 LogFriendly 2026-01-05 19:28:50 -05:00
Michael Bucari-Tovo
4bd641ee50 Improve LocateAudiobooksDialog
- Move LocatedAudiobooksViewModel to LibationUiBase
- Refactor Avalonia and Classic displays to use same view model.
- Do scan on background task
2026-01-05 15:38:15 -07:00
Robert
6e56297434 LogFriendly 2026-01-05 14:00:43 -05:00
rmcrackan
e1f59eadbd Merge pull request #1529 from rmcrackan/rmcrackan/doc-github-sources
Doc re-directs to include pointers to github sources
2026-01-04 09:49:57 -05:00
Robert
cfda065219 Doc re-directs to include pointers to github sources 2026-01-04 09:48:14 -05:00
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
radiorambo
cf932bd66c redirect old doc links to new doc links 2025-12-23 19:58:14 +05:30
232 changed files with 5364 additions and 1986 deletions

View File

@@ -3,7 +3,8 @@ 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",
description:
"Libation: Liberate your Library - A free application for downloading your Audible audiobooks",
head: [["link", { rel: "icon", href: "/favicon.ico" }]],
cleanUrls: true,
themeConfig: {
@@ -26,14 +27,20 @@ export default defineConfig({
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: "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: "Overview", link: "/docs/index" },
{ text: "Getting Started", link: "/docs/getting-started" },
{ text: "FAQ", link: "/docs/frequently-asked-questions" },
{
@@ -56,7 +63,10 @@ export default defineConfig({
text: "Features",
collapsed: false,
items: [
{ text: "Audio File Formats", link: "/docs/features/audio-file-formats" },
{
text: "Audio File Formats",
link: "/docs/features/audio-file-formats",
},
{ text: "Naming Templates", link: "/docs/features/naming-templates" },
{
text: "Searching & Filtering",
@@ -67,12 +77,19 @@ export default defineConfig({
{
text: "Advanced",
collapsed: false,
items: [{ text: "Advanced Topics", link: "/docs/advanced/advanced" }],
},
{
text: "Development",
collapsed: false,
items: [
{ text: "Advanced Topics", link: "/docs/advanced/advanced" },
{
text: "Linux Development Setup",
link: "/docs/advanced/linux-development-setup-using-nix",
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" },
],
},
],
@@ -81,7 +98,9 @@ export default defineConfig({
level: "deep",
},
socialLinks: [{ icon: "github", link: "https://github.com/rmcrackan/Libation" }],
socialLinks: [
{ icon: "github", link: "https://github.com/rmcrackan/Libation" },
],
search: {
provider: "local",

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/advanced/advanced
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/advanced/advanced.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/features/audio-file-formats
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/audio-file-formats.md))

3
Documentation/Docker.md Normal file
View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/installation/docker
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/docker.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/frequently-asked-questions
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/frequently-asked-questions.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/getting-started
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/getting-started.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/installation/linux
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/linux.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/installation/mac
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/installation/mac.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/development/nix-linux-setup
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/development/nix-linux-setup.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/features/naming-templates
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/naming-templates.md))

View File

@@ -0,0 +1,3 @@
# This page has been moved to https://getlibation.com/docs/features/searching-and-filtering
([page in github](https://github.com/rmcrackan/Libation/blob/master/docs/features/searching-and-filtering.md))

View File

@@ -1,10 +1,15 @@
# 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)
## Features
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
- **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
@@ -17,26 +22,34 @@ All documentation has been moved to our new site: [getlibation.com](https://getl
## Development
### Documentation
Grab the latest release for your platform from the [Releases Page](https://github.com/rmcrackan/Libation/releases/latest).
The documentation is built with [VitePress](https://vitepress.dev/) and located in the `docs` directory. For more information like [markdown syntax](https://vitepress.dev/guide/markdown#advanced-configuration) and [routing](https://vitepress.dev/guide/routing) or other features, refer [VitePress documentation](https://vitepress.dev/guide).
## Documentation
**Prerequisites**: Node.js 18+
Comprehensive documentation is available in the `docs` directory and on our [Documentation Site](https://getlibation.com/docs).
**Commands**:
- [Getting Started](https://getlibation.com/docs/getting-started)
- [FAQ](https://getlibation.com/docs/frequently-asked-questions)
```bash
# Install dependencies
npm install
## Development & Contributing
# Start local dev server (http://localhost:5173)
npm run docs:dev
We welcome contributions!
# Build for production (output: docs/.vitepress/dist)
npm run docs:build
- **[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.
# Preview production build
npm run docs:preview
```
## Community & Support
**Note**: New pages are automatically routed based on their folder structure (e.g., `docs/docs/index.md` maps to `/docs/index`). To add them to the sidebar, update the `sidebar` configuration in `.vitepress/config.js`.
- **[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.2.1" />
<PackageReference Include="AAXClean.Codecs" Version="2.1.3.1" />
</ItemGroup>
<ItemGroup>

View File

@@ -25,66 +25,29 @@ 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);
}
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than 3 seconds long but still has some audio frames, those frames are combined with the following chapter and not split into a new file.
I also implemented file naming by chapter title. When 2 or more consecutive chapters are combined, the first of the combined chapter's title is used in the file name. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
*/
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
if (AaxFile is null) return false;
var chapters = DownloadOptions.ChapterInfo.Chapters;
// Ensure split files are at least minChapterLength in duration.
var splitChapters = new ChapterInfo(DownloadOptions.ChapterInfo.StartOffset);
var runningTotal = TimeSpan.Zero;
string title = "";
for (int i = 0; i < chapters.Count; i++)
{
if (runningTotal == TimeSpan.Zero)
title = chapters[i].Title;
runningTotal += chapters[i].Duration;
if (runningTotal >= minChapterLength)
{
splitChapters.AddChapter(title, runningTotal);
runningTotal = TimeSpan.Zero;
}
}
try
{
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
await (AaxConversion = decryptMultiAsync(AaxFile, DownloadOptions.ChapterInfo));
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);

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>13.0.0.1</Version>
<Version>13.1.3.1</Version>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Octokit" Version="14.0.0" />

View File

@@ -121,7 +121,7 @@ namespace AppScaffolding
}
catch(Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {@WalFile}", walFile);
Log.Logger.Warning(ex, "Could not delete SQLite WAL file: {WalFile}", walFile);
}
}
if (File.Exists(shmFile))
@@ -132,14 +132,17 @@ namespace AppScaffolding
}
catch (Exception ex)
{
Log.Logger.Warning(ex, "Could not delete SQLite SHM file: {@ShmFile}", shmFile);
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

@@ -5,8 +5,12 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="CsvHelper" Version="33.1.0" />
<PackageReference Include="ClosedXML" Version="0.105.0" />
<PackageReference Include="CsvHelper" Version="33.1.0">
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
</PackageReference>
<PackageReference Include="ClosedXML" Version="0.105.0">
<PrivateAssets>compile;contentFiles;build;buildMultitargeting;buildTransitive;analyzers;native</PrivateAssets>
</PackageReference>
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,142 @@
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Linq;
#nullable enable
namespace ApplicationServices;
internal class ExportDto(LibraryBook libBook)
{
[Name("Account")]
public string Account { get; } = libBook.Account;
[Name("Date Added to library")]
public DateTime DateAdded { get; } = libBook.DateAdded;
[Name("Is Audible Plus?")]
public bool IsAudiblePlus { get; } = libBook.IsAudiblePlus;
[Name("Absent from last scan?")]
public bool AbsentFromLastScan { get; } = libBook.AbsentFromLastScan;
[Name("Audible Product Id")]
public string AudibleProductId { get; } = libBook.Book.AudibleProductId;
[Name("Locale")]
public string Locale { get; } = libBook.Book.Locale;
[Name("Title")]
public string Title { get; } = libBook.Book.Title;
[Name("Subtitle")]
public string Subtitle { get; } = libBook.Book.Subtitle;
[Name("Authors")]
public string AuthorNames { get; } = libBook.Book.AuthorNames;
[Name("Narrators")]
public string NarratorNames { get; } = libBook.Book.NarratorNames;
[Name("Length In Minutes")]
public int LengthInMinutes { get; } = libBook.Book.LengthInMinutes;
[Name("Description")]
public string Description { get; } = libBook.Book.Description;
[Name("Publisher")]
public string Publisher { get; } = libBook.Book.Publisher;
[Name("Has PDF")]
public bool HasPdf { get; } = libBook.Book.HasPdf;
[Name("Series Names")]
public string SeriesNames { get; } = libBook.Book.SeriesNames();
[Name("Series Order")]
public string SeriesOrder { get; } = libBook.Book.SeriesLink?.Any() is true ? string.Join(", ", libBook.Book.SeriesLink.Select(sl => $"{sl.Order} : {sl.Series.Name}")) : "";
[Name("Community Rating: Overall")]
public float? CommunityRatingOverall { get; } = ZeroIsNull(libBook.Book.Rating?.OverallRating);
[Name("Community Rating: Performance")]
public float? CommunityRatingPerformance { get; } = ZeroIsNull(libBook.Book.Rating?.PerformanceRating);
[Name("Community Rating: Story")]
public float? CommunityRatingStory { get; } = ZeroIsNull(libBook.Book.Rating?.StoryRating);
[Name("Cover Id")]
public string PictureId { get; } = libBook.Book.PictureId;
[Name("Cover Id Large")]
public string PictureLarge { get; } = libBook.Book.PictureLarge;
[Name("Is Abridged?")]
public bool IsAbridged { get; } = libBook.Book.IsAbridged;
[Name("Date Published")]
public DateTime? DatePublished { get; } = libBook.Book.DatePublished;
[Name("Categories")]
public string CategoriesNames { get; } = string.Join("; ", libBook.Book.LowestCategoryNames());
[Name("My Rating: Overall")]
public float? MyRatingOverall { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.OverallRating);
[Name("My Rating: Performance")]
public float? MyRatingPerformance { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.PerformanceRating);
[Name("My Rating: Story")]
public float? MyRatingStory { get; } = ZeroIsNull(libBook.Book.UserDefinedItem.Rating.StoryRating);
[Name("My Libation Tags")]
public string MyLibationTags { get; } = libBook.Book.UserDefinedItem.Tags;
[Name("Book Liberated Status")]
public string BookStatus { get; } = libBook.Book.UserDefinedItem.BookStatus.ToString();
[Name("PDF Liberated Status")]
public string? PdfStatus { get; } = libBook.Book.UserDefinedItem.PdfStatus.ToString();
[Name("Content Type")]
public string ContentType { get; } = libBook.Book.ContentType.ToString();
[Name("Language")]
public string Language { get; } = libBook.Book.Language;
[Name("Last Downloaded")]
public DateTime? LastDownloaded { get; } = libBook.Book.UserDefinedItem.LastDownloaded;
[Name("Last Downloaded Version")]
public string? LastDownloadedVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedVersion?.ToString();
[Name("Is Finished?")]
public bool IsFinished { get; } = libBook.Book.UserDefinedItem.IsFinished;
[Name("Is Spatial?")]
public bool IsSpatial { get; } = libBook.Book.IsSpatial;
[Name("Included Until")]
public DateTime? IncludedUntil { get; } = libBook.IncludedUntil;
[Name("Last Downloaded File Version")]
public string? LastDownloadedFileVersion { get; } = libBook.Book.UserDefinedItem.LastDownloadedFileVersion;
[Ignore /* csv ignore */]
public AudioFormat? LastDownloadedFormat { get; } = libBook.Book.UserDefinedItem.LastDownloadedFormat;
[Name("Last Downloaded Codec"), JsonIgnore]
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
[Name("Last Downloaded Sample rate"), JsonIgnore]
public int? SampleRate => LastDownloadedFormat?.SampleRate;
[Name("Last Downloaded Audio Channels"), JsonIgnore]
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
[Name("Last Downloaded Bitrate"), JsonIgnore]
public int? BitRate => LastDownloadedFormat?.BitRate;
private static float? ZeroIsNull(float? value) => value is 0 ? null : value;
}

View File

@@ -0,0 +1,9 @@
using LibationSearchEngine;
#nullable enable
namespace ApplicationServices;
public interface ISearchEngine
{
SearchResultSet? GetSearchResultSet(string? searchString);
}

View File

@@ -220,6 +220,8 @@ namespace ApplicationServices
{
book.AbsentFromLastScan = false;
}
book.SetIncludedUntil(importItem.DtoItem.GetExpirationDate());
book.SetIsAudiblePlus(importItem.DtoItem.IsAyce is true);
});
}
@@ -376,8 +378,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 +387,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 +419,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 +428,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 +516,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 +531,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 +569,7 @@ namespace ApplicationServices
qtyChanges = context.SaveChanges();
}
if (qtyChanges > 0)
BookUserDefinedItemCommitted?.Invoke(null, libraryBooks);
BookUserDefinedItemCommitted?.Invoke(null, nonNullBooks);
return qtyChanges;
}

View File

@@ -3,322 +3,79 @@ using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using System;
using System.Collections.Generic;
using System.Globalization;
using System.Linq;
using System.Reflection;
namespace ApplicationServices
#nullable enable
namespace ApplicationServices;
public static class LibraryExporter
{
public class ExportDto
public static void ToCsv(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
{
public static string GetName(string fieldName)
{
var property = typeof(ExportDto).GetProperty(fieldName);
var attribute = property.GetCustomAttributes(typeof(NameAttribute), true)[0];
var description = (NameAttribute)attribute;
var text = description.Names;
return text[0];
}
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var dtos = libraryBooks.ToDtos();
if (dtos.Count == 0)
return;
[Name("Account")]
public string Account { get; set; }
using var csv = new CsvWriter(new System.IO.StreamWriter(saveFilePath), CultureInfo.CurrentCulture);
csv.WriteHeader(typeof(ExportDto));
csv.NextRecord();
csv.WriteRecords(dtos); }
[Name("Date Added to library")]
public DateTime DateAdded { get; set; }
[Name("Audible Product Id")]
public string AudibleProductId { get; set; }
[Name("Locale")]
public string Locale { get; set; }
[Name("Title")]
public string Title { get; set; }
[Name("Subtitle")]
public string Subtitle { get; set; }
[Name("Authors")]
public string AuthorNames { get; set; }
[Name("Narrators")]
public string NarratorNames { get; set; }
[Name("Length In Minutes")]
public int LengthInMinutes { get; set; }
[Name("Description")]
public string Description { get; set; }
[Name("Publisher")]
public string Publisher { get; set; }
[Name("Has PDF")]
public bool HasPdf { get; set; }
[Name("Series Names")]
public string SeriesNames { get; set; }
[Name("Series Order")]
public string SeriesOrder { get; set; }
[Name("Community Rating: Overall")]
public float? CommunityRatingOverall { get; set; }
[Name("Community Rating: Performance")]
public float? CommunityRatingPerformance { get; set; }
[Name("Community Rating: Story")]
public float? CommunityRatingStory { get; set; }
[Name("Cover Id")]
public string PictureId { get; set; }
[Name("Is Abridged?")]
public bool IsAbridged { get; set; }
[Name("Date Published")]
public DateTime? DatePublished { get; set; }
[Name("Categories")]
public string CategoriesNames { get; set; }
[Name("My Rating: Overall")]
public float? MyRatingOverall { get; set; }
[Name("My Rating: Performance")]
public float? MyRatingPerformance { get; set; }
[Name("My Rating: Story")]
public float? MyRatingStory { get; set; }
[Name("My Libation Tags")]
public string MyLibationTags { get; set; }
[Name("Book Liberated Status")]
public string BookStatus { get; set; }
[Name("PDF Liberated Status")]
public string PdfStatus { get; set; }
[Name("Content Type")]
public string ContentType { get; set; }
[Name("Language")]
public string Language { get; set; }
[Name("LastDownloaded")]
public DateTime? LastDownloaded { get; set; }
[Name("LastDownloadedVersion")]
public string LastDownloadedVersion { get; set; }
[Name("IsFinished")]
public bool IsFinished { get; set; }
[Name("IsSpatial")]
public bool IsSpatial { get; set; }
[Name("Last Downloaded File Version")]
public string LastDownloadedFileVersion { get; set; }
[Ignore /* csv ignore */]
public AudioFormat LastDownloadedFormat { get; set; }
[Name("Last Downloaded Codec"), JsonIgnore]
public string CodecString => LastDownloadedFormat?.CodecString ?? "";
[Name("Last Downloaded Sample rate"), JsonIgnore]
public int? SampleRate => LastDownloadedFormat?.SampleRate;
[Name("Last Downloaded Audio Channels"), JsonIgnore]
public int? ChannelCount => LastDownloadedFormat?.ChannelCount;
[Name("Last Downloaded Bitrate"), JsonIgnore]
public int? BitRate => LastDownloadedFormat?.BitRate;
public static void ToJson(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
{
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var dtos = libraryBooks.ToDtos();
var serializer = new JsonSerializer();
using var writer = new JsonTextWriter(new System.IO.StreamWriter(saveFilePath)) { Formatting = Formatting.Indented };
serializer.Serialize(writer, dtos);
}
public static class LibToDtos
public static void ToXlsx(string saveFilePath, IEnumerable<LibraryBook>? libraryBooks = null)
{
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
=> library.Select(a => new ExportDto
{
Account = a.Account,
DateAdded = a.DateAdded,
AudibleProductId = a.Book.AudibleProductId,
Locale = a.Book.Locale,
Title = a.Book.Title,
Subtitle = a.Book.Subtitle,
AuthorNames = a.Book.AuthorNames,
NarratorNames = a.Book.NarratorNames,
LengthInMinutes = a.Book.LengthInMinutes,
Description = a.Book.Description,
Publisher = a.Book.Publisher,
HasPdf = a.Book.HasPdf,
SeriesNames = a.Book.SeriesNames(),
SeriesOrder = a.Book.SeriesLink.Any() ? a.Book.SeriesLink?.Select(sl => $"{sl.Order} : {sl.Series.Name}").Aggregate((a, b) => $"{a}, {b}") : "",
CommunityRatingOverall = a.Book.Rating?.OverallRating.ZeroIsNull(),
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating.ZeroIsNull(),
CommunityRatingStory = a.Book.Rating?.StoryRating.ZeroIsNull(),
PictureId = a.Book.PictureId,
IsAbridged = a.Book.IsAbridged,
DatePublished = a.Book.DatePublished,
CategoriesNames = string.Join("; ", a.Book.LowestCategoryNames()),
MyRatingOverall = a.Book.UserDefinedItem.Rating.OverallRating.ZeroIsNull(),
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating.ZeroIsNull(),
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating.ZeroIsNull(),
MyLibationTags = a.Book.UserDefinedItem.Tags,
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
ContentType = a.Book.ContentType.ToString(),
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
IsFinished = a.Book.UserDefinedItem.IsFinished,
IsSpatial = a.Book.IsSpatial,
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
}).ToList();
libraryBooks ??= DbContexts.GetLibrary_Flat_NoTracking();
var dtos = libraryBooks.ToDtos();
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
}
public static class LibraryExporter
{
public static void ToCsv(string saveFilePath)
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Library");
var columns = typeof(ExportDto).GetProperties().Where(p => p.GetCustomAttribute<NameAttribute>() is not null).ToArray();
// headers
var currentRow = sheet.FirstRow();
var currentCell = currentRow.FirstCell();
foreach (var column in columns)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
if (!dtos.Any())
return;
using var writer = new System.IO.StreamWriter(saveFilePath);
using var csv = new CsvWriter(writer, System.Globalization.CultureInfo.CurrentCulture);
csv.WriteHeader(typeof(ExportDto));
csv.NextRecord();
csv.WriteRecords(dtos);
currentCell.Value = GetColumnName(column);
currentCell.Style.Font.Bold = true;
currentCell = currentCell.CellRight();
}
public static void ToJson(string saveFilePath)
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var dto in dtos)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var json = JsonConvert.SerializeObject(dtos, Formatting.Indented);
System.IO.File.WriteAllText(saveFilePath, json);
}
currentRow = currentRow.RowBelow();
currentCell = currentRow.FirstCell();
public static void ToXlsx(string saveFilePath)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
using var workbook = new XLWorkbook();
var sheet = workbook.AddWorksheet("Library");
// headers
var columns = new[] {
nameof(ExportDto.Account),
nameof(ExportDto.DateAdded),
nameof(ExportDto.AudibleProductId),
nameof(ExportDto.Locale),
nameof(ExportDto.Title),
nameof(ExportDto.Subtitle),
nameof(ExportDto.AuthorNames),
nameof(ExportDto.NarratorNames),
nameof(ExportDto.LengthInMinutes),
nameof(ExportDto.Description),
nameof(ExportDto.Publisher),
nameof(ExportDto.HasPdf),
nameof(ExportDto.SeriesNames),
nameof(ExportDto.SeriesOrder),
nameof(ExportDto.CommunityRatingOverall),
nameof(ExportDto.CommunityRatingPerformance),
nameof(ExportDto.CommunityRatingStory),
nameof(ExportDto.PictureId),
nameof(ExportDto.IsAbridged),
nameof(ExportDto.DatePublished),
nameof(ExportDto.CategoriesNames),
nameof(ExportDto.MyRatingOverall),
nameof(ExportDto.MyRatingPerformance),
nameof(ExportDto.MyRatingStory),
nameof(ExportDto.MyLibationTags),
nameof(ExportDto.BookStatus),
nameof(ExportDto.PdfStatus),
nameof(ExportDto.ContentType),
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
nameof(ExportDto.IsFinished),
nameof(ExportDto.IsSpatial),
nameof(ExportDto.LastDownloadedFileVersion),
nameof(ExportDto.CodecString),
nameof(ExportDto.SampleRate),
nameof(ExportDto.ChannelCount),
nameof(ExportDto.BitRate)
};
int rowIndex = 1, col = 1;
var headerRow = sheet.Row(rowIndex++);
foreach (var c in columns)
foreach (var column in columns)
{
var headerCell = headerRow.Cell(col++);
headerCell.Value = ExportDto.GetName(c);
headerCell.Style.Font.Bold = true;
var value = column.GetValue(dto);
currentCell.Value = XLCellValue.FromObject(value);
currentCell.Style.DateFormat.Format = currentCell.DataType is XLDataType.DateTime ? dateFormat : string.Empty;
currentCell = currentCell.CellRight();
}
var dateFormat = CultureInfo.CurrentCulture.DateTimeFormat.ShortDatePattern + " HH:mm:ss";
// Add data rows
foreach (var dto in dtos)
{
col = 1;
var row = sheet.Row(rowIndex++);
row.Cell(col++).Value = dto.Account;
row.Cell(col++).SetDate(dto.DateAdded, dateFormat);
row.Cell(col++).Value = dto.AudibleProductId;
row.Cell(col++).Value = dto.Locale;
row.Cell(col++).Value = dto.Title;
row.Cell(col++).Value = dto.Subtitle;
row.Cell(col++).Value = dto.AuthorNames;
row.Cell(col++).Value = dto.NarratorNames;
row.Cell(col++).Value = dto.LengthInMinutes;
row.Cell(col++).Value = dto.Description;
row.Cell(col++).Value = dto.Publisher;
row.Cell(col++).Value = dto.HasPdf;
row.Cell(col++).Value = dto.SeriesNames;
row.Cell(col++).Value = dto.SeriesOrder;
row.Cell(col++).Value = dto.CommunityRatingOverall;
row.Cell(col++).Value = dto.CommunityRatingPerformance;
row.Cell(col++).Value = dto.CommunityRatingStory;
row.Cell(col++).Value = dto.PictureId;
row.Cell(col++).Value = dto.IsAbridged;
row.Cell(col++).SetDate(dto.DatePublished, dateFormat);
row.Cell(col++).Value = dto.CategoriesNames;
row.Cell(col++).Value = dto.MyRatingOverall;
row.Cell(col++).Value = dto.MyRatingPerformance;
row.Cell(col++).Value = dto.MyRatingStory;
row.Cell(col++).Value = dto.MyLibationTags;
row.Cell(col++).Value = dto.BookStatus;
row.Cell(col++).Value = dto.PdfStatus;
row.Cell(col++).Value = dto.ContentType;
row.Cell(col++).Value = dto.Language;
row.Cell(col++).SetDate(dto.LastDownloaded, dateFormat);
row.Cell(col++).Value = dto.LastDownloadedVersion;
row.Cell(col++).Value = dto.IsFinished;
row.Cell(col++).Value = dto.IsSpatial;
row.Cell(col++).Value = dto.LastDownloadedFileVersion;
row.Cell(col++).Value = dto.CodecString;
row.Cell(col++).Value = dto.SampleRate;
row.Cell(col++).Value = dto.ChannelCount;
row.Cell(col++).Value = dto.BitRate;
}
workbook.SaveAs(saveFilePath);
}
private static void SetDate(this IXLCell cell, DateTime? value, string dateFormat)
{
cell.Value = value;
cell.Style.DateFormat.Format = dateFormat;
}
workbook.SaveAs(saveFilePath);
}
private static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
=> library.Select(a => new ExportDto(a)).ToList();
private static string GetColumnName(PropertyInfo property)
=> property.GetCustomAttribute<NameAttribute>()?.Names?.FirstOrDefault() ?? property.Name;
}

View File

@@ -0,0 +1,16 @@
using LibationSearchEngine;
#nullable enable
namespace ApplicationServices;
/// <summary>
/// The main search engine used Libation.
/// Acts as an adapter to SearchEngineCommands.Search()
/// </summary>
public class MainSearchEngine : ISearchEngine
{
public static MainSearchEngine Instance { get; } = new MainSearchEngine();
private MainSearchEngine() { }
public SearchResultSet? GetSearchResultSet(string? searchString)
=> string.IsNullOrEmpty(searchString) ? null : SearchEngineCommands.Search(searchString);
}

View File

@@ -0,0 +1,45 @@
using DataLayer;
using LibationFileManager;
using LibationSearchEngine;
using System.Collections.Generic;
#nullable enable
namespace ApplicationServices;
/// <summary>
/// A temporary search engine created in InProgress/TempSearchEngine
/// Used for Trash Bin searches to avoid interfering with the main search engine
/// </summary>
public class TempSearchEngine : ISearchEngine
{
public static string SearchEnginePath { get; }
= System.IO.Path.Combine(Configuration.Instance.InProgress, nameof(TempSearchEngine));
private SearchEngine SearchEngine { get; } = new SearchEngine(SearchEnginePath);
public bool ReindexSearchEngine(IEnumerable<LibraryBook> books)
{
try
{
SearchEngine.CreateNewIndex(books, overwrite: true);
return true;
}
catch
{
return false;
}
}
public SearchResultSet? GetSearchResultSet(string? searchString)
{
if (string.IsNullOrEmpty(searchString))
return null;
try
{
return SearchEngine.Search(searchString);
}
catch
{
return null;
}
}
}

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.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

@@ -6,7 +6,7 @@
<ItemGroup>
<PackageReference Include="AudibleApi" Version="10.1.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.33.2" />
<PackageReference Include="Google.Protobuf" Version="3.33.4" />
</ItemGroup>
<ItemGroup>

View File

@@ -0,0 +1,24 @@
using AudibleApi.Common;
using System;
using System.Linq;
namespace AudibleUtilities;
public static class Extensions
{
extension(Item item)
{
/// <summary>
/// Determines when your audible plus or free book will expire from your library
/// plan.IsAyce from underlying AudibleApi project determines the plans to look at, first plan found is used.
/// In some cases current date is later than end date so exclude.
/// </summary>
/// <returns>The DateTime that this title will become unavailable, otherwise null</returns>
public DateTime? GetExpirationDate()
=> item.Plans
?.Where(p => p.IsAyce)
.Select(p => p.EndDate)
.FirstOrDefault(end => end.HasValue && end.Value.Year is not (2099 or 9999) && end.Value.LocalDateTime >= DateTime.Now)
?.DateTime;
}
}

View File

@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

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

View File

@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Postgres.Migrations
{
/// <inheritdoc />
public partial class AddIsAudiblePlus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "text",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "text",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "real",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "real",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "real",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "real",
oldNullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsAudiblePlus",
table: "LibraryBooks",
type: "boolean",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAudiblePlus",
table: "LibraryBooks");
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "text",
nullable: true,
oldClrType: typeof(string),
oldType: "text");
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "real",
nullable: true,
oldClrType: typeof(float),
oldType: "real");
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "real",
nullable: true,
oldClrType: typeof(float),
oldType: "real");
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "real",
nullable: true,
oldClrType: typeof(float),
oldType: "real");
}
}
}

View File

@@ -17,7 +17,7 @@ namespace DataLayer.Postgres.Migrations
{
#pragma warning disable 612, 618
modelBuilder
.HasAnnotation("ProductVersion", "9.0.8")
.HasAnnotation("ProductVersion", "10.0.0")
.HasAnnotation("Relational:MaxIdentifierLength", 63);
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
@@ -210,6 +210,9 @@ namespace DataLayer.Postgres.Migrations
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("timestamp without time zone");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("boolean");
b.Property<bool>("IsDeleted")
.HasColumnType("boolean");
@@ -351,6 +354,7 @@ namespace DataLayer.Postgres.Migrations
.HasColumnType("integer");
b1.Property<string>("Tags")
.IsRequired()
.HasColumnType("text");
b1.HasKey("BookId");
@@ -384,7 +388,8 @@ namespace DataLayer.Postgres.Migrations
b1.Navigation("Book");
b1.Navigation("Rating");
b1.Navigation("Rating")
.IsRequired();
});
b.Navigation("Rating");

View File

@@ -9,11 +9,11 @@
<OutputType>Library</OutputType>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

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

View File

@@ -0,0 +1,101 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddIsAudiblePlus : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "TEXT",
nullable: false,
defaultValue: "",
oldClrType: typeof(string),
oldType: "TEXT",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "REAL",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "REAL",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "REAL",
nullable: false,
defaultValue: 0f,
oldClrType: typeof(float),
oldType: "REAL",
oldNullable: true);
migrationBuilder.AddColumn<bool>(
name: "IsAudiblePlus",
table: "LibraryBooks",
type: "INTEGER",
nullable: false,
defaultValue: false);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "IsAudiblePlus",
table: "LibraryBooks");
migrationBuilder.AlterColumn<string>(
name: "Tags",
table: "UserDefinedItem",
type: "TEXT",
nullable: true,
oldClrType: typeof(string),
oldType: "TEXT");
migrationBuilder.AlterColumn<float>(
name: "Rating_StoryRating",
table: "UserDefinedItem",
type: "REAL",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<float>(
name: "Rating_PerformanceRating",
table: "UserDefinedItem",
type: "REAL",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL");
migrationBuilder.AlterColumn<float>(
name: "Rating_OverallRating",
table: "UserDefinedItem",
type: "REAL",
nullable: true,
oldClrType: typeof(float),
oldType: "REAL");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.8");
modelBuilder.HasAnnotation("ProductVersion", "10.0.0");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -197,6 +197,9 @@ namespace DataLayer.Migrations
b.Property<DateTime?>("IncludedUntil")
.HasColumnType("TEXT");
b.Property<bool>("IsAudiblePlus")
.HasColumnType("INTEGER");
b.Property<bool>("IsDeleted")
.HasColumnType("INTEGER");
@@ -334,6 +337,7 @@ namespace DataLayer.Migrations
.HasColumnType("INTEGER");
b1.Property<string>("Tags")
.IsRequired()
.HasColumnType("TEXT");
b1.HasKey("BookId");
@@ -367,7 +371,8 @@ namespace DataLayer.Migrations
b1.Navigation("Book");
b1.Navigation("Rating");
b1.Navigation("Rating")
.IsRequired();
});
b.Navigation("Rating");

View File

@@ -13,12 +13,12 @@
<PackageReference Include="Dinah.Core" Version="10.0.0.1" />
<PackageReference Include="Dinah.EntityFrameworkCore" Version="10.0.0.1" />
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.0" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="10.0.2" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.0">
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="10.0.2">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>

View File

@@ -15,6 +15,7 @@ namespace DataLayer
public bool AbsentFromLastScan { get; set; }
public DateTime? IncludedUntil { get; private set; }
public bool IsAudiblePlus { get; set; }
private LibraryBook() { }
public LibraryBook(Book book, DateTime dateAdded, string account)
{
@@ -28,6 +29,7 @@ namespace DataLayer
public void SetAccount(string account) => Account = account;
public void SetIncludedUntil(DateTime? includedUntil) => IncludedUntil = includedUntil;
public void SetIsAudiblePlus(bool isAudiblePlus) => IsAudiblePlus = isAudiblePlus;
public override string ToString() => $"{DateAdded:d} {Book}";
}
}

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

@@ -7,10 +7,11 @@ using System.Text;
namespace DataLayer;
public class MockLibraryBook : LibraryBook
{
protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil)
protected MockLibraryBook(Book book, DateTime dateAdded, string account, DateTime? includedUntil, bool isAudiblePlus)
: base(book, dateAdded, account)
{
SetIncludedUntil(includedUntil);
SetIsAudiblePlus(isAudiblePlus);
}
public MockLibraryBook AddSeries(string seriesName, int order)
@@ -76,6 +77,7 @@ public class MockLibraryBook : LibraryBook
DateTime? dateAdded = null,
DateTime? datePublished = null,
DateTime? includedUntil = null,
bool isAudiblePlus = false,
string title = "Mock Book Title",
string subtitle = "Mock Book Subtitle",
string description = "This is a mock book description.",
@@ -86,7 +88,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,13 +105,20 @@ 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(
book,
dateAdded ?? DateTime.Now,
account,
includedUntil)
includedUntil,
isAudiblePlus)
{
AbsentFromLastScan = absetFromLastScan
};

View File

@@ -47,7 +47,8 @@ namespace DataLayer
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.Where(lb => lb.IsDeleted)
//Return all parents so the trash bin grid can show podcasts beneath their parents
.Where(lb => lb.IsDeleted || lb.Book.ContentType == ContentType.Parent)
.getLibrary()
.ToList();

View File

@@ -18,6 +18,14 @@ namespace DtoImporterService
private SeriesImporter seriesImporter { get; }
private CategoryImporter categoryImporter { get; }
/// <summary>
/// Indicates whether <see cref="BookImporter"/> loaded every Book from the <seealso cref="LibationContext"/> during import.
/// If true, the DbContext was queried for all Books, rather than just those being imported.
/// If means that all <see cref="LibraryBook"/> objects in the DbContext will have their <see cref="LibraryBook.Book"/> property populated.
/// If false, only those Books being imported were loaded, and some <see cref="LibraryBook"/> objects will have a null <see cref="LibraryBook.Book"/> property for books not included in the import set.
/// </summary>
internal bool LoadedEntireLibrary {get; private set; }
public BookImporter(LibationContext context) : base(context)
{
contributorImporter = new ContributorImporter(DbContext);
@@ -56,6 +64,7 @@ namespace DtoImporterService
.ToArray()
.Where(b => productIds.Contains(b.AudibleProductId))
.ToDictionarySafe(b => b.AudibleProductId);
LoadedEntireLibrary = true;
}
else
{
@@ -69,16 +78,16 @@ namespace DtoImporterService
{
var qtyNew = 0;
foreach (var item in importItems)
{
if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book))
foreach (var item in importItems)
{
book = createNewBook(item);
qtyNew++;
}
if (!Cache.TryGetValue(item.DtoItem.ProductId, out var book))
{
book = createNewBook(item);
qtyNew++;
}
updateBook(item, book);
}
updateBook(item, book);
}
return qtyNew;
}
@@ -160,6 +169,14 @@ namespace DtoImporterService
{
var item = importItem.DtoItem;
// Replacing narrators only became necessary to correct a bug introduced in 13.1.0
// which would no import narrators with null ASINs. Thus, affected books had the
// author listed as the narrators. This can probably be removed in the future.
// Bug went live in 13.1.0 on 2026/01/02. Today is 2026/01/08.
var narrators = item.Narrators?.DistinctBy(a => a.Name).Select(n => contributorImporter.Cache[n.Name]).ToArray();
if (narrators is not null && narrators.Length > 0)
book.ReplaceNarrators(narrators);
book.UpdateLengthInMinutes(item.LengthInMinutes);
// Update the book titles, since formatting can change

View File

@@ -87,7 +87,7 @@ namespace DtoImporterService
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Error adding category ladder. {@DebugInfo}", categoryList);
Serilog.Log.Logger.Error(ex, "Error adding category ladder.");
throw;
}
}

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

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

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)
{
@@ -67,7 +67,7 @@ internal static class AudioFormatDecoder
var mpegSize = mp3File.Length - mp3File.Position;
if (mpegSize < 64)
{
Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {@File}", mp3Filename);
Serilog.Log.Logger.Warning("Remaining file length is too short to contain any mp3 frames. {File}", mp3Filename);
return AudioFormat.Default;
}
@@ -80,7 +80,7 @@ internal static class AudioFormatDecoder
if (layerDesc is not Layer.Layer_3)
{
Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString());
Serilog.Log.Logger.Warning("Could not read mp3 data from {layerVersion} file.", layerDesc);
return AudioFormat.Default;
}

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)
@@ -317,7 +322,7 @@ namespace FileLiberator
catch (Exception ex)
{
//Failure to determine output audio format should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook, firstAudioFile);
Serilog.Log.Logger.Error(ex, "Error determining output audio format for {@Book}. File = '{@audioFile}'", options.LibraryBook.LogFriendly(), firstAudioFile);
return AudioFormat.Default;
}
@@ -329,13 +334,15 @@ namespace FileLiberator
/// <summary>Move new files to 'Books' directory</summary>
/// <returns>Return directory if audiobook file(s) were successfully created and can be located on disk. Else null.</returns>
private void MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)
private async Task MoveFilesToBooksDir(LibraryBook libraryBook, LongPath destinationDir, List<TempFile> entries, CancellationToken cancellationToken)
{
cancellationToken.ThrowIfCancellationRequested();
AverageSpeed averageSpeed = new();
MoveWithProgress moveWithProgress = new();
var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length);
long totalBytesMoved = 0;
moveWithProgress.MoveProgress += onMovefileProgress;
for (var i = 0; i < entries.Count; i++)
{
@@ -350,28 +357,13 @@ namespace FileLiberator
Configuration.OverwriteExisting);
var realDest
= FileUtility.SaferMoveToValidPath(
entry.FilePath,
= FileUtility.GetValidFilename(
destFileName,
Configuration.ReplacementCharacters,
entry.Extension,
Configuration.OverwriteExisting);
#region File Move Progress
totalBytesMoved += new FileInfo(realDest).Length;
averageSpeed.AddPosition(totalBytesMoved);
var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average;
if (double.IsNormal(estSecsRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
OnStreamingProgressChanged(new DownloadProgress
{
ProgressPercentage = 100d * totalBytesMoved / totalSizeToMove,
BytesReceived = totalBytesMoved,
TotalBytesToReceive = totalSizeToMove
});
#endregion
await moveWithProgress.MoveAsync(entry.FilePath, realDest, Configuration.OverwriteExisting, cancellationToken);
// propagate corrected path for cue file (after this for-loop)
entries[i] = entry with { FilePath = realDest };
@@ -390,6 +382,23 @@ namespace FileLiberator
cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
void onMovefileProgress(object? sender, MoveFileProgressEventArgs e)
{
totalBytesMoved += e.BytesMoved;
averageSpeed.AddPosition(totalBytesMoved);
var estSecsRemaining = (totalSizeToMove - totalBytesMoved) / averageSpeed.Average;
if (double.IsNormal(estSecsRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estSecsRemaining));
OnStreamingProgressChanged(new DownloadProgress
{
ProgressPercentage = 100d * totalBytesMoved / totalSizeToMove,
BytesReceived = totalBytesMoved,
TotalBytesToReceive = totalSizeToMove
});
};
}
private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
@@ -422,7 +431,7 @@ namespace FileLiberator
{
//Failure to download cover art should not be considered a failure to download the book
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {@metadataFile}.", options.LibraryBook, coverPath);
Serilog.Log.Logger.Error(ex, "Error downloading cover art for {@Book} to {coverPath}.", options.LibraryBook.LogFriendly(), coverPath);
throw;
}
}
@@ -471,7 +480,7 @@ namespace FileLiberator
{
//Failure to download records should not be considered a failure to download the book
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {@recordsPath}.", options.LibraryBook, recordsPath);
Serilog.Log.Logger.Error(ex, "Error downloading clips and bookmarks for {@Book} to {recordsPath}.", options.LibraryBook.LogFriendly(), recordsPath);
throw;
}
}
@@ -507,7 +516,7 @@ namespace FileLiberator
{
//Failure to download metadata should not be considered a failure to download the book
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading metdatat of {@Book} to {@metadataFile}.", options.LibraryBook, metadataPath);
Serilog.Log.Logger.Error(ex, "Error downloading metadata of {@Book} to {metadataFile}.", options.LibraryBook.LogFriendly(), metadataPath);
throw;
}
}
@@ -518,12 +527,12 @@ namespace FileLiberator
{
Serilog.Log.Verbose("Getting destination directory for {@Book}", libraryBook.LogFriendly());
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook, Configuration);
Serilog.Log.Verbose("Got destination directory for {@Book}. {@Directory}", libraryBook.LogFriendly(), destinationDir);
Serilog.Log.Verbose("Got destination directory for {@Book}. {Directory}", libraryBook.LogFriendly(), destinationDir);
if (!Directory.Exists(destinationDir))
{
Serilog.Log.Verbose("Creating destination {@Directory}", destinationDir);
Serilog.Log.Verbose("Creating destination {Directory}", destinationDir);
Directory.CreateDirectory(destinationDir);
Serilog.Log.Verbose("Created destination {@Directory}", destinationDir);
Serilog.Log.Verbose("Created destination {Directory}", destinationDir);
}
return destinationDir;
}

View File

@@ -56,7 +56,7 @@ public partial class DownloadOptions
}
else if (metadata.ContentReference != license.ContentMetadata.ContentReference)
{
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {@DrmType}. {@Metadata}. {@License} ",
Serilog.Log.Logger.Warning("Metadata ContentReference does not match License ContentReference with drm_type = {DrmType}. {@Metadata}. {@License} ",
license.DrmType,
metadata.ContentReference,
license.ContentMetadata.ContentReference);
@@ -111,7 +111,7 @@ public partial class DownloadOptions
if (canUseWidevine)
Serilog.Log.Logger.Warning("Unable to get a Widevine CDM. Falling back to ADRM.");
else
Serilog.Log.Logger.Warning("Account {@account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
Serilog.Log.Logger.Warning("Account {account} is not registered as an android device, so content will not be downloaded with Widevine DRM. Remove and re-add the account in Libation to fix.", libraryBook.Account.ToMask());
}
token.ThrowIfCancellationRequested();
@@ -170,17 +170,6 @@ public partial class DownloadOptions
/// </summary>
public static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
= config.StripAudibleBrandAudio
? licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs
: 0;
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
{
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
@@ -190,18 +179,22 @@ public partial class DownloadOptions
if (config.MergeOpeningAndEndCredits)
combineCredits(chapters);
if (config.StripAudibleBrandAudio)
stripBranding(chapters, licInfo.ContentMetadata.ChapterInfo.BrandIntroDurationMs, licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
if (config.SplitFilesByChapter)
combineShortChapters(chapters, config.MinimumFileDuration * 1000);
var dlOptions = new DownloadOptions(config, libraryBook, licInfo)
{
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapters[0].StartOffsetMs)),
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
//Build AAXClean.ChapterInfo
for (int i = 0; i < chapters.Count; i++)
{
var chapter = chapters[i];
long chapLenMs = chapter.LengthMs;
if (i == 0)
chapLenMs -= chapterStartMs;
if (config.StripAudibleBrandAudio && i == chapters.Count - 1)
chapLenMs -= licInfo.ContentMetadata.ChapterInfo.BrandOutroDurationMs;
dlOptions.ChapterInfo.AddChapter(chapter.Title, TimeSpan.FromMilliseconds(chapLenMs));
dlOptions.ChapterInfo.AddChapter(chapters[i].Title, TimeSpan.FromMilliseconds(chapters[i].LengthMs));
}
return dlOptions;
@@ -349,6 +342,50 @@ public partial class DownloadOptions
return chaps;
}
/*
https://github.com/rmcrackan/Libation/pull/127#issuecomment-939088489
If the chapter truly is empty, that is, 0 audio frames in length, then yes it is ignored.
If the chapter is shorter than minChapterLength but still has some audio frames, those
frames are combined with the following chapter and not split into a new file.
When 2 or more consecutive chapters are combined, chapter titles are concatenated
with a apace in between. For example, given an audiobook with the following chapters:
00:00:00 - 00:00:02 | Part 1
00:00:02 - 00:35:00 | Chapter 1
00:35:02 - 01:02:00 | Chapter 2
01:02:00 - 01:02:02 | Part 2
01:02:02 - 01:41:00 | Chapter 3
01:41:00 - 02:05:00 | Chapter 4
The book will be split into the following files:
00:00:00 - 00:35:00 | Book - 01 - Part 1 Chapter 1.m4b
00:35:00 - 01:02:00 | Book - 02 - Chapter 2.m4b
01:02:00 - 01:41:00 | Book - 03 - Part 2.m4b
01:41:00 - 02:05:00 | Book - 04 - Chapter 4.m4b
*/
public static void combineShortChapters(List<Chapter> chapters, long minChapterLengthMs)
{
for (int i = 0; i < chapters.Count; i++)
{
while (chapters[i].LengthMs < minChapterLengthMs && chapters.Count > i + 1)
{
chapters[i].Title += " " + chapters[i + 1].Title;
chapters[i].LengthMs += chapters[i + 1].LengthMs;
chapters.RemoveAt(i + 1);
}
}
}
public static void stripBranding(List<Chapter> chapters, long introMs, long outroMs)
{
chapters[0].LengthMs -= introMs;
chapters[0].StartOffsetMs += introMs;
chapters[^1].LengthMs -= outroMs;
}
public static void combineCredits(IList<Chapter> chapters)
{
if (chapters.Count > 1 && chapters[0].Title == "Opening Credits")

View File

@@ -46,7 +46,7 @@ namespace FileManager
if (!Directory.Exists(directoryName))
return false;
Serilog.Log.Logger.Debug("Testing write permissions for directory: {@DirectoryName}", directoryName);
Serilog.Log.Logger.Debug("Testing write permissions for directory: {DirectoryName}", directoryName);
var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString());
return CanWriteFile(testFilePath);
}
@@ -55,9 +55,9 @@ namespace FileManager
{
try
{
Serilog.Log.Logger.Debug("Testing ability to write filename: {@filename}", filename);
Serilog.Log.Logger.Debug("Testing ability to write filename: {filename}", filename);
File.WriteAllBytes(filename, []);
Serilog.Log.Logger.Debug("Deleting test file after successful write: {@filename}", filename);
Serilog.Log.Logger.Debug("Deleting test file after successful write: {filename}", filename);
try
{
FileUtility.SaferDelete(filename);
@@ -65,13 +65,13 @@ namespace FileManager
catch (Exception ex)
{
//An error deleting the file doesn't constitute a write failure.
Serilog.Log.Logger.Debug(ex, "Error deleting test file: {@filename}", filename);
Serilog.Log.Logger.Debug(ex, "Error deleting test file: {filename}", filename);
}
return true;
}
catch (Exception ex)
{
Serilog.Log.Logger.Debug(ex, "Error writing test file: {@filename}", filename);
Serilog.Log.Logger.Debug(ex, "Error writing test file: {filename}", filename);
return false;
}
}

View File

@@ -0,0 +1,184 @@
using System;
using System.Buffers;
using System.Diagnostics;
using System.IO;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.Win32.SafeHandles;
using Serilog;
#nullable enable
namespace FileManager;
public class MoveFileProgressEventArgs : EventArgs
{
public long TotalFileSize { get; }
public long TotalBytesTransferred { get; }
public long BytesMoved { get; }
public bool Continue { get; set; } = true;
internal MoveFileProgressEventArgs(long bytesMoved, long totalBytesTransferred, long totalFileSize)
{
BytesMoved = bytesMoved;
TotalBytesTransferred = totalBytesTransferred;
TotalFileSize = totalFileSize;
}
}
public class MoveWithProgress
{
public event EventHandler<MoveFileProgressEventArgs>? MoveProgress;
public async Task<bool> MoveAsync(LongPath source, LongPath destination, bool overwrite = false, CancellationToken cancellation = default)
{
ArgumentException.ThrowIfNullOrEmpty(source, nameof(source));
ArgumentException.ThrowIfNullOrEmpty(destination, nameof(destination));
var sourceFileInfo = new FileInfo(source);
if (!sourceFileInfo.Exists)
throw new FileNotFoundException($"Source file '{source}' does not exist.", source);
var destinationFile = new FileInfo(destination);
var sourceDevice = GetDeviceId(sourceFileInfo);
var destinationDevice = GetDeviceId(destinationFile.Directory);
if (sourceDevice == destinationDevice)
{
File.Move(sourceFileInfo.FullName, destinationFile.FullName, overwrite);
MoveProgress?.Invoke(this, new MoveFileProgressEventArgs(destinationFile.Length, destinationFile.Length, sourceFileInfo.Length));
return true;
}
if (destinationFile.Exists && !overwrite)
throw new IOException("The file exists.");
bool success = false;
try
{
success = await CopyWithProgressAsync(sourceFileInfo, destinationFile, cancellation);
}
finally
{
if (success)
FileUtility.SaferDelete(sourceFileInfo.FullName);
else
FileUtility.SaferDelete(destinationFile.FullName);
}
return success;
}
private static string? GetDeviceId(FileSystemInfo? fsEntry)
=> fsEntry?.FullName is not string path ? null
: LongPath.IsWindows ? GetDriveSerialNumber(path)
: LongPath.IsOSX ? RunShellCommand("stat -L -f %d \"" + path + "\"")
: RunShellCommand("stat -L -f -c %d \"" + path + "\"");
private async Task<bool> CopyWithProgressAsync(FileInfo sourceFileInfo, FileInfo destinationFile, CancellationToken cancellation)
{
const int BlockSizeMb = 8;
const int BlockSizeBytes = BlockSizeMb * (1 << 20);
using FileStream sourceStream = sourceFileInfo.Open(FileMode.Open, FileAccess.Read, FileShare.Read);
using FileStream destinationStream = destinationFile.Open(FileMode.OpenOrCreate, FileAccess.Write, FileShare.Read);
using IMemoryOwner<byte> pool = MemoryPool<byte>.Shared.Rent(2 * BlockSizeBytes);
Memory<byte> readBuff = pool.Memory.Slice(0, BlockSizeBytes);
Memory<byte> writeBuff = pool.Memory.Slice(BlockSizeBytes, BlockSizeBytes);
long totalCopied = 0, bytesMovedSinceLastReport = 0;
DateTime nextReport = default;
int bytesRead = await sourceStream.ReadAsync(writeBuff, cancellation);
while (bytesRead > 0)
{
totalCopied += bytesRead;
bytesMovedSinceLastReport += bytesRead;
var readTask = sourceStream.ReadAsync(readBuff, cancellation);
await destinationStream.WriteAsync(writeBuff[..bytesRead], cancellation);
if (DateTime.UtcNow >= nextReport)
{
var args = new MoveFileProgressEventArgs(bytesMovedSinceLastReport, totalCopied, sourceFileInfo.Length);
bytesMovedSinceLastReport = 0;
MoveProgress?.Invoke(this, args);
if (!args.Continue)
break;
nextReport = DateTime.UtcNow.AddMilliseconds(200.0);
}
bytesRead = await readTask;
(readBuff, writeBuff) = (writeBuff, readBuff);
}
destinationStream.SetLength(totalCopied);
MoveProgress?.Invoke(this, new MoveFileProgressEventArgs(bytesMovedSinceLastReport, totalCopied, sourceFileInfo.Length));
return totalCopied == sourceFileInfo.Length;
}
private static string? RunShellCommand(string command)
{
var psi = new ProcessStartInfo
{
FileName = "/bin/sh",
RedirectStandardOutput = true,
UseShellExecute = false,
CreateNoWindow = true,
ArgumentList = { "-c", command }
};
try
{
var proc = Process.Start(psi);
proc?.WaitForExit();
return proc?.StandardOutput?.ReadToEnd()?.Trim();
}
catch (Exception e)
{
Log.Logger.Error(e, "Failed to run shell command. {@Arguments}", psi.ArgumentList);
return null;
}
}
private static string? GetDriveSerialNumber(string path)
{
const uint FILE_FLAG_BACKUP_SEMANTICS = 0x02000000;
const uint OPEN_EXISTING = 3;
var handle = CreateFile(path, FileAccess.Read, FileShare.Read, 0, OPEN_EXISTING, FILE_FLAG_BACKUP_SEMANTICS, 0);
if (handle.IsInvalid)
return null;
try
{
BY_HANDLE_FILE_INFORMATION info = default;
if (!GetFileInformationByHandle(handle, ref info))
{
return null;
}
return info.dwVolumeSerialNumber.ToString("x8");
}
finally
{
handle.Close();
}
}
[DllImport("kernel32.dll", SetLastError = true)]
[return: MarshalAs(UnmanagedType.Bool)]
private static extern bool GetFileInformationByHandle(SafeFileHandle hFile, ref BY_HANDLE_FILE_INFORMATION lpFileInformation);
[DllImport("kernel32.dll", CharSet = CharSet.Unicode, SetLastError = true)]
private static extern SafeFileHandle CreateFile(string fileName, FileAccess fileAccess, FileShare fileShare, nint lpSecurityAttributes, uint dwCreationDisposition, uint dwFlagsAndAttributes, nint hTemplateFile);
[StructLayout(LayoutKind.Sequential, Pack = 4)]
private struct BY_HANDLE_FILE_INFORMATION
{
private uint dwFileAttributes;
private long ftCreationTime;
private long ftLastAccessTime;
private long ftLastWriteTime;
public uint dwVolumeSerialNumber;
private uint nFileSizeHigh;
private uint nFileSizeLow;
private uint nNumberOfLinks;
private uint nFileIndexHigh;
private uint nFileIndexLow;
}
}

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

@@ -142,7 +142,7 @@ namespace FileManager
File.WriteAllText(Filepath, endContents);
success = true;
}
Serilog.Log.Logger.Information("Removed property. {@DebugInfo}", propertyName);
Serilog.Log.Logger.Information("Removed property. {propertyName}", propertyName);
}
catch { }

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

@@ -70,11 +70,11 @@
<TrimmableAssembly Include="Avalonia.Themes.Default" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Avalonia.Desktop" Version="11.3.9" />
<PackageReference Include="Avalonia.Desktop" Version="11.3.11" />
<!--Condition below is needed to remove Avalonia.Diagnostics package from build output in Release configuration.-->
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.9" />
<PackageReference Condition="'$(Configuration)' == 'Debug'" Include="Avalonia.Diagnostics" Version="11.3.11" />
<PackageReference Include="ReactiveUI.Avalonia" Version="11.3.8" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.9" />
<PackageReference Include="Avalonia.Themes.Fluent" Version="11.3.11" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\HangoverBase\HangoverBase.csproj" />

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

@@ -97,7 +97,7 @@ public class TrashBinViewModel : ViewModelBase, IDisposable
public void Reload()
{
var deletedBooks = DbContexts.GetDeletedLibraryBooks();
var deletedBooks = DbContexts.GetDeletedLibraryBooks().Where(lb => lb.Book.ContentType is not ContentType.Parent);
DeletedBooks.Clear();
DeletedBooks.AddRange(deletedBooks.Select(lb => new CheckBoxViewModel { Item = lb }));

View File

@@ -54,7 +54,7 @@ namespace HangoverWinForms
deletedCbl.Items.Clear();
List<LibraryBook> deletedBooks = DbContexts.GetDeletedLibraryBooks();
foreach (var lb in deletedBooks)
foreach (var lb in deletedBooks.Where(lb => lb.Book.ContentType is not ContentType.Parent))
deletedCbl.Items.Add(lb);
setLabel();

View File

@@ -6,10 +6,6 @@
x:Class="LibationAvalonia.App"
Name="Libation">
<Application.DataTemplates>
<local:ViewLocator/>
</Application.DataTemplates>
<Application.Resources>
<ResourceDictionary>
@@ -105,6 +101,29 @@
<Setter Property="BorderThickness" Value="2" />
</Style>
</Style>
<Style Selector="NumericUpDown.SmallNumericUpDown">
<Setter Property="Height" Value="{DynamicResource TextControlThemeMinHeight}" />
<Setter Property="HorizontalContentAlignment" Value="Center" />
<Style Selector="^ /template/ ButtonSpinner#PART_Spinner">
<Style Selector="^ RepeatButton#PART_IncreaseButton">
<Setter Property="MinWidth" Value="24" />
<Style Selector="^ > PathIcon">
<Setter Property="Data">
M0,0 l8,-10 l8,10 Z
</Setter>
</Style>
</Style>
<Style Selector="^ RepeatButton#PART_DecreaseButton">
<Setter Property="MinWidth" Value="24" />
<Style Selector="^ > PathIcon">
<Setter Property="Data">
M0,0 l8,10 l8,-10 Z
</Setter>
</Style>
</Style>
</Style>
</Style>
</Application.Styles>
<NativeMenu.Menu>

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

@@ -1,117 +1,121 @@
using Avalonia.Collections;
using Avalonia.Controls;
using LibationUiBase.GridView;
using Avalonia.Input;
using System;
using System.Collections.Generic;
using System.Linq;
using System.Reflection;
namespace LibationAvalonia.Controls
namespace LibationAvalonia.Controls;
public class DataGridCellContextMenu<TContext> where TContext : class
{
internal static class DataGridContextMenus
public static DataGridCellContextMenu<TContext>? Create(ContextMenu? contextMenu)
{
public static event EventHandler<DataGridCellContextMenuStripNeededEventArgs> CellContextMenuStripNeeded;
private static readonly ContextMenu ContextMenu = new();
private static readonly AvaloniaList<Control> MenuItems = new();
private static readonly PropertyInfo OwningColumnProperty;
private static readonly PropertyInfo OwningGridProperty;
static DataGridContextMenus()
DataGrid? grid = null;
DataGridCell? cell = null;
var parent = contextMenu?.Parent;
while (parent is not null && grid is null)
{
ContextMenu.ItemsSource = MenuItems;
OwningColumnProperty = typeof(DataGridCell).GetProperty("OwningColumn", BindingFlags.Instance | BindingFlags.NonPublic);
OwningGridProperty = typeof(DataGridColumn).GetProperty("OwningGrid", BindingFlags.Instance | BindingFlags.NonPublic);
grid ??= parent as DataGrid;
cell ??= parent as DataGridCell;
parent = parent.Parent;
}
public static void AttachContextMenu(this DataGridCell cell)
if (grid is null || cell is null || cell.Tag is not DataGridColumn column || contextMenu!.DataContext is not TContext clickedEntry)
return null;
var allSelected = grid.SelectedItems.OfType<TContext>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
{
if (cell is not null && cell.ContextMenu is null)
{
cell.ContextRequested += Cell_ContextRequested;
cell.ContextMenu = ContextMenu;
}
//User didn't right-click on a selected cell
grid.SelectedItem = clickedEntry;
allSelected = [clickedEntry];
}
else if (clickedIndex > 0)
{
//Ensure the clicked entry is first in the list
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
}
private static void Cell_ContextRequested(object sender, ContextRequestedEventArgs e)
return new DataGridCellContextMenu<TContext>(contextMenu, grid, column, allSelected);
}
public string CellClipboardContents
{
get
{
if (sender is DataGridCell cell &&
cell.DataContext is GridEntry clickedEntry &&
OwningColumnProperty.GetValue(cell) is DataGridColumn column &&
OwningGridProperty.GetValue(column) is DataGrid grid)
var lines = GetClipboardLines(getClickedCell: true);
return lines.Count >= 1 ? lines[0] : string.Empty;
}
}
public string GetRowClipboardContents() => string.Join(Environment.NewLine, GetClipboardLines(false));
public ContextMenu ContextMenu { get; }
public DataGrid Grid { get; }
public DataGridColumn Column { get; }
public TContext[] RowItems { get; }
public AvaloniaList<Control> ContextMenuItems { get; }
private DataGridCellContextMenu(ContextMenu contextMenu, DataGrid grid, DataGridColumn column, TContext[] rowItems)
{
Grid = grid;
Column = column;
RowItems = rowItems;
ContextMenu = contextMenu;
ContextMenuItems = contextMenu.ItemsSource as AvaloniaList<Control> ?? new();
contextMenu.ItemsSource = ContextMenuItems;
ContextMenuItems.Clear();
}
private List<string> GetClipboardLines(bool getClickedCell)
{
if (RowItems is null || RowItems.Length == 0)
return [];
List<string> lines = [];
Grid.CopyingRowClipboardContent += Grid_CopyingRowClipboardContent;
Grid.RaiseEvent(GetCopyEventArgs());
Grid.CopyingRowClipboardContent -= Grid_CopyingRowClipboardContent;
return lines;
void Grid_CopyingRowClipboardContent(object? sender, DataGridRowClipboardEventArgs e)
{
if (getClickedCell)
{
var allSelected = grid.SelectedItems.OfType<GridEntry>().ToArray();
var clickedIndex = Array.IndexOf(allSelected, clickedEntry);
if (clickedIndex == -1)
if (e.IsColumnHeadersRow)
return;
var cellContent = e.ClipboardRowContent.FirstOrDefault(c => c.Column == Column);
if (cellContent.Column is not null)
{
//User didn't right-click on a selected cell
grid.SelectedItem = clickedEntry;
allSelected = [clickedEntry];
lines.Add(cellContent.Content?.ToString() ?? string.Empty);
}
else if (clickedIndex > 0)
{
//Ensure the clicked entry is first in the list
(allSelected[0], allSelected[clickedIndex]) = (allSelected[clickedIndex], allSelected[0]);
}
var args = new DataGridCellContextMenuStripNeededEventArgs
{
Column = column,
Grid = grid,
GridEntries = allSelected,
ContextMenu = ContextMenu
};
args.ContextMenuItems.Clear();
CellContextMenuStripNeeded?.Invoke(sender, args);
e.Handled = args.ContextMenuItems.Count == 0;
}
else if (e.Item == RowItems[0])
lines.Insert(1, FormatClipboardRowContent(e));
else
e.Handled = true;
lines.Add(FormatClipboardRowContent(e));
//Clear so that the DataGrid copy implementation doesn't set the clipboard
e.ClipboardRowContent.Clear();
}
}
public class DataGridCellContextMenuStripNeededEventArgs
private static KeyEventArgs GetCopyEventArgs() => new()
{
private static readonly MethodInfo GetCellValueMethod;
static DataGridCellContextMenuStripNeededEventArgs()
{
GetCellValueMethod = typeof(DataGridColumn).GetMethod("GetCellValue", BindingFlags.NonPublic | BindingFlags.Instance);
}
Key = Key.C,
KeyModifiers = KeyModifiers.Control,
Route = Avalonia.Interactivity.RoutingStrategies.Bubble,
PhysicalKey = PhysicalKey.C,
KeySymbol = "c",
KeyDeviceType = KeyDeviceType.Keyboard,
RoutedEvent = InputElement.KeyDownEvent
};
private static string GetCellValue(DataGridColumn column, object item)
=> GetCellValueMethod.Invoke(column, new object[] { item, column.ClipboardContentBinding })?.ToString() ?? "";
private string FormatClipboardRowContent(DataGridRowClipboardEventArgs e)
=> string.Join("\t", e.ClipboardRowContent.Select(c => RemoveLineBreaks(c.Content?.ToString())));
private static string RemoveLineBreaks(string? text)
=> text?.Replace("\r\n", " ").Replace('\r', ' ').Replace('\n', ' ') ?? "";
public string CellClipboardContents => GetCellValue(Column, GridEntries[0]);
public string GetRowClipboardContents()
{
if (GridEntries is null || GridEntries.Length == 0)
return string.Empty;
else if (GridEntries.Length == 1)
return HeaderNames + Environment.NewLine + GetRowClipboardContents(GridEntries[0]);
else
return string.Join(Environment.NewLine, GridEntries.Select(GetRowClipboardContents).Prepend(HeaderNames));
}
private string HeaderNames
=> string.Join("\t",
Grid.Columns
.Where(c => c.IsVisible)
.OrderBy(c => c.DisplayIndex)
.Select(c => RemoveLineBreaks(c.Header.ToString())));
private static string RemoveLineBreaks(string text)
=> text.Replace("\r\n", "").Replace('\r', ' ').Replace('\n', ' ');
private string GetRowClipboardContents(GridEntry gridEntry)
{
var contents = Grid.Columns.Where(c => c.IsVisible).OrderBy(c => c.DisplayIndex).Select(c => RemoveLineBreaks(GetCellValue(c, gridEntry))).ToArray();
return string.Join("\t", contents);
}
public required DataGrid Grid { get; init; }
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>;
}
}

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()
{
@@ -25,7 +25,7 @@ namespace LibationAvalonia.Controls
IsEditingMode = false
};
cell?.AttachContextMenu();
cell.Tag = this;
if (!IsReadOnly)
ToolTip.SetTip(myRatingElement, "Click to change ratings");

View File

@@ -6,7 +6,7 @@ namespace LibationAvalonia.Controls
{
protected override Control GenerateElement(DataGridCell cell, object dataItem)
{
cell?.AttachContextMenu();
cell.Tag = this;
return base.GenerateElement(cell, dataItem);
}
}

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

@@ -6,6 +6,7 @@
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels.Settings"
x:DataType="vm:AudioSettingsVM"
x:CompileBindings="True"
x:Class="LibationAvalonia.Controls.Settings.Audio">
<Grid
@@ -38,43 +39,43 @@
<Grid ColumnDefinitions="*,Auto">
<TextBlock
VerticalAlignment="Center"
Text="{CompiledBinding FileDownloadQualityText}" />
Text="{Binding FileDownloadQualityText}" />
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
ItemsSource="{CompiledBinding DownloadQualities}"
SelectedItem="{CompiledBinding FileDownloadQuality}"/>
ItemsSource="{Binding DownloadQualities}"
SelectedItem="{Binding FileDownloadQuality}"/>
</Grid>
<Grid ColumnDefinitions="*,Auto">
<CheckBox
ToolTip.Tip="{CompiledBinding UseWidevineTip}"
ToolTip.Tip="{Binding UseWidevineTip}"
IsCheckedChanged="UseWidevine_IsCheckedChanged"
IsChecked="{CompiledBinding UseWidevine, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding UseWidevineText}" />
IsChecked="{Binding UseWidevine, Mode=TwoWay}">
<TextBlock Text="{Binding UseWidevineText}" />
</CheckBox>
<CheckBox
Grid.Column="1"
ToolTip.Tip="{CompiledBinding Request_xHE_AACTip}"
IsEnabled="{CompiledBinding UseWidevine}"
IsChecked="{CompiledBinding Request_xHE_AAC, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding Request_xHE_AACText}" />
ToolTip.Tip="{Binding Request_xHE_AACTip}"
IsEnabled="{Binding UseWidevine}"
IsChecked="{Binding Request_xHE_AAC, Mode=TwoWay}">
<TextBlock Text="{Binding Request_xHE_AACText}" />
</CheckBox>
</Grid>
<!--
<Grid ColumnDefinitions="*,Auto">
<CheckBox
ToolTip.Tip="{CompiledBinding RequestSpatialTip}"
IsEnabled="{CompiledBinding UseWidevine}"
IsChecked="{CompiledBinding RequestSpatial, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding RequestSpatialText}" />
ToolTip.Tip="{Binding RequestSpatialTip}"
IsEnabled="{Binding UseWidevine}"
IsChecked="{Binding RequestSpatial, Mode=TwoWay}">
<TextBlock Text="{Binding RequestSpatialText}" />
</CheckBox>
<Grid
Grid.Column="1"
ColumnDefinitions="Auto,Auto"
VerticalAlignment="Top"
ToolTip.Tip="{CompiledBinding SpatialAudioCodecTip}">
ToolTip.Tip="{Binding SpatialAudioCodecTip}">
<Grid.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<MultiBinding.Bindings>
@@ -92,78 +93,100 @@
Margin="5,0,0,0"
Grid.Column="1"
VerticalAlignment="Center"
ItemsSource="{CompiledBinding SpatialAudioCodecs}"
SelectedItem="{CompiledBinding SpatialAudioCodec}"/>
</Grid>
ItemsSource="{Binding SpatialAudioCodecs}"
SelectedItem="{Binding SpatialAudioCodec}"/>
</Grid>
</Grid>
-->
<CheckBox IsChecked="{CompiledBinding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CreateCueSheetText}" />
<CheckBox IsChecked="{Binding CreateCueSheet, Mode=TwoWay}">
<TextBlock Text="{Binding CreateCueSheetText}" />
</CheckBox>
<CheckBox IsChecked="{CompiledBinding DownloadCoverArt, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding DownloadCoverArtText}" />
<CheckBox IsChecked="{Binding DownloadCoverArt, Mode=TwoWay}">
<TextBlock Text="{Binding DownloadCoverArtText}" />
</CheckBox>
<Grid ColumnDefinitions="*,Auto">
<CheckBox IsChecked="{CompiledBinding DownloadClipsBookmarks, Mode=TwoWay}">
<CheckBox IsChecked="{Binding DownloadClipsBookmarks, Mode=TwoWay}">
<TextBlock Text="Download Clips, Notes and Bookmarks as" />
</CheckBox>
<controls:WheelComboBox
Margin="5,0,0,0"
Grid.Column="1"
IsEnabled="{CompiledBinding DownloadClipsBookmarks}"
ItemsSource="{CompiledBinding ClipBookmarkFormats}"
SelectedItem="{CompiledBinding ClipBookmarkFormat}"/>
IsEnabled="{Binding DownloadClipsBookmarks}"
ItemsSource="{Binding ClipBookmarkFormats}"
SelectedItem="{Binding ClipBookmarkFormat}"/>
</Grid>
<CheckBox
IsChecked="{CompiledBinding RetainAaxFile, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding RetainAaxFileTip}">
<TextBlock Text="{CompiledBinding RetainAaxFileText}" />
IsChecked="{Binding RetainAaxFile, Mode=TwoWay}"
ToolTip.Tip="{Binding RetainAaxFileTip}">
<TextBlock Text="{Binding RetainAaxFileText}" />
</CheckBox>
<CheckBox
IsChecked="{CompiledBinding MergeOpeningAndEndCredits, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MergeOpeningAndEndCreditsTip}">
<TextBlock Text="{CompiledBinding MergeOpeningEndCreditsText}" />
IsChecked="{Binding MergeOpeningAndEndCredits, Mode=TwoWay}"
ToolTip.Tip="{Binding MergeOpeningAndEndCreditsTip}">
<TextBlock Text="{Binding MergeOpeningEndCreditsText}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{CompiledBinding CombineNestedChapterTitlesTip}"
IsChecked="{CompiledBinding CombineNestedChapterTitles, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding CombineNestedChapterTitlesText}" />
ToolTip.Tip="{Binding CombineNestedChapterTitlesTip}"
IsChecked="{Binding CombineNestedChapterTitles, Mode=TwoWay}">
<TextBlock Text="{Binding CombineNestedChapterTitlesText}" />
</CheckBox>
<CheckBox
ToolTip.Tip="{CompiledBinding AllowLibationFixupTip}"
IsChecked="{CompiledBinding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding AllowLibationFixupText}" />
ToolTip.Tip="{Binding AllowLibationFixupTip}"
IsChecked="{Binding AllowLibationFixup, Mode=TwoWay}">
<TextBlock Text="{Binding AllowLibationFixupText}" />
</CheckBox>
<controls:GroupBox
Grid.Row="1"
Label="Audiobook Fix-ups"
IsEnabled="{CompiledBinding AllowLibationFixup}">
IsEnabled="{Binding AllowLibationFixup}">
<StackPanel Orientation="Vertical">
<CheckBox IsChecked="{CompiledBinding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Text="{CompiledBinding SplitFilesByChapterText}" />
<Grid
Margin="0,0,0,5"
Grid.ColumnDefinitions="Auto,Auto"
Grid.RowDefinitions="Auto,Auto">
<CheckBox Margin="0" Grid.ColumnSpan="2" IsChecked="{Binding SplitFilesByChapter, Mode=TwoWay}">
<TextBlock Grid.ColumnSpan="2" Text="{Binding SplitFilesByChapterText}" />
</CheckBox>
<TextBlock Grid.Row="1" Margin="15,0" VerticalAlignment="Center" ToolTip.Tip="{Binding MinimumFileDurationTip}" Text="{Binding MinimumFileDurationText}" />
<NumericUpDown
Classes="SmallNumericUpDown"
Grid.Column="1"
Grid.Row="1"
ToolTip.Tip="{Binding MinimumFileDurationTip}"
MinWidth="100"
Minimum="0"
Maximum="120"
Increment="1"
FormatString="N0"
ParsingNumberStyle="Integer"
IsEnabled="{Binding SplitFilesByChapter}"
Value="{Binding MinimumFileDuration, Mode=TwoWay}"/>
</Grid>
<CheckBox
IsChecked="{Binding StripAudibleBrandAudio, Mode=TwoWay}"
ToolTip.Tip="{Binding StripAudibleBrandAudioTip}">
<TextBlock Text="{Binding StripAudibleBrandingText}" />
</CheckBox>
<CheckBox
IsChecked="{CompiledBinding StripAudibleBrandAudio, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripAudibleBrandAudioTip}">
<TextBlock Text="{CompiledBinding StripAudibleBrandingText}" />
</CheckBox>
<CheckBox
IsChecked="{CompiledBinding StripUnabridged, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding StripUnabridgedTip}">
<TextBlock Text="{CompiledBinding StripUnabridgedText}" />
IsChecked="{Binding StripUnabridged, Mode=TwoWay}"
ToolTip.Tip="{Binding StripUnabridgedTip}">
<TextBlock Text="{Binding StripUnabridgedText}" />
</CheckBox>
</StackPanel>
</controls:GroupBox>
@@ -178,24 +201,24 @@
Margin="10,0,0,0">
<RadioButton
IsChecked="{CompiledBinding !DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
IsChecked="{Binding !DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{Binding DecryptToLossyTip}">
<StackPanel VerticalAlignment="Center">
<TextBlock
Text="Download my books in the original audio format (Lossless)" />
<CheckBox
IsEnabled="{CompiledBinding !DecryptToLossy}"
IsChecked="{CompiledBinding MoveMoovToBeginning, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding MoveMoovToBeginningTip}">
<TextBlock Text="{CompiledBinding MoveMoovToBeginningText}" />
IsEnabled="{Binding !DecryptToLossy}"
IsChecked="{Binding MoveMoovToBeginning, Mode=TwoWay}"
ToolTip.Tip="{Binding MoveMoovToBeginningTip}">
<TextBlock Text="{Binding MoveMoovToBeginningText}" />
</CheckBox>
</StackPanel>
</RadioButton>
<RadioButton
IsChecked="{CompiledBinding DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding DecryptToLossyTip}">
IsChecked="{Binding DecryptToLossy, Mode=TwoWay}"
ToolTip.Tip="{Binding DecryptToLossyTip}">
<TextBlock
TextWrapping="Wrap"
Text="Download my books as .MP3 files (transcode if necessary)" />
@@ -203,7 +226,7 @@
<controls:GroupBox
Grid.Column="1"
IsEnabled="{CompiledBinding DecryptToLossy}"
IsEnabled="{Binding DecryptToLossy}"
Label="Mp3 Encoding Options">
<Grid RowDefinitions="Auto,Auto,Auto,Auto,*">
@@ -220,21 +243,21 @@
<RadioButton
Margin="5"
Content="Bitrate"
IsChecked="{CompiledBinding LameTargetBitrate, Mode=TwoWay}"/>
IsChecked="{Binding LameTargetBitrate, Mode=TwoWay}"/>
<RadioButton
Grid.Column="1"
Margin="5"
Content="Quality"
IsChecked="{CompiledBinding !LameTargetBitrate, Mode=TwoWay}"/>
IsChecked="{Binding !LameTargetBitrate, Mode=TwoWay}"/>
</Grid>
</controls:GroupBox>
<CheckBox
HorizontalAlignment="Right"
Grid.Column="1"
IsChecked="{CompiledBinding LameDownsampleMono, Mode=TwoWay}"
ToolTip.Tip="{CompiledBinding LameDownsampleMonoTip}">
IsChecked="{Binding LameDownsampleMono, Mode=TwoWay}"
ToolTip.Tip="{Binding LameDownsampleMonoTip}">
<TextBlock
TextWrapping="Wrap"
@@ -249,8 +272,8 @@
<controls:WheelComboBox
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding SampleRates}"
SelectedItem="{CompiledBinding SelectedSampleRate, Mode=TwoWay}"/>
ItemsSource="{Binding SampleRates}"
SelectedItem="{Binding SelectedSampleRate, Mode=TwoWay}"/>
<TextBlock Margin="0,0,0,5" Grid.Column="2" Text="Encoder Quality:" />
@@ -258,23 +281,23 @@
Grid.Column="2"
Grid.Row="1"
HorizontalAlignment="Stretch"
ItemsSource="{CompiledBinding EncoderQualities}"
SelectedItem="{CompiledBinding SelectedEncoderQuality, Mode=TwoWay}"/>
ItemsSource="{Binding EncoderQualities}"
SelectedItem="{Binding SelectedEncoderQuality, Mode=TwoWay}"/>
</Grid>
<controls:GroupBox
Grid.Row="2"
Margin="0,5"
Label="Bitrate"
IsEnabled="{CompiledBinding LameTargetBitrate}" >
IsEnabled="{Binding LameTargetBitrate}" >
<StackPanel>
<Grid ColumnDefinitions="*,25,Auto">
<Slider
Grid.Column="0"
IsEnabled="{CompiledBinding !LameMatchSource}"
Value="{CompiledBinding LameBitrate, Mode=TwoWay}"
IsEnabled="{Binding !LameMatchSource}"
Value="{Binding LameBitrate, Mode=TwoWay}"
Minimum="16"
Maximum="320"
IsSnapToTickEnabled="True" TickFrequency="16"
@@ -283,7 +306,7 @@
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Tip" Value="{Binding $parent[Slider].Value, Mode=OneWay, StringFormat='\{0:f0\} Kbps'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
@@ -294,7 +317,7 @@
<TextBlock
Grid.Column="1"
HorizontalAlignment="Right"
Text="{CompiledBinding LameBitrate}" />
Text="{Binding LameBitrate}" />
<TextBlock
Grid.Column="2"
@@ -306,7 +329,7 @@
<CheckBox
Grid.Column="0"
IsChecked="{CompiledBinding LameConstantBitrate, Mode=TwoWay}">
IsChecked="{Binding LameConstantBitrate, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
@@ -317,7 +340,7 @@
<CheckBox
Grid.Column="1"
HorizontalAlignment="Right"
IsChecked="{CompiledBinding LameMatchSource, Mode=TwoWay}">
IsChecked="{Binding LameMatchSource, Mode=TwoWay}">
<TextBlock
TextWrapping="Wrap"
@@ -332,7 +355,7 @@
Grid.Row="3"
Margin="0,5"
Label="Quality"
IsEnabled="{CompiledBinding !LameTargetBitrate}">
IsEnabled="{Binding !LameTargetBitrate}">
<Grid
ColumnDefinitions="*,Auto,25"
@@ -341,7 +364,7 @@
<Slider
Grid.Column="0"
Grid.ColumnSpan="2"
Value="{CompiledBinding LameVBRQuality, Mode=TwoWay}"
Value="{Binding LameVBRQuality, Mode=TwoWay}"
Minimum="0"
Maximum="9"
IsSnapToTickEnabled="True" TickFrequency="1"
@@ -349,7 +372,7 @@
TickPlacement="Outside">
<Slider.Styles>
<Style Selector="Slider /template/ Thumb">
<Setter Property="ToolTip.Tip" Value="{CompiledBinding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Tip" Value="{Binding $parent[Slider].Value, Mode=OneWay, StringFormat='V\{0:f0\}'}" />
<Setter Property="ToolTip.Placement" Value="Top" />
<Setter Property="ToolTip.VerticalOffset" Value="-10" />
<Setter Property="ToolTip.HorizontalOffset" Value="-30" />
@@ -363,7 +386,7 @@
Orientation="Horizontal">
<TextBlock Text="V" />
<TextBlock Text="{CompiledBinding LameVBRQuality}" />
<TextBlock Text="{Binding LameVBRQuality}" />
</StackPanel>
@@ -397,8 +420,8 @@
Grid.Row="2"
Grid.ColumnSpan="2"
Margin="0,10,0,0"
IsEnabled="{CompiledBinding SplitFilesByChapter}"
Label="{CompiledBinding ChapterTitleTemplateText}">
IsEnabled="{Binding SplitFilesByChapter}"
Label="{Binding ChapterTitleTemplateText}">
<Grid ColumnDefinitions="*,Auto" Margin="0,8" >
@@ -406,7 +429,7 @@
Grid.Column="0"
FontSize="14"
IsReadOnly="True"
Text="{CompiledBinding ChapterTitleTemplate}" />
Text="{Binding ChapterTitleTemplate}" />
<Button
Grid.Column="1"

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();
@@ -56,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;
}
}
@@ -73,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

@@ -3,6 +3,9 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="600" d:DesignHeight="450"
xmlns:uibase="clr-namespace:LibationUiBase;assembly=LibationUiBase"
x:DataType="uibase:LocatedAudiobooksViewModel"
x:CompileBindings="True"
Width="600" Height="450"
x:Class="LibationAvalonia.Dialogs.LocateAudiobooksDialog"
Title="Locate Audiobooks"
@@ -13,17 +16,34 @@
<StackPanel Grid.Column="1" Orientation="Horizontal">
<TextBlock Text="IDs Found: " />
<TextBlock Text="{Binding FoundAsins}" />
<TextBlock Text="{Binding FoundAsinCount}" />
</StackPanel>
<ListBox Margin="0,5,0,0" Grid.Row="1" Grid.ColumnSpan="2" Name="foundAudiobooksLB" ItemsSource="{Binding FoundFiles}" AutoScrollToSelectedItem="true">
<ListBox.ItemTemplate>
<DataTemplate>
<Grid ColumnDefinitions="Auto,*">
<TextBlock Grid.Column="0" Margin="0,0,10,0" Text="{Binding Item1}" />
<TextBlock Grid.Column="1" Text="{Binding Item2}" />
</Grid>
</DataTemplate>
</ListBox.ItemTemplate>
</ListBox>
<DataGrid
Margin="0,5,0,0"
Grid.Row="1"
Grid.ColumnSpan="2"
IsReadOnly="True"
CanUserSortColumns="True"
CanUserResizeColumns="True"
GridLinesVisibility="All"
DoubleTapped="foundFilesDataGrid_DoubleTapped"
Name="foundFilesDataGrid"
ItemsSource="{Binding FoundFiles}">
<DataGrid.Styles>
<Style Selector="DataGridCell TextBlock">
<Setter Property="ToolTip.Tip" Value="Double-click to open containing folder."/>
</Style>
</DataGrid.Styles>
<DataGrid.Columns>
<DataGridTextColumn
Header="Found ID"
Width="Auto"
Binding="{Binding ID}" />
<DataGridTextColumn
Header="Found File"
Width="*"
Binding="{Binding FileName}" />
</DataGrid.Columns>
</DataGrid>
</Grid>
</Window>

View File

@@ -1,43 +1,39 @@
using ApplicationServices;
using Avalonia.Collections;
using Avalonia.Controls;
using Avalonia.Platform.Storage;
using DataLayer;
using LibationAvalonia.ViewModels;
using Dinah.Core;
using LibationFileManager;
using ReactiveUI;
using LibationUiBase;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Reactive.Linq;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
namespace LibationAvalonia.Dialogs
{
public partial class LocateAudiobooksDialog : DialogWindow
{
private event EventHandler<FilePathCache.CacheEntry>? FileFound;
private readonly CancellationTokenSource tokenSource = new();
private readonly List<string> foundAsins = new();
private readonly LocatedAudiobooksViewModel _viewModel;
public LocateAudiobooksDialog()
{
InitializeComponent();
DataContext = _viewModel = new();
var list = new AvaloniaList<FoundAudiobook>();
DataContext = _viewModel = new(list);
list.CollectionChanged += (_, _) => foundFilesDataGrid.ScrollIntoView(list[^1], foundFilesDataGrid.Columns[0]);
this.RestoreSizeAndLocation(Configuration.Instance);
if (Design.IsDesignMode)
{
_viewModel.FoundFiles.Add(new("[0000001]", "Filename 1.m4b"));
_viewModel.FoundFiles.Add(new("[0000002]", "Filename 2.m4b"));
_viewModel.AddFoundFile(new("0000000001", FileType.Audio, "Filename 1.m4b"));
_viewModel.AddFoundFile(new("0000000002", FileType.Audio, "Filename 2.m4b"));
}
else
{
Opened += LocateAudiobooksDialog_Opened;
FileFound += LocateAudiobooks_FileFound;
Closing += LocateAudiobooksDialog_Closing;
}
}
@@ -51,19 +47,6 @@ namespace LibationAvalonia.Dialogs
this.SaveSizeAndLocation(Configuration.Instance);
}
private void LocateAudiobooks_FileFound(object? sender, FilePathCache.CacheEntry e)
{
var newItem = new Tuple<string, string>($"[{e.Id}]", Path.GetFileName(e.Path));
_viewModel.FoundFiles.Add(newItem);
foundAudiobooksLB.SelectedItem = newItem;
if (!foundAsins.Any(asin => asin == e.Id))
{
foundAsins.Add(e.Id);
_viewModel.FoundAsins = foundAsins.Count;
}
}
private async void LocateAudiobooksDialog_Opened(object? sender, EventArgs e)
{
var folderPicker = new FolderPickerOpenOptions
@@ -78,37 +61,18 @@ namespace LibationAvalonia.Dialogs
if (selectedFolder is null || !Directory.Exists(selectedFolder))
{
await CancelAndCloseAsync();
return;
}
await foreach (var book in AudioFileStorage.FindAudiobooksAsync(selectedFolder, tokenSource.Token))
else
{
try
{
FilePathCache.Insert(book);
var lb = DbContexts.GetLibraryBook_Flat_NoTracking(book.Id);
if (lb is not null && lb.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await lb.UpdateBookStatusAsync(LiberatedStatus.Liberated);
tokenSource.Token.ThrowIfCancellationRequested();
FileFound?.Invoke(this, book);
}
catch (OperationCanceledException) { }
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book);
}
await _viewModel.FindAndAddBooksAsync(selectedFolder, tokenSource.Token);
await MessageBox.Show(this, $"Libation has found {_viewModel.FoundAsinCount} unique audiobooks and added them to its database. ", $"Found {_viewModel.FoundAsinCount} Audiobooks");
}
}
await MessageBox.Show(this, $"Libation has found {foundAsins.Count} unique audiobooks and added them to its database. ", $"Found {foundAsins.Count} Audiobooks");
await SaveAndCloseAsync();
private void foundFilesDataGrid_DoubleTapped(object? sender, Avalonia.Input.TappedEventArgs e)
{
if (sender is DataGrid dg && dg.SelectedItem is FoundAudiobook foundAudiobook)
Go.To.File(foundAudiobook.Entry.Path);
}
}
public class LocatedAudiobooksViewModel : ViewModelBase
{
public AvaloniaList<Tuple<string, string>> FoundFiles { get; } = new();
public int FoundAsins { get => field; set => this.RaiseAndSetIfChanged(ref field, value); }
}
}

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();

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