mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-14 00:39:15 -05:00
Compare commits
69 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
db9c810c2d | ||
|
|
ae18ae1b8d | ||
|
|
c1298e9ff6 | ||
|
|
647eb8b9d9 | ||
|
|
fd64d394c2 | ||
|
|
f026d415bd | ||
|
|
3948e25c99 | ||
|
|
6df47d4d9c | ||
|
|
0103c879f2 | ||
|
|
79e2bca8fc | ||
|
|
7bbc681767 | ||
|
|
c95dccd246 | ||
|
|
94cf665be7 | ||
|
|
7d38874257 | ||
|
|
659f793eb8 | ||
|
|
6f7cd4d5b5 | ||
|
|
068f37319f | ||
|
|
dc58a101af | ||
|
|
7b68415b02 | ||
|
|
1514de54da | ||
|
|
804bac5c4c | ||
|
|
3fa805d51f | ||
|
|
1eff725125 | ||
|
|
af1b1a70ae | ||
|
|
042f2e135a | ||
|
|
930fc3da58 | ||
|
|
90e8d03590 | ||
|
|
ee908a4f13 | ||
|
|
70ec31303b | ||
|
|
319d547aa0 | ||
|
|
a59e42e7c9 | ||
|
|
af2e89dd1e | ||
|
|
b2c5884e11 | ||
|
|
11d9cdefe2 | ||
|
|
54485ae150 | ||
|
|
4bd641ee50 | ||
|
|
6e56297434 | ||
|
|
e1f59eadbd | ||
|
|
cfda065219 | ||
|
|
e3ad4a2c32 | ||
|
|
2b1c772df7 | ||
|
|
9b3e4f8762 | ||
|
|
396d2c8a95 | ||
|
|
91090b74ab | ||
|
|
32979b5905 | ||
|
|
f6b96fc210 | ||
|
|
09e610fe08 | ||
|
|
e50d8c74de | ||
|
|
2b1ca13249 | ||
|
|
7d30a3036d | ||
|
|
bb8b435810 | ||
|
|
e850465ec1 | ||
|
|
29a5c943cb | ||
|
|
31087c0855 | ||
|
|
c91d359017 | ||
|
|
7dfdc0688a | ||
|
|
c6c36c74f1 | ||
|
|
d932b57853 | ||
|
|
a29da7318b | ||
|
|
678c3e6bcd | ||
|
|
a8466e38d4 | ||
|
|
802ccf25e8 | ||
|
|
c243b9c913 | ||
|
|
4d47ab3ebe | ||
|
|
8dea6200ce | ||
|
|
a578777352 | ||
|
|
e58e5165cf | ||
|
|
87c2cb6e19 | ||
|
|
cf932bd66c |
@@ -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",
|
||||
|
||||
3
Documentation/Advanced.md
Normal file
3
Documentation/Advanced.md
Normal 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))
|
||||
3
Documentation/AudioFileFormats.md
Normal file
3
Documentation/AudioFileFormats.md
Normal 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
3
Documentation/Docker.md
Normal 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))
|
||||
3
Documentation/FrequentlyAskedQuestions.md
Normal file
3
Documentation/FrequentlyAskedQuestions.md
Normal 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))
|
||||
3
Documentation/GettingStarted.md
Normal file
3
Documentation/GettingStarted.md
Normal 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))
|
||||
3
Documentation/InstallOnLinux.md
Normal file
3
Documentation/InstallOnLinux.md
Normal 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))
|
||||
3
Documentation/InstallOnMac.md
Normal file
3
Documentation/InstallOnMac.md
Normal 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))
|
||||
3
Documentation/LinuxDevelopmentSetupUsingNix.md
Normal file
3
Documentation/LinuxDevelopmentSetupUsingNix.md
Normal 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))
|
||||
3
Documentation/NamingTemplates.md
Normal file
3
Documentation/NamingTemplates.md
Normal 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))
|
||||
3
Documentation/SearchingAndFiltering.md
Normal file
3
Documentation/SearchingAndFiltering.md
Normal 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))
|
||||
49
README.md
49
README.md
@@ -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.
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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>
|
||||
|
||||
142
Source/ApplicationServices/ExportDto.cs
Normal file
142
Source/ApplicationServices/ExportDto.cs
Normal 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;
|
||||
}
|
||||
9
Source/ApplicationServices/ISearchEngine.cs
Normal file
9
Source/ApplicationServices/ISearchEngine.cs
Normal file
@@ -0,0 +1,9 @@
|
||||
using LibationSearchEngine;
|
||||
|
||||
#nullable enable
|
||||
namespace ApplicationServices;
|
||||
|
||||
public interface ISearchEngine
|
||||
{
|
||||
SearchResultSet? GetSearchResultSet(string? searchString);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
16
Source/ApplicationServices/MainSearchEngine.cs
Normal file
16
Source/ApplicationServices/MainSearchEngine.cs
Normal 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);
|
||||
}
|
||||
45
Source/ApplicationServices/TempSearchEngine.cs
Normal file
45
Source/ApplicationServices/TempSearchEngine.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
33
Source/AudibleUtilities/AudibleApiSanitizers.cs
Normal file
33
Source/AudibleUtilities/AudibleApiSanitizers.cs
Normal 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();
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
24
Source/AudibleUtilities/Extensions.cs
Normal file
24
Source/AudibleUtilities/Extensions.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
499
Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.Designer.cs
generated
Normal file
499
Source/DataLayer.Postgres/Migrations/20260107224301_AddIsAudiblePlus.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
482
Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.Designer.cs
generated
Normal file
482
Source/DataLayer.Sqlite/Migrations/20260107224303_AddIsAudiblePlus.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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}";
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
{
|
||||
|
||||
@@ -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
|
||||
};
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
184
Source/FileManager/MoveWithProgress.cs
Normal file
184
Source/FileManager/MoveWithProgress.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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 { }
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 }));
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -21,7 +21,6 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia;
|
||||
|
||||
public class App : Application
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Controls
|
||||
{
|
||||
public partial class DirectoryOrCustomSelectControl : UserControl
|
||||
|
||||
@@ -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 "";
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -3,7 +3,6 @@ using Avalonia.Controls;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class DescriptionDisplayDialog : Window
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
|
||||
|
||||
@@ -6,7 +6,6 @@ using ReactiveUI;
|
||||
using System.Collections.Generic;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class EditReplacementChars : DialogWindow
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
Codec"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
SortMemberPath="Bitrate"
|
||||
Binding="{CompiledBinding BitrateString}"
|
||||
Header="Existing
Bitrate"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding AvailableCodec}"
|
||||
Header="Available
Codec"/>
|
||||
|
||||
<DataGridTextColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
SortMemberPath="AvailableBitrate"
|
||||
Binding="{CompiledBinding AvailableBitrateString}"
|
||||
Header="Available
Bitrate"/>
|
||||
|
||||
<DataGridCheckBoxColumn
|
||||
Width="90"
|
||||
IsReadOnly="True"
|
||||
Binding="{CompiledBinding IsSignificant}"
|
||||
Header="Significantly
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>
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
{
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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); }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,6 @@ using LibationUiBase.Forms;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs.Login
|
||||
{
|
||||
public class AvaloniaLoginChoiceEager : ILoginChoiceEager
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -3,7 +3,6 @@ using LibationSearchEngine;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
#nullable enable
|
||||
namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
public partial class SearchSyntaxDialog : DialogWindow
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user