Compare commits

...

44 Commits

Author SHA1 Message Date
rmcrackan
144ab2162a incr ver 2025-08-05 12:58:45 -04:00
rmcrackan
6d0c4a9b3c Merge pull request #1322 from Mbucari/master
Improve Libation's interaction with the file system & other minor fixes
2025-08-05 12:57:03 -04:00
Michael Bucari-Tovo
8a682533c1 Change repo link to rmcrackan 2025-08-05 10:39:03 -06:00
Michael Bucari-Tovo
cecabc911e Add "Locate Audiobooks" help text 2025-08-05 10:29:48 -06:00
Michael Bucari-Tovo
e35f5209dc Don't change user's replacement character settings
Instead, add the NTFS-only invalid characters to the set of invalid filename characters.
2025-08-05 09:49:22 -06:00
MBucari
4ffe70af0e Fix serilog not logging caller name 2025-08-04 23:24:55 -06:00
MBucari
233ba3184f Add link to naming template wiki 2025-08-04 21:23:50 -06:00
Michael Bucari-Tovo
ac4c168725 Allow Libation to start with an invalid Books directory
- Configuration.LibationSettingsAreValid is true if Books property exists and is any non-null, non-empty string.
- If LibationSettingsAreValid is false, Libation will prompt user to set up Libation.
- When the main window is shown, Libation checks if the books directory exists, and if it doesn't, user is notified and prompted to change their setting
- When a user tries to liberate or convert a book, Books directory is validated and user notified if it does not exist.
2025-08-04 19:58:26 -06:00
Michael Bucari-Tovo
db588629c0 Null safety and minor UI bugfix
Properly cancel the Locate Audiobooks when the dialog window closes before scanning is finished.
2025-08-04 17:15:37 -06:00
Michael Bucari-Tovo
29be091a4b Fix cross-thread error on AccoundSettings.Saved event 2025-08-04 14:18:04 -06:00
Michael Bucari-Tovo
82a48db57b Fix walkthrough errors on chardonnay. 2025-08-04 10:27:37 -06:00
Mbucari
9f0f32a462 Merge branch 'rmcrackan:master' into master 2025-08-04 10:02:02 -06:00
rmcrackan
f64239b5ee Merge pull request #1316 from ajundi/master
Added nix flake and shell files for nixos developers
2025-08-04 09:55:42 -04:00
Ayman Jundi
bc8a35aedd Added nix flake and shell files for nixos users who want to work on development. You can use flake method => 'nix develop' or non flake method =>`nix-shell'
The shell.nix file is used for both flake and non-flake invocations. The lock file is also set at a version where the project works.
Note the none-flake method will follow the version of the system and isn't guaranteed to work on older installations if they haven't been updated in a while.
Added Documentation for using Nix package manager for development ./Documentation/LinuxDevelopmentSetupUsingNix.md

Signed-off-by: Ayman Jundi <ajundi@gmail.com>
2025-08-03 16:36:03 -04:00
Ayman Jundi
2fca6b8b91 Added launch.json and task.json entries that allows building and debugging for linux-64 targets without having to modify the csproj files.
Signed-off-by: Ayman Jundi <ajundi@gmail.com>
2025-08-03 15:33:17 -04:00
Mbucari
bc2eddd2dd Merge branch 'rmcrackan:master' into master 2025-07-31 10:36:26 -06:00
Michael Bucari-Tovo
ae012548bd Smart handling of filename limitations cross platform
Automatically determine if filename lengths in the Books directory are limited to 255 UTF-16 characters (NTFS) or 255 UTF-8 bytes (pretty much every other file system) (#1260)

In non-Windows environments, determine if the Books directory supports filenames containing characters which are illegal in Windows environments (<>|:*?). If it doesn't, then ensure those characters are included in the user's ReplacementCharacters settings (#1258).
2025-07-30 16:04:48 -06:00
rmcrackan
76a59873ea undo dependabot overreach 2025-07-30 09:42:43 -04:00
rmcrackan
3129fdba7b Merge pull request #1319 from rmcrackan/dependabot/nuget/Source/ApplicationServices/multi-ab24e9613a
Bump SixLabors.ImageSharp to 2.1.11, 3.1.11
2025-07-30 09:37:18 -04:00
dependabot[bot]
1771813849 Bump SixLabors.ImageSharp to 2.1.11, 3.1.11
---
updated-dependencies:
- dependency-name: SixLabors.ImageSharp
  dependency-version: 2.1.11
  dependency-type: direct:production
- dependency-name: SixLabors.ImageSharp
  dependency-version: 3.1.11
  dependency-type: direct:production
...

Signed-off-by: dependabot[bot] <support@github.com>
2025-07-30 13:30:26 +00:00
Michael Bucari-Tovo
7024bbf823 Provide NTFS default characters for non-windows users (#1258) 2025-07-29 15:20:52 -06:00
Michael Bucari-Tovo
663f70b8bf Use series Order string instead of parsed float in template tags (#1056) 2025-07-29 12:18:37 -06:00
rmcrackan
7741e3caff incr ver 2025-07-29 07:54:24 -04:00
rmcrackan
c82eefa768 Merge pull request #1317 from Mbucari/master
Bug fixes and UI tweak
2025-07-29 07:52:01 -04:00
Michael Bucari-Tovo
0e4231906a Set IsSpatial field instead of only flipping to true (#1273) 2025-07-28 15:28:04 -06:00
Michael Bucari-Tovo
9bca84dca4 Sort columns with null values always at the bottom 2025-07-28 09:29:17 -06:00
MBucari
ca30fd41c6 Use proper version string based on build version 2025-07-28 08:59:20 -06:00
MBucari
be96f99461 Increment Version 2025-07-27 11:40:35 -06:00
MBucari
f017fe419f Fix ID3 tag encoding error (#1315) 2025-07-27 11:38:01 -06:00
rmcrackan
ed42916cb2 incr ver 2025-07-26 23:11:51 -04:00
rmcrackan
0bb5bba3c8 Merge pull request #1314 from Mbucari/master
New audio format features, bug fixes, and minor tweaks/improvements.
2025-07-26 23:10:05 -04:00
MBucari
a887bf4619 Add "Is Spatial" grid column. 2025-07-26 18:19:19 -06:00
MBucari
53eebcd6ba Use single file downloader/namer if file has only 1 chapter 2025-07-25 16:02:28 -06:00
MBucari
a09ae1316d Don't display null file versions 2025-07-25 16:01:48 -06:00
MBucari
7088bd4b8d Check for file existance 2025-07-25 15:49:41 -06:00
MBucari
b27325cdcb Improve comvert to mp3 task
- Improve progress reporting and cancellation performance
- Clear current book from queue before queueing single convert to mp3 task
2025-07-25 15:35:03 -06:00
MBucari
accedeb1b1 Improve EditQuickFilters dialog reordering behavior 2025-07-25 14:23:14 -06:00
MBucari
c98c7c095a Fix quickfilter modification bug (#1313) 2025-07-25 14:22:29 -06:00
MBucari
9b217a4e18 Add audio format data
- Add Book.IsSpatial property and add it to search index
- Read audio format of actual output files and store it in UserDefinedItem. Now works with MP3s.
- Store last downloaded audio file version
- Add IsSpatial, file version, and Audio Format to library exports and to template tags. Updated docs.
- Add last downloaded audio file version and format info to the Last Downloaded tab
- Migrated the DB
- Update AAXClean with some bug fixes
  - Fixed error converting xHE-AAC audio files to mp3 when splitting by chapter (or trimming the audible branding from the beginning of the file)
  - Improve mp3 ID# tags support. Chapter titles are now preserved.
  - Add support for reading EC-3 and AC-4 audio format metadata
2025-07-25 12:18:50 -06:00
Michael Bucari-Tovo
a62a9ffc5b Use HttpClient in synchronous mode 2025-07-23 17:00:54 -06:00
Michael Bucari-Tovo
08aebf8ecf Add thread safety 2025-07-23 17:00:36 -06:00
Michael Bucari-Tovo
2f082a9656 Refactor and optimize audiobook download and decrypt process
- Add more null safety
- Fix possible FilePathCache race condition
- Add MoveFilesToBooksDir progress reporting
- All metadata is now downloaded in parallel with other post-success tasks.
- Improve download resuming and file cleanup reliability
- The downloader creates temp files with a UUID filename and does not insert them into the FilePathCache. Created files only receive their final file names when they are moved into the Books folder. This is to prepare for a future plan re naming templates
2025-07-23 16:55:09 -06:00
Michael Bucari-Tovo
1f473039e1 Make search syntax dialog field names scrollable 2025-07-22 15:39:43 -06:00
Michael Bucari-Tovo
0f4197924e Use LibationUiBase.ReactiveObject where applicable
Also tweak the classic process queue control layout
2025-07-22 11:59:34 -06:00
122 changed files with 3293 additions and 1409 deletions

13
.vscode/launch.json vendored
View File

@@ -6,7 +6,7 @@
"configurations": [
{
"name": ".NET Core Launch (console)",
"name": ".NET Core Launch (console) Windows",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build",
@@ -15,6 +15,17 @@
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
},
{
"name": ".NET Core Launch (console) Linux",
"type": "coreclr",
"request": "launch",
"preLaunchTask": "build_linux",
"program": "${workspaceFolder}/Source/bin/Avalonia/Debug/Libation.dll",
"args": [],
"cwd": "${workspaceFolder}",
"stopAtEntry": false,
"console": "internalConsole"
}
]

17
.vscode/tasks.json vendored
View File

@@ -37,6 +37,23 @@
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
},
{
"label": "build_linux",
"type": "shell",
"command": "dotnet",
"args": [
"build",
"${workspaceFolder}/Source/LibationAvalonia/LibationAvalonia.csproj",
"-p:TargetFramework=net9.0",
"-p:TargetFrameworks=net9.0",
"-p:RuntimeIdentifier=linux-x64"
],
"group": "build",
"presentation": {
//"reveal": "silent"
},
"problemMatcher": "$msCompile"
}
]
}

View File

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

View File

@@ -46,9 +46,12 @@ These tags will be replaced in the template with the audiobook's values.
|\<series\>|All series to which the book belongs (if any)|[Series List](#series-list-formatters)|
|\<first series\>|First series|[Series](#series-formatters)|
|\<series#\>|Number order in series (alias for \<first series[{#}]\>|[Number](#number-formatters)|
|\<bitrate\>|File's original bitrate (Kbps)|[Number](#number-formatters)|
|\<samplerate\>|File's original audio sample rate|[Number](#number-formatters)|
|\<channels\>|Number of audio channels|[Number](#number-formatters)|
|\<bitrate\>|Bitrate (kbps) of the last downloaded audiobook|[Number](#number-formatters)|
|\<samplerate\>|Sample rate (Hz) of the last downloaded audiobook|[Number](#number-formatters)|
|\<channels\>|Number of audio channels in the last downloaded audiobook|[Number](#number-formatters)|
|\<codec\>|Audio codec of the last downloaded audiobook|[Text](#text-formatters)|
|\<file version\>|Audible's file version number of the last downloaded audiobook|[Text](#text-formatters)|
|\<libation version\>|Libation version used during last download of the audiobook|[Text](#text-formatters)|
|\<account\>|Audible account of this book|[Text](#text-formatters)|
|\<account nickname\>|Audible account nickname of this book|[Text](#text-formatters)|
|\<locale\>|Region/country|[Text](#text-formatters)|

View File

Binary file not shown.

After

Width:  |  Height:  |  Size: 18 KiB

View File

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

View File

@@ -1,19 +1,21 @@
using AAXClean;
using System;
using System.IO;
using System.Linq;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public abstract class AaxcDownloadConvertBase : AudiobookDownloadBase
{
public event EventHandler<AppleTags> RetrievedMetadata;
public event EventHandler<AppleTags>? RetrievedMetadata;
public Mp4File AaxFile { get; private set; }
protected Mp4Operation AaxConversion { get; set; }
public Mp4File? AaxFile { get; private set; }
protected Mp4Operation? AaxConversion { get; set; }
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions) { }
protected AaxcDownloadConvertBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions) { }
/// <summary>Setting cover art by this method will insert the art into the audiobook metadata</summary>
public override void SetCoverArt(byte[] coverArt)
@@ -31,11 +33,13 @@ namespace AaxDecrypter
private Mp4File Open()
{
if (DownloadOptions.InputType is FileType.Dash)
if (DownloadOptions.DecryptionKeys is not KeyData[] keys || keys.Length == 0)
throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} cannot be null or empty for a '{DownloadOptions.InputType}' file.");
else if (DownloadOptions.InputType is FileType.Dash)
{
//We may have multiple keys , so use the key whose key ID matches
//the dash files default Key ID.
var keyIds = DownloadOptions.DecryptionKeys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
var keyIds = keys.Select(k => new Guid(k.KeyPart1, bigEndian: true)).ToArray();
var dash = new DashFile(InputFileStream);
var kidIndex = Array.IndexOf(keyIds, dash.Tenc.DefaultKID);
@@ -43,26 +47,38 @@ namespace AaxDecrypter
if (kidIndex == -1)
throw new InvalidOperationException($"None of the {keyIds.Length} key IDs match the dash file's default KeyID of {dash.Tenc.DefaultKID}");
DownloadOptions.DecryptionKeys[0] = DownloadOptions.DecryptionKeys[kidIndex];
var keyId = DownloadOptions.DecryptionKeys[kidIndex].KeyPart1;
var key = DownloadOptions.DecryptionKeys[kidIndex].KeyPart2;
keys[0] = keys[kidIndex];
var keyId = keys[kidIndex].KeyPart1;
var key = keys[kidIndex].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null decryption key (KeyPart2).");
dash.SetDecryptionKey(keyId, key);
WriteKeyFile($"KeyId={Convert.ToHexString(keyId)}{Environment.NewLine}Key={Convert.ToHexString(key)}");
return dash;
}
else if (DownloadOptions.InputType is FileType.Aax)
{
var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1);
var key = keys[0].KeyPart1;
aax.SetDecryptionKey(keys[0].KeyPart1);
WriteKeyFile($"ActivationBytes={Convert.ToHexString(key)}");
return aax;
}
else if (DownloadOptions.InputType is FileType.Aaxc)
{
var aax = new AaxFile(InputFileStream);
aax.SetDecryptionKey(DownloadOptions.DecryptionKeys[0].KeyPart1, DownloadOptions.DecryptionKeys[0].KeyPart2);
var key = keys[0].KeyPart1;
var iv = keys[0].KeyPart2 ?? throw new InvalidOperationException($"{nameof(DownloadOptions.DecryptionKeys)} for '{DownloadOptions.InputType}' must have a non-null initialization vector (KeyPart2).");
aax.SetDecryptionKey(keys[0].KeyPart1, iv);
WriteKeyFile($"Key={Convert.ToHexString(key)}{Environment.NewLine}IV={Convert.ToHexString(iv)}");
return aax;
}
else throw new InvalidOperationException($"{nameof(DownloadOptions.InputType)} of '{DownloadOptions.InputType}' is unknown.");
void WriteKeyFile(string contents)
{
var keyFile = Path.Combine(Path.ChangeExtension(InputFileStream.SaveFilePath, ".key"));
File.WriteAllText(keyFile, contents + Environment.NewLine);
OnTempFileCreated(new(keyFile));
}
}
protected bool Step_GetMetadata()
@@ -110,15 +126,15 @@ namespace AaxDecrypter
if (DownloadOptions.SeriesName is string series)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "SERIES", series);
if (DownloadOptions.SeriesNumber is float part)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part.ToString());
if (DownloadOptions.SeriesNumber is string part)
AaxFile.AppleTags.AppleListBox.EditOrAddFreeformTag(tagDomain, "PART", part);
}
OnInitialized();
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor);
OnRetrievedNarrators(AaxFile.AppleTags.Narrator);
OnRetrievedCoverArt(AaxFile.AppleTags.Cover);
OnInitialized();
return !IsCanceled;
}

View File

@@ -5,20 +5,20 @@ using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
{
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
private FileStream workingFileStream;
private FileStream? workingFileStream;
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
public AaxcDownloadMultiConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{
AsyncSteps.Name = $"Download, Convert Aaxc To {DownloadOptions.OutputFormat}, and Split";
AsyncSteps["Step 1: Get Aaxc Metadata"] = () => Task.Run(Step_GetMetadata);
AsyncSteps["Step 2: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 3: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
}
protected override void OnInitialized()
@@ -59,6 +59,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
*/
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.
@@ -83,10 +84,10 @@ That naming may not be desirable for everyone, but it's an easy change to instea
try
{
await (AaxConversion = decryptMultiAsync(splitChapters));
await (AaxConversion = decryptMultiAsync(AaxFile, splitChapters));
if (AaxConversion.IsCompletedSuccessfully)
await moveMoovToBeginning(workingFileStream?.Name);
await moveMoovToBeginning(AaxFile, workingFileStream?.Name);
return AaxConversion.IsCompletedSuccessfully;
}
@@ -97,17 +98,17 @@ That naming may not be desirable for everyone, but it's an easy change to instea
}
}
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
private Mp4Operation decryptMultiAsync(Mp4File aaxFile, ChapterInfo splitChapters)
{
var chapterCount = 0;
return
DownloadOptions.OutputFormat == OutputFormat.M4b
? AaxFile.ConvertToMultiMp4aAsync
? aaxFile.ConvertToMultiMp4aAsync
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback)
)
: AaxFile.ConvertToMultiMp3Async
: aaxFile.ConvertToMultiMp3Async
(
splitChapters,
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
@@ -116,33 +117,32 @@ That naming may not be desirable for everyone, but it's an easy change to instea
void newSplit(int currentChapter, ChapterInfo splitChapters, INewSplitCallback newSplitCallback)
{
moveMoovToBeginning(aaxFile, workingFileStream?.Name).GetAwaiter().GetResult();
var newTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
MultiConvertFileProperties props = new()
{
OutputFileName = OutputFileName,
OutputFileName = newTempFile.FilePath,
PartsPosition = currentChapter,
PartsTotal = splitChapters.Count,
Title = newSplitCallback?.Chapter?.Title,
Title = newSplitCallback.Chapter?.Title,
};
moveMoovToBeginning(workingFileStream?.Name).GetAwaiter().GetResult();
newSplitCallback.OutputFile = workingFileStream = createOutputFileStream(props);
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitle(props);
newSplitCallback.TrackNumber = currentChapter;
newSplitCallback.TrackCount = splitChapters.Count;
OnFileCreated(workingFileStream.Name);
OnTempFileCreated(newTempFile with { PartProperties = props });
}
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
{
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
FileUtility.SaferDelete(fileName);
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
FileUtility.SaferDelete(multiConvertFileProperties.OutputFileName);
return File.Open(multiConvertFileProperties.OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
}
}
private Mp4Operation moveMoovToBeginning(string filename)
private Mp4Operation moveMoovToBeginning(Mp4File aaxFile, string? filename)
{
if (DownloadOptions.OutputFormat is OutputFormat.M4b
&& DownloadOptions.MoveMoovToBeginning
@@ -151,7 +151,7 @@ That naming may not be desirable for everyone, but it's an easy change to instea
{
return Mp4File.RelocateMoovAsync(filename);
}
else return Mp4Operation.FromCompleted(AaxFile);
else return Mp4Operation.FromCompleted(aaxFile);
}
}
}

View File

@@ -6,13 +6,16 @@ using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
{
private readonly AverageSpeed averageSpeed = new();
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
: base(outFileName, cacheDirectory, dlOptions)
private TempFile? outputTempFile;
public AaxcDownloadSingleConverter(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
: base(outDirectory, cacheDirectory, dlOptions)
{
var step = 1;
@@ -21,7 +24,6 @@ namespace AaxDecrypter
AsyncSteps[$"Step {step++}: Download Decrypted Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
if (DownloadOptions.MoveMoovToBeginning && DownloadOptions.OutputFormat is OutputFormat.M4b)
AsyncSteps[$"Step {step++}: Move moov atom to beginning"] = Step_MoveMoov;
AsyncSteps[$"Step {step++}: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps[$"Step {step++}: Create Cue"] = Step_CreateCueAsync;
}
@@ -39,14 +41,16 @@ namespace AaxDecrypter
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
{
FileUtility.SaferDelete(OutputFileName);
if (AaxFile is null) return false;
outputTempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
FileUtility.SaferDelete(outputTempFile.FilePath);
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnFileCreated(OutputFileName);
using var outputFile = File.Open(outputTempFile.FilePath, FileMode.OpenOrCreate, FileAccess.ReadWrite);
OnTempFileCreated(outputTempFile);
try
{
await (AaxConversion = decryptAsync(outputFile));
await (AaxConversion = decryptAsync(AaxFile, outputFile));
return AaxConversion.IsCompletedSuccessfully;
}
@@ -58,14 +62,15 @@ namespace AaxDecrypter
private async Task<bool> Step_MoveMoov()
{
AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName);
if (outputTempFile is null) return false;
AaxConversion = Mp4File.RelocateMoovAsync(outputTempFile.FilePath);
AaxConversion.ConversionProgressUpdate += AaxConversion_MoovProgressUpdate;
await AaxConversion;
AaxConversion.ConversionProgressUpdate -= AaxConversion_MoovProgressUpdate;
return AaxConversion.IsCompletedSuccessfully;
}
private void AaxConversion_MoovProgressUpdate(object sender, ConversionProgressEventArgs e)
private void AaxConversion_MoovProgressUpdate(object? sender, ConversionProgressEventArgs e)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
@@ -84,20 +89,20 @@ namespace AaxDecrypter
});
}
private Mp4Operation decryptAsync(Stream outputFile)
private Mp4Operation decryptAsync(Mp4File aaxFile, Stream outputFile)
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
? AaxFile.ConvertToMp3Async
? aaxFile.ConvertToMp3Async
(
outputFile,
DownloadOptions.LameConfig,
DownloadOptions.ChapterInfo
)
: DownloadOptions.FixupFile
? AaxFile.ConvertToMp4aAsync
? aaxFile.ConvertToMp4aAsync
(
outputFile,
DownloadOptions.ChapterInfo
)
: AaxFile.ConvertToMp4aAsync(outputFile);
: aaxFile.ConvertToMp4aAsync(outputFile);
}
}

View File

@@ -6,55 +6,50 @@ using System;
using System.IO;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
{
public enum OutputFormat { M4b, Mp3 }
public abstract class AudiobookDownloadBase
{
public event EventHandler<string> RetrievedTitle;
public event EventHandler<string> RetrievedAuthors;
public event EventHandler<string> RetrievedNarrators;
public event EventHandler<byte[]> RetrievedCoverArt;
public event EventHandler<DownloadProgress> DecryptProgressUpdate;
public event EventHandler<TimeSpan> DecryptTimeRemaining;
public event EventHandler<string> FileCreated;
public event EventHandler<string?>? RetrievedTitle;
public event EventHandler<string?>? RetrievedAuthors;
public event EventHandler<string?>? RetrievedNarrators;
public event EventHandler<byte[]?>? RetrievedCoverArt;
public event EventHandler<DownloadProgress>? DecryptProgressUpdate;
public event EventHandler<TimeSpan>? DecryptTimeRemaining;
public event EventHandler<TempFile>? TempFileCreated;
public bool IsCanceled { get; protected set; }
protected AsyncStepSequence AsyncSteps { get; } = new();
protected string OutputFileName { get; }
protected string OutputDirectory { get; }
public IDownloadOptions DownloadOptions { get; }
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
protected NetworkFileStream InputFileStream => NfsPersister.NetworkFileStream;
protected virtual long InputFilePosition => InputFileStream.Position;
private bool downloadFinished;
private readonly NetworkFileStreamPersister nfsPersister;
private NetworkFileStreamPersister? m_nfsPersister;
private NetworkFileStreamPersister NfsPersister => m_nfsPersister ??= OpenNetworkFileStream();
private readonly DownloadProgress zeroProgress;
private readonly string jsonDownloadState;
private readonly string tempFilePath;
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
{
OutputFileName = ArgumentValidator.EnsureNotNullOrWhiteSpace(outFileName, nameof(outFileName));
protected AudiobookDownloadBase(string outDirectory, string cacheDirectory, IDownloadOptions dlOptions)
{
OutputDirectory = ArgumentValidator.EnsureNotNullOrWhiteSpace(outDirectory, nameof(outDirectory));
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
var outDir = Path.GetDirectoryName(OutputFileName);
if (!Directory.Exists(outDir))
Directory.CreateDirectory(outDir);
if (!Directory.Exists(OutputDirectory))
Directory.CreateDirectory(OutputDirectory);
if (!Directory.Exists(cacheDirectory))
Directory.CreateDirectory(cacheDirectory);
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
jsonDownloadState = Path.Combine(cacheDirectory, $"{DownloadOptions.AudibleProductId}.json");
tempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
DownloadOptions = ArgumentValidator.EnsureNotNull(dlOptions, nameof(dlOptions));
DownloadOptions.DownloadSpeedChanged += (_, speed) => InputFileStream.SpeedLimit = speed;
// delete file after validation is complete
FileUtility.SaferDelete(OutputFileName);
nfsPersister = OpenNetworkFileStream();
zeroProgress = new DownloadProgress
{
BytesReceived = 0,
@@ -65,24 +60,30 @@ namespace AaxDecrypter
OnDecryptProgressUpdate(zeroProgress);
}
protected TempFile GetNewTempFilePath(string extension)
{
extension = FileUtility.GetStandardizedExtension(extension);
var path = Path.Combine(OutputDirectory, Guid.NewGuid().ToString("N") + extension);
return new(path, extension);
}
public async Task<bool> RunAsync()
{
await InputFileStream.BeginDownloadingAsync();
var progressTask = Task.Run(reportProgress);
AsyncSteps[$"Cleanup"] = CleanupAsync;
(bool success, var elapsed) = await AsyncSteps.RunAsync();
//Stop the downloader so it doesn't keep running in the background.
if (!success)
nfsPersister.Dispose();
NfsPersister.Dispose();
await progressTask;
var speedup = DownloadOptions.RuntimeLength / elapsed;
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
nfsPersister.Dispose();
NfsPersister.Dispose();
return success;
async Task reportProgress()
@@ -129,50 +130,43 @@ namespace AaxDecrypter
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
public virtual void SetCoverArt(byte[] coverArt) { }
protected void OnRetrievedTitle(string title)
protected void OnRetrievedTitle(string? title)
=> RetrievedTitle?.Invoke(this, title);
protected void OnRetrievedAuthors(string authors)
protected void OnRetrievedAuthors(string? authors)
=> RetrievedAuthors?.Invoke(this, authors);
protected void OnRetrievedNarrators(string narrators)
protected void OnRetrievedNarrators(string? narrators)
=> RetrievedNarrators?.Invoke(this, narrators);
protected void OnRetrievedCoverArt(byte[] coverArt)
protected void OnRetrievedCoverArt(byte[]? coverArt)
=> RetrievedCoverArt?.Invoke(this, coverArt);
protected void OnDecryptProgressUpdate(DownloadProgress downloadProgress)
=> DecryptProgressUpdate?.Invoke(this, downloadProgress);
protected void OnDecryptTimeRemaining(TimeSpan timeRemaining)
=> DecryptTimeRemaining?.Invoke(this, timeRemaining);
protected void OnFileCreated(string path)
=> FileCreated?.Invoke(this, path);
public void OnTempFileCreated(TempFile path)
=> TempFileCreated?.Invoke(this, path);
protected virtual void FinalizeDownload()
{
nfsPersister?.Dispose();
NfsPersister.Dispose();
downloadFinished = true;
}
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
{
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
{
var recordsFile = await DownloadOptions.SaveClipsAndBookmarksAsync(OutputFileName);
if (File.Exists(recordsFile))
OnFileCreated(recordsFile);
}
return !IsCanceled;
}
protected async Task<bool> Step_CreateCueAsync()
{
if (!DownloadOptions.CreateCueSheet) return !IsCanceled;
if (DownloadOptions.ChapterInfo.Count <= 1)
{
Serilog.Log.Logger.Information($"Skipped creating .cue because book has no chapters.");
return !IsCanceled;
}
// not a critical step. its failure should not prevent future steps from running
try
{
var path = Path.ChangeExtension(OutputFileName, ".cue");
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
OnFileCreated(path);
var tempFile = GetNewTempFilePath(".cue");
await File.WriteAllTextAsync(tempFile.FilePath, Cue.CreateContents(Path.GetFileName(tempFile.FilePath), DownloadOptions.ChapterInfo));
OnTempFileCreated(tempFile);
}
catch (Exception ex)
{
@@ -181,58 +175,9 @@ namespace AaxDecrypter
return !IsCanceled;
}
private async Task<bool> CleanupAsync()
{
if (IsCanceled) return false;
FileUtility.SaferDelete(jsonDownloadState);
if (DownloadOptions.DecryptionKeys != null &&
DownloadOptions.RetainEncryptedFile &&
DownloadOptions.InputType is AAXClean.FileType fileType)
{
//Write aax decryption key
string keyPath = Path.ChangeExtension(tempFilePath, ".key");
FileUtility.SaferDelete(keyPath);
string aaxPath;
if (fileType is AAXClean.FileType.Aax)
{
await File.WriteAllTextAsync(keyPath, $"ActivationBytes={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
}
else if (fileType is AAXClean.FileType.Aaxc)
{
await File.WriteAllTextAsync(keyPath,
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
$"IV={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".aaxc");
}
else if (fileType is AAXClean.FileType.Dash)
{
await File.WriteAllTextAsync(keyPath,
$"KeyId={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart1)}{Environment.NewLine}" +
$"Key={Convert.ToHexString(DownloadOptions.DecryptionKeys[0].KeyPart2)}");
aaxPath = Path.ChangeExtension(tempFilePath, ".dash");
}
else
throw new InvalidOperationException($"Unknown file type: {fileType}");
if (tempFilePath != aaxPath)
FileUtility.SaferMove(tempFilePath, aaxPath);
OnFileCreated(aaxPath);
OnFileCreated(keyPath);
}
else
FileUtility.SaferDelete(tempFilePath);
return !IsCanceled;
}
private NetworkFileStreamPersister OpenNetworkFileStream()
{
NetworkFileStreamPersister nfsp = default;
NetworkFileStreamPersister? nfsp = default;
try
{
if (!File.Exists(jsonDownloadState))
@@ -253,8 +198,14 @@ namespace AaxDecrypter
}
finally
{
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
//nfsp will only be null when an unhandled exception occurs. Let the caller handle it.
if (nfsp is not null)
{
nfsp.NetworkFileStream.RequestHeaders["User-Agent"] = DownloadOptions.UserAgent;
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
OnTempFileCreated(new(tempFilePath, DownloadOptions.InputType.ToString()));
OnTempFileCreated(new(jsonDownloadState));
}
}
NetworkFileStreamPersister newNetworkFilePersister()

View File

@@ -1,6 +1,5 @@
using AAXClean;
using System;
using System.Threading.Tasks;
#nullable enable
namespace AaxDecrypter
@@ -33,11 +32,8 @@ namespace AaxDecrypter
KeyData[]? DecryptionKeys { get; }
TimeSpan RuntimeLength { get; }
OutputFormat OutputFormat { get; }
bool TrimOutputToChapterLength { get; }
bool RetainEncryptedFile { get; }
bool StripUnabridged { get; }
bool CreateCueSheet { get; }
bool DownloadClipsBookmarks { get; }
long DownloadSpeedBps { get; }
ChapterInfo ChapterInfo { get; }
bool FixupFile { get; }
@@ -47,14 +43,12 @@ namespace AaxDecrypter
string? Publisher { get; }
string? Language { get; }
string? SeriesName { get; }
float? SeriesNumber { get; }
string? SeriesNumber { get; }
NAudio.Lame.LameConfig? LameConfig { get; }
bool Downsample { get; }
bool MatchSourceBitrate { get; }
bool MoveMoovToBeginning { get; }
string GetMultipartFileName(MultiConvertFileProperties props);
string GetMultipartTitle(MultiConvertFileProperties props);
Task<string> SaveClipsAndBookmarksAsync(string fileName);
public FileType? InputType { get; }
}
}

View File

@@ -100,6 +100,12 @@ namespace AaxDecrypter
Position = WritePosition
};
if (_writeFile.Length < WritePosition)
{
_writeFile.Dispose();
throw new InvalidDataException($"{SaveFilePath} file length is shorter than {WritePosition}");
}
_readFile = new FileStream(SaveFilePath, FileMode.Open, FileAccess.Read, FileShare.ReadWrite);
SetUriForSameFile(uri);

View File

@@ -0,0 +1,17 @@
using FileManager;
#nullable enable
namespace AaxDecrypter;
public record TempFile
{
public LongPath FilePath { get; init; }
public string Extension { get; }
public MultiConvertFileProperties? PartProperties { get; init; }
public TempFile(LongPath filePath, string? extension = null)
{
FilePath = filePath;
extension ??= System.IO.Path.GetExtension(filePath);
Extension = FileUtility.GetStandardizedExtension(extension).ToLowerInvariant();
}
}

View File

@@ -1,5 +1,4 @@
using FileManager;
using System;
using System.Threading.Tasks;
namespace AaxDecrypter
@@ -8,13 +7,12 @@ namespace AaxDecrypter
{
protected override long InputFilePosition => InputFileStream.WritePosition;
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
: base(outFileName, cacheDirectory, dlLic)
public UnencryptedAudiobookDownloader(string outDirectory, string cacheDirectory, IDownloadOptions dlLic)
: base(outDirectory, cacheDirectory, dlLic)
{
AsyncSteps.Name = "Download Unencrypted Audiobook";
AsyncSteps["Step 1: Download Audiobook"] = Step_DownloadAndDecryptAudiobookAsync;
AsyncSteps["Step 2: Download Clips and Bookmarks"] = Step_DownloadClipsBookmarksAsync;
AsyncSteps["Step 3: Create Cue"] = Step_CreateCueAsync;
AsyncSteps["Step 2: Create Cue"] = Step_CreateCueAsync;
}
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
@@ -26,8 +24,9 @@ namespace AaxDecrypter
else
{
FinalizeDownload();
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
OnFileCreated(OutputFileName);
var tempFile = GetNewTempFilePath(DownloadOptions.OutputFormat.ToString());
FileUtility.SaferMove(InputFileStream.SaveFilePath, tempFile.FilePath);
OnTempFileCreated(tempFile);
return true;
}
}

View File

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

View File

@@ -121,7 +121,7 @@ namespace AppScaffolding
zipFileSink["Name"] = "File";
fileChanged = true;
}
var hooks = $"{nameof(LibationFileManager)}.{nameof(FileSinkHook)}, {nameof(LibationFileManager)}";
var hooks = typeof(FileSinkHook).AssemblyQualifiedName;
if (serilog.SelectToken("$.WriteTo[?(@.Name == 'File')].Args", false) is JObject fileSinkArgs
&& fileSinkArgs["hooks"]?.Value<string>() != hooks)
{
@@ -158,7 +158,8 @@ namespace AppScaffolding
// - with class and method info: "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception}";
// output example: 2019-11-26 08:48:40.224 -05:00 [DBG] (at LibationWinForms.Program.init()) Begin Libation
// {Properties:j} needed for expanded exception logging
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" }
{ "outputTemplate", "{Timestamp:yyyy-MM-dd HH:mm:ss.fff zzz} [{Level:u3}] (at {Caller}) {Message:lj}{NewLine}{Exception} {Properties:j}" },
{ "hooks", typeof(FileSinkHook).AssemblyQualifiedName }, // for FileSinkHook
}
}
}

View File

@@ -521,8 +521,8 @@ namespace ApplicationServices
udi.UpdateRating(rating.OverallRating, rating.PerformanceRating, rating.StoryRating);
});
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version libationVersion)
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion); });
public static int UpdateBookStatus(this LibraryBook lb, LiberatedStatus bookStatus, Version? libationVersion, AudioFormat audioFormat, string audioVersion)
=> lb.UpdateUserDefinedItem(udi => { udi.BookStatus = bookStatus; udi.SetLastDownloaded(libationVersion, audioFormat, audioVersion); });
public static int UpdateBookStatus(this LibraryBook libraryBook, LiberatedStatus bookStatus)
=> libraryBook.UpdateUserDefinedItem(udi => udi.BookStatus = bookStatus);

View File

@@ -4,8 +4,8 @@ using System.Linq;
using CsvHelper;
using CsvHelper.Configuration.Attributes;
using DataLayer;
using Newtonsoft.Json;
using NPOI.XSSF.UserModel;
using Serilog;
namespace ApplicationServices
{
@@ -115,7 +115,29 @@ namespace ApplicationServices
[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 class LibToDtos
{
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
@@ -135,16 +157,16 @@ namespace ApplicationServices
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,
CommunityRatingPerformance = a.Book.Rating?.PerformanceRating,
CommunityRatingStory = a.Book.Rating?.StoryRating,
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,
MyRatingPerformance = a.Book.UserDefinedItem.Rating.PerformanceRating,
MyRatingStory = a.Book.UserDefinedItem.Rating.StoryRating,
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(),
@@ -152,8 +174,13 @@ namespace ApplicationServices
Language = a.Book.Language,
LastDownloaded = a.Book.UserDefinedItem.LastDownloaded,
LastDownloadedVersion = a.Book.UserDefinedItem.LastDownloadedVersion?.ToString() ?? "",
IsFinished = a.Book.UserDefinedItem.IsFinished
}).ToList();
IsFinished = a.Book.UserDefinedItem.IsFinished,
IsSpatial = a.Book.IsSpatial,
LastDownloadedFileVersion = a.Book.UserDefinedItem.LastDownloadedFileVersion ?? "",
LastDownloadedFormat = a.Book.UserDefinedItem.LastDownloadedFormat
}).ToList();
private static float? ZeroIsNull(this float value) => value is 0 ? null : value;
}
public static class LibraryExporter
{
@@ -162,7 +189,6 @@ namespace ApplicationServices
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);
@@ -174,7 +200,7 @@ namespace ApplicationServices
public static void ToJson(string saveFilePath)
{
var dtos = DbContexts.GetLibrary_Flat_NoTracking().ToDtos();
var json = Newtonsoft.Json.JsonConvert.SerializeObject(dtos, Newtonsoft.Json.Formatting.Indented);
var json = JsonConvert.SerializeObject(dtos, Formatting.Indented);
System.IO.File.WriteAllText(saveFilePath, json);
}
@@ -227,7 +253,13 @@ namespace ApplicationServices
nameof(ExportDto.Language),
nameof(ExportDto.LastDownloaded),
nameof(ExportDto.LastDownloadedVersion),
nameof(ExportDto.IsFinished)
nameof(ExportDto.IsFinished),
nameof(ExportDto.IsSpatial),
nameof(ExportDto.LastDownloadedFileVersion),
nameof(ExportDto.CodecString),
nameof(ExportDto.SampleRate),
nameof(ExportDto.ChannelCount),
nameof(ExportDto.BitRate)
};
var col = 0;
foreach (var c in columns)
@@ -248,15 +280,10 @@ namespace ApplicationServices
foreach (var dto in dtos)
{
col = 0;
row = sheet.CreateRow(rowIndex);
row = sheet.CreateRow(rowIndex++);
row.CreateCell(col++).SetCellValue(dto.Account);
var dateCell = row.CreateCell(col++);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.DateAdded);
row.CreateCell(col++).SetCellValue(dto.DateAdded).CellStyle = dateStyle;
row.CreateCell(col++).SetCellValue(dto.AudibleProductId);
row.CreateCell(col++).SetCellValue(dto.Locale);
row.CreateCell(col++).SetCellValue(dto.Title);
@@ -269,56 +296,46 @@ namespace ApplicationServices
row.CreateCell(col++).SetCellValue(dto.HasPdf);
row.CreateCell(col++).SetCellValue(dto.SeriesNames);
row.CreateCell(col++).SetCellValue(dto.SeriesOrder);
col = createCell(row, col, dto.CommunityRatingOverall);
col = createCell(row, col, dto.CommunityRatingPerformance);
col = createCell(row, col, dto.CommunityRatingStory);
row.CreateCell(col++).SetCellValue(dto.CommunityRatingOverall);
row.CreateCell(col++).SetCellValue(dto.CommunityRatingPerformance);
row.CreateCell(col++).SetCellValue(dto.CommunityRatingStory);
row.CreateCell(col++).SetCellValue(dto.PictureId);
row.CreateCell(col++).SetCellValue(dto.IsAbridged);
var datePubCell = row.CreateCell(col++);
datePubCell.CellStyle = dateStyle;
if (dto.DatePublished.HasValue)
datePubCell.SetCellValue(dto.DatePublished.Value);
else
datePubCell.SetCellValue("");
row.CreateCell(col++).SetCellValue(dto.DatePublished).CellStyle = dateStyle;
row.CreateCell(col++).SetCellValue(dto.CategoriesNames);
col = createCell(row, col, dto.MyRatingOverall);
col = createCell(row, col, dto.MyRatingPerformance);
col = createCell(row, col, dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyRatingOverall);
row.CreateCell(col++).SetCellValue(dto.MyRatingPerformance);
row.CreateCell(col++).SetCellValue(dto.MyRatingStory);
row.CreateCell(col++).SetCellValue(dto.MyLibationTags);
row.CreateCell(col++).SetCellValue(dto.BookStatus);
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
row.CreateCell(col++).SetCellValue(dto.ContentType);
row.CreateCell(col++).SetCellValue(dto.Language);
if (dto.LastDownloaded.HasValue)
{
dateCell = row.CreateCell(col);
dateCell.CellStyle = dateStyle;
dateCell.SetCellValue(dto.LastDownloaded.Value);
}
row.CreateCell(++col).SetCellValue(dto.LastDownloadedVersion);
row.CreateCell(++col).SetCellValue(dto.IsFinished);
rowIndex++;
row.CreateCell(col++).SetCellValue(dto.Language);
row.CreateCell(col++).SetCellValue(dto.LastDownloaded).CellStyle = dateStyle;
row.CreateCell(col++).SetCellValue(dto.LastDownloadedVersion);
row.CreateCell(col++).SetCellValue(dto.IsFinished);
row.CreateCell(col++).SetCellValue(dto.IsSpatial);
row.CreateCell(col++).SetCellValue(dto.LastDownloadedFileVersion);
row.CreateCell(col++).SetCellValue(dto.CodecString);
row.CreateCell(col++).SetCellValue(dto.SampleRate);
row.CreateCell(col++).SetCellValue(dto.ChannelCount);
row.CreateCell(col++).SetCellValue(dto.BitRate);
}
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
workbook.Write(fileData);
}
private static int createCell(NPOI.SS.UserModel.IRow row, int col, float? nullableFloat)
{
if (nullableFloat.HasValue)
row.CreateCell(col++).SetCellValue(nullableFloat.Value);
else
row.CreateCell(col++).SetCellValue("");
return col;
}
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, DateTime? nullableDate)
=> nullableDate.HasValue ? cell.SetCellValue(nullableDate.Value)
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, int? nullableInt)
=> nullableInt.HasValue ? cell.SetCellValue(nullableInt.Value)
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
private static NPOI.SS.UserModel.ICell SetCellValue(this NPOI.SS.UserModel.ICell cell, float? nullableFloat)
=> nullableFloat.HasValue ? cell.SetCellValue(nullableFloat.Value)
: cell.SetCellType(NPOI.SS.UserModel.CellType.Numeric);
}
}

View File

@@ -5,7 +5,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="AudibleApi" Version="9.4.1.1" />
<PackageReference Include="AudibleApi" Version="9.4.2.1" />
<PackageReference Include="Google.Protobuf" Version="3.31.1" />
</ItemGroup>

View File

@@ -0,0 +1,70 @@
#nullable enable
using Newtonsoft.Json;
namespace DataLayer;
public enum Codec : byte
{
Unknown,
Mp3,
AAC_LC,
xHE_AAC,
EC_3,
AC_4
}
public class AudioFormat
{
public static AudioFormat Default => new(Codec.Unknown, 0, 0, 0);
[JsonIgnore]
public bool IsDefault => Codec is Codec.Unknown && BitRate == 0 && SampleRate == 0 && ChannelCount == 0;
[JsonIgnore]
public Codec Codec { get; set; }
public int SampleRate { get; set; }
public int ChannelCount { get; set; }
public int BitRate { get; set; }
public AudioFormat(Codec codec, int bitRate, int sampleRate, int channelCount)
{
Codec = codec;
BitRate = bitRate;
SampleRate = sampleRate;
ChannelCount = channelCount;
}
public string CodecString => Codec switch
{
Codec.Mp3 => "mp3",
Codec.AAC_LC => "AAC-LC",
Codec.xHE_AAC => "xHE-AAC",
Codec.EC_3 => "EC-3",
Codec.AC_4 => "AC-4",
Codec.Unknown or _ => "[Unknown]",
};
//Property | Start | Num | Max | Current Max |
// | Bit | Bits | Value | Value Used |
//-----------------------------------------------------
//Codec | 35 | 4 | 15 | 5 |
//BitRate | 23 | 12 | 4_095 | 768 |
//SampleRate | 5 | 18 | 262_143 | 48_000 |
//ChannelCount | 0 | 5 | 31 | 6 |
public long Serialize() =>
((long)Codec << 35) |
((long)BitRate << 23) |
((long)SampleRate << 5) |
(long)ChannelCount;
public static AudioFormat Deserialize(long value)
{
var codec = (Codec)((value >> 35) & 15);
var bitRate = (int)((value >> 23) & 4_095);
var sampleRate = (int)((value >> 5) & 262_143);
var channelCount = (int)(value & 31);
return new AudioFormat(codec, bitRate, sampleRate, channelCount);
}
public override string ToString()
=> IsDefault ? "[Unknown Audio Format]"
: $"{CodecString} ({ChannelCount}ch | {SampleRate:N0}Hz | {BitRate}kbps)";
}

View File

@@ -13,7 +13,6 @@ namespace DataLayer.Configurations
entity.OwnsOne(b => b.Rating);
entity.Property(nameof(Book._audioFormat));
//
// CRUCIAL: ignore unmapped collections, even get-only
//
@@ -50,6 +49,11 @@ namespace DataLayer.Configurations
b_udi
.Property(udi => udi.LastDownloadedVersion)
.HasConversion(ver => ver.ToString(), str => Version.Parse(str));
b_udi
.Property(udi => udi.LastDownloadedFormat)
.HasConversion(af => af.Serialize(), str => AudioFormat.Deserialize(str));
b_udi.Property(udi => udi.LastDownloadedFileVersion);
// owns it 1:1, store in same table
b_udi.OwnsOne(udi => udi.Rating);

View File

@@ -43,18 +43,13 @@ namespace DataLayer
public ContentType ContentType { get; private set; }
public string Locale { get; private set; }
//This field is now unused, however, there is little sense in adding a
//database migration to remove an unused field. Leave it for compatibility.
#pragma warning disable CS0649 // Field 'Book._audioFormat' is never assigned to, and will always have its default value 0
internal long _audioFormat;
#pragma warning restore CS0649
// mutable
public string PictureId { get; set; }
public string PictureLarge { get; set; }
// book details
public bool IsAbridged { get; private set; }
public bool IsSpatial { get; private set; }
public DateTime? DatePublished { get; private set; }
public string Language { get; private set; }
@@ -242,10 +237,11 @@ namespace DataLayer
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
=> Rating.Update(overallRating, performanceRating, storyRating);
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
public void UpdateBookDetails(bool isAbridged, bool? isSpatial, DateTime? datePublished, string language)
{
// don't overwrite with default values
IsAbridged |= isAbridged;
IsSpatial = isSpatial ?? IsSpatial;
DatePublished = datePublished ?? DatePublished;
Language = language?.FirstCharToUpper() ?? Language;
}

View File

@@ -24,24 +24,52 @@ namespace DataLayer
{
internal int BookId { get; private set; }
public Book Book { get; private set; }
public DateTime? LastDownloaded { get; private set; }
public Version LastDownloadedVersion { get; private set; }
/// <summary>
/// Date the audio file was last downloaded.
/// </summary>
public DateTime? LastDownloaded { get; private set; }
/// <summary>
/// Version of Libation used the last time the audio file was downloaded.
/// </summary>
public Version LastDownloadedVersion { get; private set; }
/// <summary>
/// Audio format of the last downloaded audio file.
/// </summary>
public AudioFormat LastDownloadedFormat { get; private set; }
/// <summary>
/// Version of the audio file that was last downloaded.
/// </summary>
public string LastDownloadedFileVersion { get; private set; }
public void SetLastDownloaded(Version version)
public void SetLastDownloaded(Version libationVersion, AudioFormat audioFormat, string audioVersion)
{
if (LastDownloadedVersion != version)
if (LastDownloadedVersion != libationVersion)
{
LastDownloadedVersion = version;
LastDownloadedVersion = libationVersion;
OnItemChanged(nameof(LastDownloadedVersion));
}
if (LastDownloadedFormat != audioFormat)
{
LastDownloadedFormat = audioFormat;
OnItemChanged(nameof(LastDownloadedFormat));
}
if (LastDownloadedFileVersion != audioVersion)
{
LastDownloadedFileVersion = audioVersion;
OnItemChanged(nameof(LastDownloadedFileVersion));
}
if (version is null)
if (libationVersion is null)
{
LastDownloaded = null;
LastDownloadedFormat = null;
LastDownloadedFileVersion = null;
}
else
{
LastDownloaded = DateTime.Now;
LastDownloaded = DateTime.Now;
OnItemChanged(nameof(LastDownloaded));
}
}
}
private UserDefinedItem() { }

View File

@@ -1,5 +1,6 @@
using Dinah.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore;
using Microsoft.EntityFrameworkCore.Diagnostics;
namespace DataLayer
{
@@ -7,6 +8,7 @@ namespace DataLayer
{
protected override LibationContext CreateNewInstance(DbContextOptions<LibationContext> options) => new LibationContext(options);
protected override void UseDatabaseEngine(DbContextOptionsBuilder optionsBuilder, string connectionString)
=> optionsBuilder.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
=> optionsBuilder.ConfigureWarnings(w => w.Ignore(RelationalEventId.PendingModelChangesWarning))
.UseSqlite(connectionString, ob => ob.UseQuerySplittingBehavior(QuerySplittingBehavior.SplitQuery));
}
}

View File

@@ -0,0 +1,474 @@
// <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("20250725074123_AddAudioFormatData")]
partial class AddAudioFormatData
{
/// <inheritdoc />
protected override void BuildTargetModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
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<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")
.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");
});
b.Navigation("Rating");
b.Navigation("Supplements");
b.Navigation("UserDefinedItem");
});
modelBuilder.Entity("DataLayer.BookCategory", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("CategoriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.CategoryLadder", "CategoryLadder")
.WithMany("BooksLink")
.HasForeignKey("CategoryLadderId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("CategoryLadder");
});
modelBuilder.Entity("DataLayer.BookContributor", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("ContributorsLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Contributor", "Contributor")
.WithMany("BooksLink")
.HasForeignKey("ContributorId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Contributor");
});
modelBuilder.Entity("DataLayer.LibraryBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithOne()
.HasForeignKey("DataLayer.LibraryBook", "BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
});
modelBuilder.Entity("DataLayer.SeriesBook", b =>
{
b.HasOne("DataLayer.Book", "Book")
.WithMany("SeriesLink")
.HasForeignKey("BookId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.HasOne("DataLayer.Series", "Series")
.WithMany("BooksLink")
.HasForeignKey("SeriesId")
.OnDelete(DeleteBehavior.Cascade)
.IsRequired();
b.Navigation("Book");
b.Navigation("Series");
});
modelBuilder.Entity("DataLayer.Book", b =>
{
b.Navigation("CategoriesLink");
b.Navigation("ContributorsLink");
b.Navigation("SeriesLink");
});
modelBuilder.Entity("DataLayer.CategoryLadder", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Contributor", b =>
{
b.Navigation("BooksLink");
});
modelBuilder.Entity("DataLayer.Series", b =>
{
b.Navigation("BooksLink");
});
#pragma warning restore 612, 618
}
}
}

View File

@@ -0,0 +1,48 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace DataLayer.Migrations
{
/// <inheritdoc />
public partial class AddAudioFormatData : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.RenameColumn(
name: "_audioFormat",
table: "Books",
newName: "IsSpatial");
migrationBuilder.AddColumn<string>(
name: "LastDownloadedFileVersion",
table: "UserDefinedItem",
type: "TEXT",
nullable: true);
migrationBuilder.AddColumn<long>(
name: "LastDownloadedFormat",
table: "UserDefinedItem",
type: "INTEGER",
nullable: true);
}
/// <inheritdoc />
protected override void Down(MigrationBuilder migrationBuilder)
{
migrationBuilder.DropColumn(
name: "LastDownloadedFileVersion",
table: "UserDefinedItem");
migrationBuilder.DropColumn(
name: "LastDownloadedFormat",
table: "UserDefinedItem");
migrationBuilder.RenameColumn(
name: "IsSpatial",
table: "Books",
newName: "_audioFormat");
}
}
}

View File

@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
protected override void BuildModel(ModelBuilder modelBuilder)
{
#pragma warning disable 612, 618
modelBuilder.HasAnnotation("ProductVersion", "8.0.5");
modelBuilder.HasAnnotation("ProductVersion", "9.0.7");
modelBuilder.Entity("CategoryCategoryLadder", b =>
{
@@ -53,6 +53,9 @@ namespace DataLayer.Migrations
b.Property<bool>("IsAbridged")
.HasColumnType("INTEGER");
b.Property<bool>("IsSpatial")
.HasColumnType("INTEGER");
b.Property<string>("Language")
.HasColumnType("TEXT");
@@ -74,9 +77,6 @@ namespace DataLayer.Migrations
b.Property<string>("Title")
.HasColumnType("TEXT");
b.Property<long>("_audioFormat")
.HasColumnType("INTEGER");
b.HasKey("BookId");
b.HasIndex("AudibleProductId");
@@ -318,6 +318,12 @@ namespace DataLayer.Migrations
b1.Property<DateTime?>("LastDownloaded")
.HasColumnType("TEXT");
b1.Property<string>("LastDownloadedFileVersion")
.HasColumnType("TEXT");
b1.Property<long?>("LastDownloadedFormat")
.HasColumnType("INTEGER");
b1.Property<string>("LastDownloadedVersion")
.HasColumnType("TEXT");

View File

@@ -2,6 +2,7 @@
using System.Linq;
using Microsoft.EntityFrameworkCore;
#nullable enable
namespace DataLayer
{
// only library importing should use tracking. All else should be NoTracking.
@@ -24,13 +25,13 @@ namespace DataLayer
.Where(c => !c.Book.IsEpisodeParent() || includeParents)
.ToList();
public static LibraryBook GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
public static LibraryBook? GetLibraryBook_Flat_NoTracking(this LibationContext context, string productId)
=> context
.LibraryBooks
.AsNoTrackingWithIdentityResolution()
.GetLibraryBook(productId);
public static LibraryBook GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
public static LibraryBook? GetLibraryBook(this IQueryable<LibraryBook> library, string productId)
=> library
.GetLibrary()
.SingleOrDefault(lb => lb.Book.AudibleProductId == productId);

View File

@@ -137,8 +137,6 @@ namespace DtoImporterService
book.ReplacePublisher(publisher);
}
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
if (item.PdfUrl is not null)
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
@@ -166,8 +164,9 @@ namespace DtoImporterService
// 2023-02-01
// updateBook must update language on books which were imported before the migration which added language.
// Can eventually delete this
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
// 2025-07-30
// updateBook must update isSpatial on books which were imported before the migration which added isSpatial.
book.UpdateBookDetails(item.IsAbridged, item.AssetDetails?.Any(a => a.IsSpatial), item.DatePublished, item.Language);
book.UpdateProductRating(
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),

View File

@@ -1,6 +1,7 @@
using System;
using System.Collections.Generic;
using System.Linq;
using AaxDecrypter;
using DataLayer;
using LibationFileManager;
using LibationFileManager.Templates;
@@ -34,30 +35,17 @@ namespace FileLiberator
return Templates.Folder.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, "");
}
/// <summary>
/// DownloadDecryptBook:
/// Path: in progress directory.
/// File name: final file name.
/// </summary>
public static string GetInProgressFilename(this AudioFileStorage _, LibraryBookDto libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook, AudibleFileStorage.DecryptInProgressDirectory, extension, returnFirstExisting: true);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension);
/// <summary>
/// PDF: audio file does not exist
/// </summary>
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBookDto dto, string extension)
=> Templates.File.GetFilename(dto, AudibleFileStorage.BooksDirectory, extension);
public static string GetBooksDirectoryFilename(this AudioFileStorage _, LibraryBook libraryBook, string extension, bool returnFirstExisting = false)
=> Templates.File.GetFilename(libraryBook.ToDto(), AudibleFileStorage.BooksDirectory, extension, returnFirstExisting: returnFirstExisting);
/// <summary>
/// PDF: audio file already exists
/// </summary>
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension)
=> Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension);
public static string GetCustomDirFilename(this AudioFileStorage _, LibraryBook libraryBook, string dirFullPath, string extension, MultiConvertFileProperties partProperties = null, bool returnFirstExisting = false)
=> partProperties is null ? Templates.File.GetFilename(libraryBook.ToDto(), dirFullPath, extension, returnFirstExisting: returnFirstExisting)
: Templates.ChapterFile.GetFilename(libraryBook.ToDto(), partProperties, dirFullPath, extension, returnFirstExisting: returnFirstExisting);
}
}

View File

@@ -0,0 +1,242 @@
using AAXClean;
using DataLayer;
using FileManager;
using Mpeg4Lib.Boxes;
using Mpeg4Lib.Util;
using NAudio.Lame.ID3;
using System;
using System.Collections.Generic;
using System.IO;
#nullable enable
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 AudioFormat FromMpeg4(string filename)
{
using var fileStream = File.Open(filename, FileMode.Open, FileAccess.Read, FileShare.Read);
return FromMpeg4(new Mp4File(fileStream));
}
public static AudioFormat FromMpeg4(Mp4File mp4File)
{
Codec codec;
if (mp4File.AudioSampleEntry.Dac4 is not null)
{
codec = Codec.AC_4;
}
else if (mp4File.AudioSampleEntry.Dec3 is not null)
{
codec = Codec.EC_3;
}
else if (mp4File.AudioSampleEntry.Esds is EsdsBox esds)
{
var objectType = esds.ES_Descriptor.DecoderConfig.AudioSpecificConfig.AudioObjectType;
codec
= objectType == 2 ? Codec.AAC_LC
: objectType == 42 ? Codec.xHE_AAC
: Codec.Unknown;
}
else
return AudioFormat.Default;
var bitrate = (int)Math.Round(mp4File.AverageBitrate / 1024d);
return new AudioFormat(codec, bitrate, mp4File.TimeScale, mp4File.AudioChannels);
}
public static AudioFormat FromMpeg3(LongPath mp3Filename)
{
using var mp3File = File.Open(mp3Filename, FileMode.Open, FileAccess.Read, FileShare.Read);
if (Id3Header.Create(mp3File) is Id3Header id3header)
id3header.SeekForwardToPosition(mp3File, mp3File.Position + id3header.Size);
else
{
Serilog.Log.Logger.Debug("File appears not to have ID3 tags.");
mp3File.Position = 0;
}
if (!SeekToFirstKeyFrame(mp3File))
{
Serilog.Log.Logger.Warning("Invalid frame sync read from file at end of ID3 tag.");
return AudioFormat.Default;
}
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);
return AudioFormat.Default;
}
#region read first mp3 frame header
//https://www.codeproject.com/Articles/8295/MPEG-Audio-Frame-Header#VBRIHeader
var reader = new BitReader(mp3File.ReadBlock(4));
reader.Position = 11; //Skip frame header magic bits
var versionId = (Version)reader.Read(2);
var layerDesc = (Layer)reader.Read(2);
if (layerDesc is not Layer.Layer_3)
{
Serilog.Log.Logger.Warning("Could not read mp3 data from {@layerVersion} file.", layerDesc.ToString());
return AudioFormat.Default;
}
if (versionId is Version.Reserved)
{
Serilog.Log.Logger.Warning("Mp3 data data cannot be read from a file with version = 'Reserved'");
return AudioFormat.Default;
}
var protectionBit = reader.ReadBool();
var bitrateIndex = reader.Read(4);
var freqIndex = reader.Read(2);
_ = reader.ReadBool(); //Padding bit
_ = reader.ReadBool(); //Private bit
var channelMode = reader.Read(2);
_ = reader.Read(2); //Mode extension
_ = reader.ReadBool(); //Copyright
_ = reader.ReadBool(); //Original
_ = reader.Read(2); //Emphasis
#endregion
//Read the sample rate,and channels from the first frame's header.
var sampleRate = Mp3SampleRateIndex[versionId][freqIndex];
var channelCount = channelMode == 3 ? 1 : 2;
//Try to read variable bitrate info from the first frame.
//Revert to fixed bitrate from frame header if not found.
var bitrate
= TryReadXingBitrate(out var br) ? br
: TryReadVbriBitrate(out br) ? br
: Mp3BitrateIndex[versionId][bitrateIndex];
return new AudioFormat(Codec.Mp3, bitrate, sampleRate, channelCount);
#region Variable bitrate header readers
bool TryReadXingBitrate(out int bitrate)
{
const int XingHeader = 0x58696e67;
const int InfoHeader = 0x496e666f;
var sideInfoSize = GetSideInfo(channelCount == 2, versionId) + (protectionBit ? 0 : 2);
mp3File.Position += sideInfoSize;
if (mp3File.ReadUInt32BE() is XingHeader or InfoHeader)
{
//Xing or Info header (common)
var flags = mp3File.ReadUInt32BE();
bool hasFramesField = (flags & 1) == 1;
bool hasBytesField = (flags & 2) == 2;
if (hasFramesField)
{
var numFrames = mp3File.ReadUInt32BE();
if (hasBytesField)
{
mpegSize = mp3File.ReadUInt32BE();
}
var samplesPerFrame = GetSamplesPerFrame(sampleRate);
var duration = samplesPerFrame * numFrames / sampleRate;
bitrate = (short)(mpegSize / duration / 1024 * 8);
return true;
}
}
else
mp3File.Position -= sideInfoSize + 4;
bitrate = 0;
return false;
}
bool TryReadVbriBitrate(out int bitrate)
{
const int VBRIHeader = 0x56425249;
mp3File.Position += 32;
if (mp3File.ReadUInt32BE() is VBRIHeader)
{
//VBRI header (rare)
_ = mp3File.ReadBlock(6);
mpegSize = mp3File.ReadUInt32BE();
var numFrames = mp3File.ReadUInt32BE();
var samplesPerFrame = GetSamplesPerFrame(sampleRate);
var duration = samplesPerFrame * numFrames / sampleRate;
bitrate = (short)(mpegSize / duration / 1024 * 8);
return true;
}
bitrate = 0;
return false;
}
#endregion
}
#region MP3 frame decoding helpers
private static bool SeekToFirstKeyFrame(Stream file)
{
//Frame headers begin with first 11 bits set.
const int MaxSeekBytes = 4096;
var maxPosition = Math.Min(file.Length, file.Position + MaxSeekBytes) - 2;
while (file.Position < maxPosition)
{
if (file.ReadByte() == 0xff)
{
if ((file.ReadByte() & 0xe0) == 0xe0)
{
file.Position -= 2;
return true;
}
file.Position--;
}
}
return false;
}
private enum Version
{
Version_2_5,
Reserved,
Version_2,
Version_1
}
private enum Layer
{
Reserved,
Layer_3,
Layer_2,
Layer_1
}
private static double GetSamplesPerFrame(int sampleRate) => sampleRate >= 32000 ? 1152 : 576;
private static byte GetSideInfo(bool stereo, Version version) => (stereo, version) switch
{
(true, Version.Version_1) => 32,
(true, Version.Version_2 or Version.Version_2_5) => 17,
(false, Version.Version_1) => 17,
(false, Version.Version_2 or Version.Version_2_5) => 9,
_ => 0,
};
private static readonly Dictionary<Version, ushort[]> Mp3SampleRateIndex = new()
{
{ Version.Version_2_5, [11025, 12000, 8000] },
{ Version.Version_2, [22050, 24000, 16000] },
{ Version.Version_1, [44100, 48000, 32000] },
};
private static readonly Dictionary<Version, short[]> Mp3BitrateIndex = new()
{
{ Version.Version_2_5, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]},
{ Version.Version_2, [-1, 8,16,24,32,40,48,56, 64, 80, 96,112,128,144,160,-1]},
{ Version.Version_1, [-1,32,40,48,56,64,80,96,112,128,160,192,224,256,320,-1]}
};
#endregion
}

View File

@@ -1,6 +1,7 @@
using System;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AAXClean;
using AAXClean.Codecs;
@@ -19,7 +20,13 @@ namespace FileLiberator
private readonly AaxDecrypter.AverageSpeed averageSpeed = new();
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
private CancellationTokenSource CancellationTokenSource { get; set; }
public override async Task CancelAsync()
{
await CancellationTokenSource.CancelAsync();
if (Mp4Operation is not null)
await Mp4Operation.CancelAsync();
}
public static bool ValidateMp3(LibraryBook libraryBook)
{
@@ -32,17 +39,29 @@ namespace FileLiberator
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
var cancellationToken = (CancellationTokenSource = new()).Token;
try
{
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId);
var m4bPaths = AudibleFileStorage.Audio.GetPaths(libraryBook.Book.AudibleProductId)
.Where(m4bPath => File.Exists(m4bPath))
.Select(m4bPath => new { m4bPath, proposedMp3Path = Mp3FileName(m4bPath), m4bSize = new FileInfo(m4bPath).Length })
.Where(p => !File.Exists(p.proposedMp3Path))
.ToArray();
foreach (var m4bPath in m4bPaths)
long totalInputSize = m4bPaths.Sum(p => p.m4bSize);
long sizeOfCompletedFiles = 0L;
foreach (var entry in m4bPaths)
{
var proposedMp3Path = Mp3FileName(m4bPath);
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
cancellationToken.ThrowIfCancellationRequested();
if (File.Exists(entry.proposedMp3Path) || !File.Exists(entry.m4bPath))
{
sizeOfCompletedFiles += entry.m4bSize;
continue;
}
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
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)
@@ -69,74 +88,85 @@ namespace FileLiberator
lameConfig.ID3.Track = trackCount > 0 ? $"{trackNum}/{trackCount}" : trackNum.ToString();
}
using var mp3File = File.Open(Path.GetTempFileName(), FileMode.OpenOrCreate, FileAccess.ReadWrite);
long currentFileNumBytesProcessed = 0;
try
{
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
await Mp4Operation;
if (Mp4Operation.IsCanceled)
var tempPath = Path.GetTempFileName();
using (var mp3File = File.Open(tempPath, FileMode.OpenOrCreate, FileAccess.ReadWrite))
{
FileUtility.SaferDelete(mp3File.Name);
return new StatusHandler { "Cancelled" };
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig, chapters);
Mp4Operation.ConversionProgressUpdate += m4bBook_ConversionProgressUpdate;
await Mp4Operation;
}
else
{
var realMp3Path
if (cancellationToken.IsCancellationRequested)
FileUtility.SaferDelete(tempPath);
cancellationToken.ThrowIfCancellationRequested();
var realMp3Path
= FileUtility.SaferMoveToValidPath(
mp3File.Name,
proposedMp3Path,
tempPath,
entry.proposedMp3Path,
Configuration.Instance.ReplacementCharacters,
extension: "mp3",
Configuration.Instance.OverwriteExisting);
SetFileTime(libraryBook, realMp3Path);
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
OnFileCreated(libraryBook, realMp3Path);
}
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "AAXClean error");
return new StatusHandler { "Conversion failed" };
SetFileTime(libraryBook, realMp3Path);
SetDirectoryTime(libraryBook, Path.GetDirectoryName(realMp3Path));
OnFileCreated(libraryBook, realMp3Path);
}
finally
{
if (Mp4Operation is not null)
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
Mp4Operation.ConversionProgressUpdate -= m4bBook_ConversionProgressUpdate;
m4bBook.InputStream.Close();
mp3File.Close();
sizeOfCompletedFiles += entry.m4bSize;
}
void m4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
{
currentFileNumBytesProcessed = (long)(e.FractionCompleted * entry.m4bSize);
var bytesCompleted = sizeOfCompletedFiles + currentFileNumBytesProcessed;
ConversionProgressUpdate(totalInputSize, bytesCompleted);
}
}
return new StatusHandler();
}
catch (Exception ex)
{
if (!cancellationToken.IsCancellationRequested)
{
Serilog.Log.Error(ex, "AAXClean error");
return new StatusHandler { "Conversion failed" };
}
return new StatusHandler { "Cancelled" };
}
finally
{
OnCompleted(libraryBook);
CancellationTokenSource.Dispose();
CancellationTokenSource = null;
}
return new StatusHandler();
}
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
private void ConversionProgressUpdate(long totalInputSize, long bytesCompleted)
{
averageSpeed.AddPosition(e.ProcessPosition.TotalSeconds);
averageSpeed.AddPosition(bytesCompleted);
var remainingTimeToProcess = (e.EndTime - e.ProcessPosition).TotalSeconds;
var estTimeRemaining = remainingTimeToProcess / averageSpeed.Average;
var remainingBytes = (totalInputSize - bytesCompleted);
var estTimeRemaining = remainingBytes / averageSpeed.Average;
if (double.IsNormal(estTimeRemaining))
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
double progressPercent = 100 * e.FractionCompleted;
double progressPercent = 100 * bytesCompleted / totalInputSize;
OnStreamingProgressChanged(
new DownloadProgress
{
ProgressPercentage = progressPercent,
BytesReceived = (long)(e.ProcessPosition - e.StartTime).TotalSeconds,
TotalBytesToReceive = (long)(e.EndTime - e.StartTime).TotalSeconds
BytesReceived = bytesCompleted,
TotalBytesToReceive = totalInputSize
});
}
}

View File

@@ -1,116 +1,105 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using AaxDecrypter;
using AaxDecrypter;
using ApplicationServices;
using AudibleApi.Common;
using DataLayer;
using Dinah.Core;
using Dinah.Core.ErrorHandling;
using Dinah.Core.Net.Http;
using FileManager;
using LibationFileManager;
using System;
using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
#nullable enable
namespace FileLiberator
{
public class DownloadDecryptBook : AudioDecodable
{
public override string Name => "Download & Decrypt";
private AudiobookDownloadBase abDownloader;
private readonly CancellationTokenSource cancellationTokenSource = new();
public class DownloadDecryptBook : AudioDecodable
{
public override string Name => "Download & Decrypt";
private CancellationTokenSource? cancellationTokenSource;
private AudiobookDownloadBase? abDownloader;
public override bool Validate(LibraryBook libraryBook) => !libraryBook.Book.Audio_Exists();
public override async Task CancelAsync()
{
cancellationTokenSource.Cancel();
if (abDownloader is not null)
await abDownloader.CancelAsync();
public override async Task CancelAsync()
{
if (abDownloader is not null) await abDownloader.CancelAsync();
if (cancellationTokenSource is not null) await cancellationTokenSource.CancelAsync();
}
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
var entries = new List<FilePathCache.CacheEntry>();
// these only work so minimally b/c CacheEntry is a record.
// in case of parallel decrypts, only capture the ones for this book id.
// if user somehow starts multiple decrypts of the same book in parallel: on their own head be it
void FilePathCache_Inserted(object sender, FilePathCache.CacheEntry e)
{
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
entries.Add(e);
}
void FilePathCache_Removed(object sender, FilePathCache.CacheEntry e)
{
if (e.Id.EqualsInsensitive(libraryBook.Book.AudibleProductId))
entries.Remove(e);
}
OnBegin(libraryBook);
var cancellationToken = cancellationTokenSource.Token;
public override async Task<StatusHandler> ProcessAsync(LibraryBook libraryBook)
{
OnBegin(libraryBook);
cancellationTokenSource = new CancellationTokenSource();
var cancellationToken = cancellationTokenSource.Token;
try
{
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
{
if (libraryBook.Book.Audio_Exists())
return new StatusHandler { "Cannot find decrypt. Final audio file already exists" };
DownloadValidation(libraryBook);
downloadValidation(libraryBook);
var api = await libraryBook.GetApiAsync();
var config = Configuration.Instance;
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, config, libraryBook, cancellationToken);
bool success = false;
try
{
FilePathCache.Inserted += FilePathCache_Inserted;
FilePathCache.Removed += FilePathCache_Removed;
success = await downloadAudiobookAsync(api, config, downloadOptions);
}
finally
{
FilePathCache.Inserted -= FilePathCache_Inserted;
FilePathCache.Removed -= FilePathCache_Removed;
}
// decrypt failed
if (!success || getFirstAudioFile(entries) == default)
{
await Task.WhenAll(
entries
.Where(f => f.FileType != FileType.AAXC)
.Select(f => Task.Run(() => FileUtility.SaferDelete(f.Path))));
using var downloadOptions = await DownloadOptions.InitiateDownloadAsync(api, Configuration.Instance, libraryBook, cancellationToken);
var result = await DownloadAudiobookAsync(api, downloadOptions, cancellationToken);
if (!result.Success || getFirstAudioFile(result.ResultFiles) is not TempFile audioFile)
{
// decrypt failed. Delete all output entries but leave the cache files.
result.ResultFiles.ForEach(f => FileUtility.SaferDelete(f.FilePath));
cancellationToken.ThrowIfCancellationRequested();
return new StatusHandler { "Decrypt failed" };
}
}
var finalStorageDir = getDestinationDirectory(libraryBook);
if (Configuration.Instance.RetainAaxFile)
{
//Add the cached aaxc and key files to the entries list to be moved to the Books directory.
result.ResultFiles.AddRange(getAaxcFiles(result.CacheFiles));
}
var moveFilesTask = Task.Run(() => moveFilesToBooksDir(libraryBook, entries, cancellationToken));
Task[] finalTasks =
[
Task.Run(() => downloadCoverArt(downloadOptions, cancellationToken)),
moveFilesTask,
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
];
//Set the last downloaded information on the book so that it can be used in the naming templates,
//but don't persist it until everything completes successfully (in the finally block)
var audioFormat = GetFileFormatInfo(downloadOptions, audioFile);
var audioVersion = downloadOptions.ContentMetadata.ContentReference.Version;
libraryBook.Book.UserDefinedItem.SetLastDownloaded(Configuration.LibationVersion, audioFormat, audioVersion);
var finalStorageDir = getDestinationDirectory(libraryBook);
//post-download tasks done in parallel.
var moveFilesTask = Task.Run(() => MoveFilesToBooksDir(libraryBook, finalStorageDir, result.ResultFiles, cancellationToken));
Task[] finalTasks =
[
moveFilesTask,
Task.Run(() => DownloadCoverArt(finalStorageDir, downloadOptions, cancellationToken)),
Task.Run(() => DownloadRecordsAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
Task.Run(() => DownloadMetadataAsync(api, finalStorageDir, downloadOptions, cancellationToken)),
Task.Run(() => WindowsDirectory.SetCoverAsFolderIcon(libraryBook.Book.PictureId, finalStorageDir, cancellationToken))
];
try
{
{
await Task.WhenAll(finalTasks);
}
catch when (!moveFilesTask.IsFaulted)
catch when (!moveFilesTask.IsFaulted)
{
//Swallow downloadCoverArt and SetCoverAsFolderIcon exceptions.
//Swallow DownloadCoverArt, DownloadRecordsAsync, DownloadMetadataAsync, and SetCoverAsFolderIcon exceptions.
//Only fail if the downloaded audio files failed to move to Books directory
}
finally
{
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{
await Task.Run(() => libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion));
{
if (moveFilesTask.IsCompletedSuccessfully && !cancellationToken.IsCancellationRequested)
{
libraryBook.UpdateBookStatus(LiberatedStatus.Liberated, Configuration.LibationVersion, audioFormat, audioVersion);
SetDirectoryTime(libraryBook, finalStorageDir);
foreach (var cacheFile in result.CacheFiles.Where(f => File.Exists(f.FilePath)))
{
//Delete cache files only after the download/decrypt operation completes successfully.
FileUtility.SaferDelete(cacheFile.FilePath);
}
}
}
@@ -122,59 +111,86 @@ namespace FileLiberator
return new StatusHandler { "Cancelled" };
}
finally
{
OnCompleted(libraryBook);
}
}
private async Task<bool> downloadAudiobookAsync(AudibleApi.Api api, Configuration config, DownloadOptions dlOptions)
{
var outFileName = AudibleFileStorage.Audio.GetInProgressFilename(dlOptions.LibraryBookDto, dlOptions.OutputFormat.ToString().ToLower());
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
abDownloader = new UnencryptedAudiobookDownloader(outFileName, cacheDir, dlOptions);
else
{
AaxcDownloadConvertBase converter
= config.SplitFilesByChapter ?
new AaxcDownloadMultiConverter(outFileName, cacheDir, dlOptions) :
new AaxcDownloadSingleConverter(outFileName, cacheDir, dlOptions);
if (config.AllowLibationFixup)
converter.RetrievedMetadata += Converter_RetrievedMetadata;
abDownloader = converter;
}
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
abDownloader.RetrievedTitle += OnTitleDiscovered;
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.FileCreated += (_, path) => OnFileCreated(dlOptions.LibraryBook, path);
// REAL WORK DONE HERE
var success = await abDownloader.RunAsync();
if (success && config.SaveMetadataToFile)
{
var metadataFile = LibationFileManager.Templates.Templates.File.GetFilename(dlOptions.LibraryBookDto, Path.GetDirectoryName(outFileName), ".metadata.json");
var item = await api.GetCatalogProductAsync(dlOptions.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(dlOptions.ContentMetadata.ContentReference));
File.WriteAllText(metadataFile, item.SourceJson.ToString());
OnFileCreated(dlOptions.LibraryBook, metadataFile);
}
return success;
{
OnCompleted(libraryBook);
cancellationTokenSource.Dispose();
cancellationTokenSource = null;
}
}
private void Converter_RetrievedMetadata(object sender, AAXClean.AppleTags tags)
private record AudiobookDecryptResult(bool Success, List<TempFile> ResultFiles, List<TempFile> CacheFiles);
private async Task<AudiobookDecryptResult> DownloadAudiobookAsync(AudibleApi.Api api, DownloadOptions dlOptions, CancellationToken cancellationToken)
{
if (sender is not AaxcDownloadConvertBase converter || converter.DownloadOptions is not DownloadOptions options)
var outpoutDir = AudibleFileStorage.DecryptInProgressDirectory;
var cacheDir = AudibleFileStorage.DownloadsInProgressDirectory;
var result = new AudiobookDecryptResult(false, [], []);
try
{
if (dlOptions.DrmType is not DrmType.Adrm and not DrmType.Widevine)
abDownloader = new UnencryptedAudiobookDownloader(outpoutDir, cacheDir, dlOptions);
else
{
AaxcDownloadConvertBase converter
= dlOptions.Config.SplitFilesByChapter && dlOptions.ChapterInfo.Count > 1 ?
new AaxcDownloadMultiConverter(outpoutDir, cacheDir, dlOptions) :
new AaxcDownloadSingleConverter(outpoutDir, cacheDir, dlOptions);
if (dlOptions.Config.AllowLibationFixup)
converter.RetrievedMetadata += Converter_RetrievedMetadata;
abDownloader = converter;
}
abDownloader.DecryptProgressUpdate += OnStreamingProgressChanged;
abDownloader.DecryptTimeRemaining += OnStreamingTimeRemaining;
abDownloader.RetrievedTitle += OnTitleDiscovered;
abDownloader.RetrievedAuthors += OnAuthorsDiscovered;
abDownloader.RetrievedNarrators += OnNarratorsDiscovered;
abDownloader.RetrievedCoverArt += AaxcDownloader_RetrievedCoverArt;
abDownloader.TempFileCreated += AbDownloader_TempFileCreated;
// REAL WORK DONE HERE
bool success = await abDownloader.RunAsync();
return result with { Success = success };
}
catch (Exception ex)
{
if (!cancellationToken.IsCancellationRequested)
Serilog.Log.Logger.Error(ex, "Error downloading audiobook {@Book}", dlOptions.LibraryBook.LogFriendly());
//don't throw any exceptions so the caller can delete any temp files.
return result;
}
finally
{
OnStreamingProgressChanged(new() { ProgressPercentage = 100 });
}
void AbDownloader_TempFileCreated(object? sender, TempFile e)
{
if (Path.GetDirectoryName(e.FilePath) == outpoutDir)
{
result.ResultFiles.Add(e);
}
else if (Path.GetDirectoryName(e.FilePath) == cacheDir)
{
result.CacheFiles.Add(e);
// Notify that the aaxc file has been created so that
// the UI can know about partially-downloaded files
if (getFileType(e) is FileType.AAXC)
OnFileCreated(dlOptions.LibraryBook, e.FilePath);
}
}
}
#region Decryptor event handlers
private void Converter_RetrievedMetadata(object? sender, AAXClean.AppleTags tags)
{
if (sender is not AaxcDownloadConvertBase converter ||
converter.AaxFile is not AAXClean.Mp4File aaxFile ||
converter.DownloadOptions is not DownloadOptions options ||
options.ChapterInfo.Chapters is not List<AAXClean.Chapter> chapters)
return;
#region Prevent erroneous truncation due to incorrect chapter info
@@ -185,159 +201,312 @@ namespace FileLiberator
//the chapter. This is never desirable, so pad the last chapter to match
//the original audio length.
var fileDuration = converter.AaxFile.Duration;
if (options.Config.StripAudibleBrandAudio)
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
var fileDuration = aaxFile.Duration;
if (options.Config.StripAudibleBrandAudio)
fileDuration -= TimeSpan.FromMilliseconds(options.ContentMetadata.ChapterInfo.BrandOutroDurationMs);
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
var durationDelta = fileDuration - options.ChapterInfo.EndOffset;
//Remove the last chapter and re-add it with the durationDelta that will
//make the chapter's end coincide with the end of the audio file.
var chapters = options.ChapterInfo.Chapters as List<AAXClean.Chapter>;
//make the chapter's end coincide with the end of the audio file.
var lastChapter = chapters[^1];
chapters.Remove(lastChapter);
options.ChapterInfo.Add(lastChapter.Title, lastChapter.Duration + durationDelta);
#endregion
#endregion
tags.Title ??= options.LibraryBookDto.TitleWithSubtitle;
tags.Album ??= tags.Title;
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
tags.AlbumArtists ??= tags.Artist;
tags.Album ??= tags.Title;
tags.Artist ??= string.Join("; ", options.LibraryBook.Book.Authors.Select(a => a.Name));
tags.AlbumArtists ??= tags.Artist;
tags.Generes = string.Join(", ", options.LibraryBook.Book.LowestCategoryNames());
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
tags.Comment ??= options.LibraryBook.Book.Description;
tags.LongDescription ??= tags.Comment;
tags.Publisher ??= options.LibraryBook.Book.Publisher;
tags.ProductID ??= options.ContentMetadata.ContentReference.Sku;
tags.Comment ??= options.LibraryBook.Book.Description;
tags.LongDescription ??= tags.Comment;
tags.Publisher ??= options.LibraryBook.Book.Publisher;
tags.Narrator ??= string.Join("; ", options.LibraryBook.Book.Narrators.Select(n => n.Name));
tags.Asin = options.LibraryBook.Book.AudibleProductId;
tags.Acr = options.ContentMetadata.ContentReference.Acr;
tags.Version = options.ContentMetadata.ContentReference.Version;
if (options.LibraryBook.Book.DatePublished is DateTime pubDate)
{
tags.Year ??= pubDate.Year.ToString();
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
}
}
private static void downloadValidation(LibraryBook libraryBook)
{
string errorString(string field)
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
string errorTitle()
{
var title
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
: libraryBook.Book.TitleWithSubtitle;
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
return errorBookTitle;
};
if (string.IsNullOrWhiteSpace(libraryBook.Account))
throw new Exception(errorString("Account"));
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
throw new Exception(errorString("Locale"));
}
private void AaxcDownloader_RetrievedCoverArt(object _, byte[] e)
{
if (Configuration.Instance.AllowLibationFixup)
{
try
{
e = OnRequestCoverArt();
abDownloader.SetCoverArt(e);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
}
}
if (e is not null)
OnCoverImageDiscovered(e);
{
tags.Year ??= pubDate.Year.ToString();
tags.ReleaseDate ??= pubDate.ToString("dd-MMM-yyyy");
}
}
/// <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 static void moveFilesToBooksDir(LibraryBook libraryBook, List<FilePathCache.CacheEntry> entries, CancellationToken cancellationToken)
{
// create final directory. move each file into it
var destinationDir = getDestinationDirectory(libraryBook);
cancellationToken.ThrowIfCancellationRequested();
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var realDest
= FileUtility.SaferMoveToValidPath(
entry.Path,
Path.Combine(destinationDir, Path.GetFileName(entry.Path)),
Configuration.Instance.ReplacementCharacters,
overwrite: Configuration.Instance.OverwriteExisting);
SetFileTime(libraryBook, realDest);
FilePathCache.Insert(libraryBook.Book.AudibleProductId, realDest);
// propagate corrected path. Must update cache with corrected path. Also want updated path for cue file (after this for-loop)
entries[i] = entry with { Path = realDest };
cancellationToken.ThrowIfCancellationRequested();
private void AaxcDownloader_RetrievedCoverArt(object? sender, byte[]? e)
{
if (Configuration.Instance.AllowLibationFixup && sender is AaxcDownloadConvertBase downloader)
{
try
{
e = OnRequestCoverArt();
downloader.SetCoverArt(e);
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to retrieve cover art from server.");
}
}
var cue = entries.FirstOrDefault(f => f.FileType == FileType.Cue);
if (cue != default)
{
Cue.UpdateFileName(cue.Path, getFirstAudioFile(entries).Path);
SetFileTime(libraryBook, cue.Path);
if (e is not null)
OnCoverImageDiscovered(e);
}
#endregion
#region Validation
private static void DownloadValidation(LibraryBook libraryBook)
{
string errorString(string field)
=> $"{errorTitle()}\r\nCannot download book. {field} is not known. Try re-importing the account which owns this book.";
string errorTitle()
{
var title
= (libraryBook.Book.TitleWithSubtitle.Length > 53)
? $"{libraryBook.Book.TitleWithSubtitle.Truncate(50)}..."
: libraryBook.Book.TitleWithSubtitle;
var errorBookTitle = $"{title} [{libraryBook.Book.AudibleProductId}]";
return errorBookTitle;
};
if (string.IsNullOrWhiteSpace(libraryBook.Account))
throw new InvalidOperationException(errorString("Account"));
if (string.IsNullOrWhiteSpace(libraryBook.Book.Locale))
throw new InvalidOperationException(errorString("Locale"));
}
#endregion
#region Post-success routines
/// <summary>Read the audio format from the audio file's metadata.</summary>
public AudioFormat GetFileFormatInfo(DownloadOptions options, TempFile firstAudioFile)
{
try
{
return firstAudioFile.Extension.ToLowerInvariant() switch
{
".m4b" or ".m4a" or ".mp4" => GetMp4AudioFormat(),
".mp3" => AudioFormatDecoder.FromMpeg3(firstAudioFile.FilePath),
_ => AudioFormat.Default
};
}
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);
return AudioFormat.Default;
}
AudioFormat GetMp4AudioFormat()
=> abDownloader is AaxcDownloadConvertBase converter && converter.AaxFile is AAXClean.Mp4File mp4File
? AudioFormatDecoder.FromMpeg4(mp4File)
: AudioFormatDecoder.FromMpeg4(firstAudioFile.FilePath);
}
/// <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)
{
cancellationToken.ThrowIfCancellationRequested();
AverageSpeed averageSpeed = new();
var totalSizeToMove = entries.Sum(f => new FileInfo(f.FilePath).Length);
long totalBytesMoved = 0;
for (var i = 0; i < entries.Count; i++)
{
var entry = entries[i];
var destFileName
= AudibleFileStorage.Audio.GetCustomDirFilename(
libraryBook,
destinationDir,
entry.Extension,
entry.PartProperties,
Configuration.Instance.OverwriteExisting);
var realDest
= FileUtility.SaferMoveToValidPath(
entry.FilePath,
destFileName,
Configuration.Instance.ReplacementCharacters,
entry.Extension,
Configuration.Instance.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
// propagate corrected path for cue file (after this for-loop)
entries[i] = entry with { FilePath = realDest };
SetFileTime(libraryBook, realDest);
OnFileCreated(libraryBook, realDest);
cancellationToken.ThrowIfCancellationRequested();
}
if (entries.FirstOrDefault(f => getFileType(f) is FileType.Cue) is TempFile cue
&& getFirstAudioFile(entries)?.FilePath is LongPath audioFilePath)
{
Cue.UpdateFileName(cue.FilePath, audioFilePath);
SetFileTime(libraryBook, cue.FilePath);
}
cancellationToken.ThrowIfCancellationRequested();
AudibleFileStorage.Audio.Refresh();
}
private static string getDestinationDirectory(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
if (!Directory.Exists(destinationDir))
Directory.CreateDirectory(destinationDir);
return destinationDir;
}
private static FilePathCache.CacheEntry getFirstAudioFile(IEnumerable<FilePathCache.CacheEntry> entries)
=> entries.FirstOrDefault(f => f.FileType == FileType.Audio);
private void DownloadCoverArt(LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
{
if (!options.Config.DownloadCoverArt) return;
private static void downloadCoverArt(DownloadOptions options, CancellationToken cancellationToken)
{
if (!Configuration.Instance.DownloadCoverArt) return;
var coverPath = "[null]";
var coverPath = "[null]";
try
{
coverPath
= AudibleFileStorage.Audio.GetCustomDirFilename(
options.LibraryBook,
destinationDir,
extension: ".jpg",
returnFirstExisting: Configuration.Instance.OverwriteExisting);
try
{
var destinationDir = getDestinationDirectory(options.LibraryBook);
coverPath = AudibleFileStorage.Audio.GetBooksDirectoryFilename(options.LibraryBookDto, ".jpg");
coverPath = Path.Combine(destinationDir, Path.GetFileName(coverPath));
if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath);
if (File.Exists(coverPath))
FileUtility.SaferDelete(coverPath);
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken);
if (picBytes.Length > 0)
{
File.WriteAllBytes(coverPath, picBytes);
SetFileTime(options.LibraryBook, coverPath);
OnFileCreated(options.LibraryBook, coverPath);
}
}
catch (Exception ex)
{
//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);
throw;
}
}
var picBytes = PictureStorage.GetPictureSynchronously(new(options.LibraryBook.Book.PictureLarge ?? options.LibraryBook.Book.PictureId, PictureSize.Native), cancellationToken);
if (picBytes.Length > 0)
{
File.WriteAllBytes(coverPath, picBytes);
SetFileTime(options.LibraryBook, coverPath);
}
}
catch (Exception ex)
{
//Failure to download cover art should not be considered a failure to download the book
Serilog.Log.Logger.Error(ex, $"Error downloading cover art of {options.LibraryBook.Book.AudibleProductId} to {coverPath} catalog product.");
throw;
}
}
}
public async Task DownloadRecordsAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
{
if (!options.Config.DownloadClipsBookmarks) return;
var recordsPath = "[null]";
var format = options.Config.ClipsBookmarksFileFormat;
var formatExtension = FileUtility.GetStandardizedExtension(format.ToString().ToLowerInvariant());
try
{
recordsPath
= AudibleFileStorage.Audio.GetCustomDirFilename(
options.LibraryBook,
destinationDir,
extension: formatExtension,
returnFirstExisting: Configuration.Instance.OverwriteExisting);
if (File.Exists(recordsPath))
FileUtility.SaferDelete(recordsPath);
var records = await api.GetRecordsAsync(options.AudibleProductId);
switch (format)
{
case Configuration.ClipBookmarkFormat.CSV:
RecordExporter.ToCsv(recordsPath, records);
break;
case Configuration.ClipBookmarkFormat.Xlsx:
RecordExporter.ToXlsx(recordsPath, records);
break;
case Configuration.ClipBookmarkFormat.Json:
RecordExporter.ToJson(recordsPath, options.LibraryBook, records);
break;
default:
throw new NotSupportedException($"Unsupported record export format: {format}");
}
SetFileTime(options.LibraryBook, recordsPath);
OnFileCreated(options.LibraryBook, recordsPath);
}
catch (Exception ex)
{
//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);
throw;
}
}
private async Task DownloadMetadataAsync(AudibleApi.Api api, LongPath destinationDir, DownloadOptions options, CancellationToken cancellationToken)
{
if (!options.Config.SaveMetadataToFile) return;
string metadataPath = "[null]";
try
{
metadataPath
= AudibleFileStorage.Audio.GetCustomDirFilename(
options.LibraryBook,
destinationDir,
extension: ".metadata.json",
returnFirstExisting: Configuration.Instance.OverwriteExisting);
if (File.Exists(metadataPath))
FileUtility.SaferDelete(metadataPath);
var item = await api.GetCatalogProductAsync(options.LibraryBook.Book.AudibleProductId, AudibleApi.CatalogOptions.ResponseGroupOptions.ALL_OPTIONS);
item.SourceJson.Add(nameof(ContentMetadata.ChapterInfo), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ChapterInfo));
item.SourceJson.Add(nameof(ContentMetadata.ContentReference), Newtonsoft.Json.Linq.JObject.FromObject(options.ContentMetadata.ContentReference));
cancellationToken.ThrowIfCancellationRequested();
File.WriteAllText(metadataPath, item.SourceJson.ToString());
SetFileTime(options.LibraryBook, metadataPath);
OnFileCreated(options.LibraryBook, metadataPath);
}
catch (Exception ex)
{
//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);
throw;
}
}
#endregion
#region Macros
private static string getDestinationDirectory(LibraryBook libraryBook)
{
var destinationDir = AudibleFileStorage.Audio.GetDestinationDirectory(libraryBook);
if (!Directory.Exists(destinationDir))
Directory.CreateDirectory(destinationDir);
return destinationDir;
}
private static FileType getFileType(TempFile file)
=> FileTypes.GetFileTypeFromPath(file.FilePath);
private static TempFile? getFirstAudioFile(IEnumerable<TempFile> entries)
=> entries.FirstOrDefault(f => File.Exists(f.FilePath) && getFileType(f) is FileType.Audio);
private static IEnumerable<TempFile> getAaxcFiles(IEnumerable<TempFile> entries)
=> entries.Where(f => File.Exists(f.FilePath) && (getFileType(f) is FileType.AAXC || f.Extension.Equals(".key", StringComparison.OrdinalIgnoreCase)));
#endregion
}
}

View File

@@ -10,7 +10,6 @@ using System.Collections.Generic;
using System.IO;
using System.Linq;
using System.Net.Http;
using System.Text.RegularExpressions;
using System.Threading;
using System.Threading.Tasks;
@@ -112,7 +111,6 @@ public partial class DownloadOptions
}
}
private static DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, LicenseInfo licInfo)
{
long chapterStartMs
@@ -126,13 +124,6 @@ public partial class DownloadOptions
RuntimeLength = TimeSpan.FromMilliseconds(licInfo.ContentMetadata.ChapterInfo.RuntimeLengthMs),
};
if (TryGetAudioInfo(licInfo.ContentMetadata.ContentUrl, out int? bitrate, out int? sampleRate, out int? channels))
{
dlOptions.LibraryBookDto.BitRate = bitrate;
dlOptions.LibraryBookDto.SampleRate = sampleRate;
dlOptions.LibraryBookDto.Channels = channels;
}
var titleConcat = config.CombineNestedChapterTitles ? ": " : null;
var chapters
= flattenChapters(licInfo.ContentMetadata.ChapterInfo.Chapters, titleConcat)
@@ -159,43 +150,6 @@ public partial class DownloadOptions
return dlOptions;
}
/// <summary>
/// The most reliable way to get these audio file properties is from the filename itself.
/// Using AAXClean to read the metadata works well for everything except AC-4 bitrate.
/// </summary>
private static bool TryGetAudioInfo(ContentUrl? contentUrl, out int? bitrate, out int? sampleRate, out int? channels)
{
bitrate = sampleRate = channels = null;
if (contentUrl?.OfflineUrl is not string url || !Uri.TryCreate(url, default, out var uri))
return false;
var file = Path.GetFileName(uri.LocalPath);
var match = AdrmAudioProperties().Match(file);
if (match.Success)
{
bitrate = int.Parse(match.Groups[1].Value);
sampleRate = int.Parse(match.Groups[2].Value);
channels = int.Parse(match.Groups[3].Value);
return true;
}
else if ((match = WidevineAudioProperties().Match(file)).Success)
{
bitrate = int.Parse(match.Groups[2].Value);
sampleRate = int.Parse(match.Groups[1].Value) * 1000;
channels = match.Groups[3].Value switch
{
"ec3" => 6,
"ac4" => 3,
_ => null
};
return true;
}
return false;
}
public static LameConfig GetLameOptions(Configuration config)
{
LameConfig lameConfig = new()
@@ -350,12 +304,4 @@ public partial class DownloadOptions
chapters.Remove(chapters[^1]);
}
}
static double RelativePercentDifference(long num1, long num2)
=> Math.Abs(num1 - num2) / (double)(num1 + num2);
[GeneratedRegex(@".+_(\d+)_(\d+)-(\w+).mp4", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex WidevineAudioProperties();
[GeneratedRegex(@".+_lc_(\d+)_(\d+)_(\d+).aax", RegexOptions.Singleline | RegexOptions.IgnoreCase)]
private static partial Regex AdrmAudioProperties();
}

View File

@@ -3,10 +3,8 @@ using AAXClean;
using Dinah.Core;
using DataLayer;
using LibationFileManager;
using System.Threading.Tasks;
using System;
using System.IO;
using ApplicationServices;
using LibationFileManager.Templates;
#nullable enable
@@ -28,15 +26,12 @@ namespace FileLiberator
public string Language => LibraryBook.Book.Language;
public string? AudibleProductId => LibraryBookDto.AudibleProductId;
public string? SeriesName => LibraryBookDto.FirstSeries?.Name;
public float? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
public string? SeriesNumber => LibraryBookDto.FirstSeries?.Number;
public NAudio.Lame.LameConfig? LameConfig { get; }
public string UserAgent => AudibleApi.Resources.Download_User_Agent;
public bool TrimOutputToChapterLength => Config.AllowLibationFixup && Config.StripAudibleBrandAudio;
public bool StripUnabridged => Config.AllowLibationFixup && Config.StripUnabridged;
public bool CreateCueSheet => Config.CreateCueSheet;
public bool DownloadClipsBookmarks => Config.DownloadClipsBookmarks;
public long DownloadSpeedBps => Config.DownloadSpeedLimit;
public bool RetainEncryptedFile => Config.RetainAaxFile;
public bool FixupFile => Config.AllowLibationFixup;
public bool Downsample => Config.AllowLibationFixup && Config.LameDownsampleMono;
public bool MatchSourceBitrate => Config.AllowLibationFixup && Config.LameMatchSourceBR && Config.LameTargetBitrate;
@@ -45,45 +40,9 @@ namespace FileLiberator
public AudibleApi.Common.DrmType DrmType { get; }
public AudibleApi.Common.ContentMetadata ContentMetadata { get; }
public string GetMultipartFileName(MultiConvertFileProperties props)
{
var baseDir = Path.GetDirectoryName(props.OutputFileName);
var extension = Path.GetExtension(props.OutputFileName);
return Templates.ChapterFile.GetFilename(LibraryBookDto, props, baseDir!, extension);
}
public string GetMultipartTitle(MultiConvertFileProperties props)
=> Templates.ChapterTitle.GetName(LibraryBookDto, props);
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
{
if (DownloadClipsBookmarks)
{
var format = Config.ClipsBookmarksFileFormat;
var formatExtension = format.ToString().ToLowerInvariant();
var filePath = Path.ChangeExtension(fileName, formatExtension);
var api = await LibraryBook.GetApiAsync();
var records = await api.GetRecordsAsync(LibraryBook.Book.AudibleProductId);
switch(format)
{
case Configuration.ClipBookmarkFormat.CSV:
RecordExporter.ToCsv(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Xlsx:
RecordExporter.ToXlsx(filePath, records);
break;
case Configuration.ClipBookmarkFormat.Json:
RecordExporter.ToJson(filePath, LibraryBook, records);
break;
}
return filePath;
}
return string.Empty;
}
public Configuration Config { get; }
private readonly IDisposable cancellation;
public void Dispose()
@@ -123,7 +82,6 @@ namespace FileLiberator
// no null/empty check for key/iv. unencrypted files do not have them
LibraryBookDto = LibraryBook.ToDto();
LibraryBookDto.Codec = licInfo.ContentMetadata.ContentReference.Codec;
cancellation =
config

View File

@@ -5,6 +5,7 @@ using System.Threading.Tasks;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using LibationFileManager;
using LibationFileManager.Templates;
#nullable enable
@@ -61,7 +62,13 @@ namespace FileLiberator
IsPodcastParent = libraryBook.Book.IsEpisodeParent(),
IsPodcast = libraryBook.Book.IsEpisodeChild() || libraryBook.Book.IsEpisodeParent(),
Language = libraryBook.Book.Language
Language = libraryBook.Book.Language,
Codec = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.CodecString,
BitRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.BitRate,
SampleRate = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.SampleRate,
Channels = libraryBook.Book.UserDefinedItem.LastDownloadedFormat?.ChannelCount,
LibationVersion = libraryBook.Book.UserDefinedItem.LastDownloadedVersion?.ToVersionString(),
FileVersion = libraryBook.Book.UserDefinedItem.LastDownloadedFileVersion
};
}
@@ -76,7 +83,7 @@ namespace FileLiberator
.Select(sb
=> new SeriesDto(
sb.Series.Name,
sb.Book.IsEpisodeParent() ? null : sb.Index,
sb.Book.IsEpisodeParent() ? null : sb.Order,
sb.Series.AudibleSeriesId)
).ToList();
}

View File

@@ -0,0 +1,74 @@
using System;
using System.IO;
namespace FileManager
{
public static class FileSystemTest
{
/// <summary>
/// Additional characters which are illegal for filenames in Windows environments.
/// Double quotes and slashes are already illegal filename characters on all platforms,
/// so they are not included here.
/// </summary>
public static string AdditionalInvalidWindowsFilenameCharacters { get; } = "<>|:*?";
/// <summary>
/// Test if the directory supports filenames with characters that are invalid on Windows (:, *, ?, &lt;, &gt;, |).
/// </summary>
public static bool CanWriteWindowsInvalidChars(LongPath directoryName)
{
var testFile = Path.Combine(directoryName, AdditionalInvalidWindowsFilenameCharacters + Guid.NewGuid().ToString());
return CanWriteFile(testFile);
}
/// <summary>
/// Test if the directory supports filenames with 255 unicode characters.
/// </summary>
public static bool CanWrite255UnicodeChars(LongPath directoryName)
{
const char unicodeChar = 'ü';
var testFileName = new string(unicodeChar, 255);
var testFile = Path.Combine(directoryName, testFileName);
return CanWriteFile(testFile);
}
/// <summary>
/// Test if a directory has write access by attempting to create an empty file in it.
/// <para/>Returns true even if the temporary file can not be deleted.
/// </summary>
public static bool CanWriteDirectory(LongPath directoryName)
{
if (!Directory.Exists(directoryName))
return false;
Serilog.Log.Logger.Debug("Testing write permissions for directory: {@DirectoryName}", directoryName);
var testFilePath = Path.Combine(directoryName, Guid.NewGuid().ToString());
return CanWriteFile(testFilePath);
}
private static bool CanWriteFile(LongPath filename)
{
try
{
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);
try
{
FileUtility.SaferDelete(filename);
}
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);
}
return true;
}
catch (Exception ex)
{
Serilog.Log.Logger.Debug(ex, "Error writing test file: {@filename}", filename);
return false;
}
}
}
}

View File

@@ -56,7 +56,7 @@ namespace FileManager
{
ArgumentValidator.EnsureNotNull(name, nameof(name));
name = ReplacementCharacters.Barebones.ReplaceFilenameChars(name);
name = ReplacementCharacters.Barebones(true).ReplaceFilenameChars(name);
return Task.Run(() => AddFileInternal(name, contents.Span, comment));
}

View File

@@ -74,12 +74,14 @@ namespace FileManager
}
public override int GetHashCode() => Replacements.GetHashCode();
public static readonly ReplacementCharacters Default
= IsWindows
? new()
{
Replacements = new Replacement[]
{
public static ReplacementCharacters Default(bool ntfs) => ntfs ? HiFi_NTFS : HiFi_Other;
public static ReplacementCharacters LoFiDefault(bool ntfs) => ntfs ? LoFi_NTFS : LoFi_Other;
public static ReplacementCharacters Barebones(bool ntfs) => ntfs ? BareBones_NTFS : BareBones_Other;
#region Defaults
private static readonly ReplacementCharacters HiFi_NTFS = new()
{
Replacements = [
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash(""),
Replacement.FilenameBackSlash(""),
@@ -91,28 +93,23 @@ namespace FileManager
Replacement.Colon("_"),
Replacement.Asterisk("✱"),
Replacement.QuestionMark(""),
Replacement.Pipe("⏐"),
}
}
: new()
{
Replacements = new Replacement[]
{
Replacement.Pipe("⏐")]
};
private static readonly ReplacementCharacters HiFi_Other = new()
{
Replacements = [
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash(""),
Replacement.FilenameBackSlash("\\"),
Replacement.OpenQuote("“"),
Replacement.CloseQuote("”"),
Replacement.OtherQuote("\"")
}
};
Replacement.OtherQuote("\"")]
};
public static readonly ReplacementCharacters LoFiDefault
= IsWindows
? new()
{
Replacements = new Replacement[]
{
private static readonly ReplacementCharacters LoFi_NTFS = new()
{
Replacements = [
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
@@ -121,56 +118,54 @@ namespace FileManager
Replacement.OtherQuote("'"),
Replacement.OpenAngleBracket("{"),
Replacement.CloseAngleBracket("}"),
Replacement.Colon("-"),
}
}
: new ()
{
Replacements = new Replacement[]
{
Replacement.Colon("-")]
};
private static readonly ReplacementCharacters LoFi_Other = new()
{
Replacements = [
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("\\"),
Replacement.OpenQuote("\""),
Replacement.CloseQuote("\""),
Replacement.OtherQuote("\"")
}
};
Replacement.OtherQuote("\"")]
};
public static readonly ReplacementCharacters Barebones
= IsWindows
? new ()
{
Replacements = new Replacement[]
{
private static readonly ReplacementCharacters BareBones_NTFS = new()
{
Replacements = [
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("_"),
Replacement.OpenQuote("_"),
Replacement.CloseQuote("_"),
Replacement.OtherQuote("_")
}
}
: new ()
{
Replacements = new Replacement[]
{
Replacement.OtherQuote("_")]
};
private static readonly ReplacementCharacters BareBones_Other = new()
{
Replacements = [
Replacement.OtherInvalid("_"),
Replacement.FilenameForwardSlash("_"),
Replacement.FilenameBackSlash("\\"),
Replacement.OpenQuote("\""),
Replacement.CloseQuote("\""),
Replacement.OtherQuote("\"")
}
};
Replacement.OtherQuote("\"")]
};
#endregion
/// <summary>
/// Characters to consider invalid in filenames in addition to those returned by <see cref="Path.GetInvalidFileNameChars()"/>
/// </summary>
public static char[] AdditionalInvalidFilenameCharacters { get; set; } = [];
private static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT;
internal static bool IsWindows => Environment.OSVersion.Platform is PlatformID.Win32NT;
private static readonly char[] invalidPathChars = Path.GetInvalidFileNameChars().Except(new[] {
private static char[] invalidPathChars { get; } = Path.GetInvalidFileNameChars().Except(new[] {
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray();
private static readonly char[] invalidSlashes = Path.GetInvalidFileNameChars().Intersect(new[] {
private static char[] invalidSlashes { get; } = Path.GetInvalidFileNameChars().Intersect(new[] {
Path.DirectorySeparatorChar, Path.AltDirectorySeparatorChar
}).ToArray();
@@ -229,8 +224,11 @@ namespace FileManager
return DefaultReplacement;
}
private static bool CharIsPathInvalid(char c)
=> invalidPathChars.Contains(c) || AdditionalInvalidFilenameCharacters.Contains(c);
public static bool ContainsInvalidPathChar(string path)
=> path.Any(c => invalidPathChars.Contains(c));
=> path.Any(CharIsPathInvalid);
public static bool ContainsInvalidFilenameChar(string path)
=> ContainsInvalidPathChar(path) || path.Any(c => invalidSlashes.Contains(c));
@@ -242,7 +240,7 @@ namespace FileManager
{
var c = fileName[i];
if (invalidPathChars.Contains(c)
if (CharIsPathInvalid(c)
|| invalidSlashes.Contains(c)
|| Replacements.Any(r => r.CharacterToReplace == c) /* Replace any other legal characters that they user wants. */ )
{
@@ -267,14 +265,14 @@ namespace FileManager
if (
(
invalidPathChars.Contains(c)
|| ( // Replace any other legal characters that they user wants.
CharIsPathInvalid(c)
|| ( // Replace any other legal characters that they user wants.
c != Path.DirectorySeparatorChar
&& c != Path.AltDirectorySeparatorChar
&& Replacements.Any(r => r.CharacterToReplace == c)
)
)
&& !( // replace all colons except drive letter designator on Windows
&& !( // replace all colons except drive letter designator on Windows
c == ':'
&& i == 1
&& Path.IsPathRooted(pathStr)
@@ -282,9 +280,9 @@ namespace FileManager
)
)
{
char preceding = i > 0 ? pathStr[i - 1] : default;
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
char preceding = i > 0 ? pathStr[i - 1] : default;
char succeeding = i < pathStr.Length - 1 ? pathStr[i + 1] : default;
builder.Append(GetPathCharReplacement(c, preceding, succeeding));
}
else
builder.Append(c);
@@ -301,23 +299,21 @@ namespace FileManager
public override object ReadJson(JsonReader reader, Type objectType, object? existingValue, JsonSerializer serializer)
{
var defaults = ReplacementCharacters.Default(ReplacementCharacters.IsWindows).Replacements;
var jObj = JObject.Load(reader);
var replaceArr = jObj[nameof(Replacement)];
var dict
= replaceArr?.ToObject<Replacement[]>()?.ToList()
?? ReplacementCharacters.Default.Replacements;
var dict = replaceArr?.ToObject<Replacement[]>()?.ToList() ?? defaults;
//Ensure that the first 6 replacements are for the expected chars and that all replacement strings are valid.
//If not, reset to default.
for (int i = 0; i < Replacement.FIXED_COUNT; i++)
{
if (dict.Count < Replacement.FIXED_COUNT
|| dict[i].CharacterToReplace != ReplacementCharacters.Barebones.Replacements[i].CharacterToReplace
|| dict[i].Description != ReplacementCharacters.Barebones.Replacements[i].Description)
|| dict[i].CharacterToReplace != defaults[i].CharacterToReplace
|| dict[i].Description != defaults[i].Description)
{
dict = ReplacementCharacters.Default.Replacements;
dict = defaults;
break;
}

View File

@@ -111,7 +111,7 @@ namespace LibationAvalonia
if (setupDialog.Config.LibationSettingsAreValid)
{
string? theme = setupDialog.SelectedTheme.Content as string;
setupDialog.Config.SetString(theme, nameof(ThemeVariant));
await RunMigrationsAsync(setupDialog.Config);
@@ -120,7 +120,10 @@ namespace LibationAvalonia
ShowMainWindow(desktop);
}
else
await CancelInstallation();
{
e.Cancel = true;
await CancelInstallation(setupDialog);
}
}
else if (setupDialog.IsReturningUser)
{
@@ -128,7 +131,8 @@ namespace LibationAvalonia
}
else
{
await CancelInstallation();
e.Cancel = true;
await CancelInstallation(setupDialog);
return;
}
@@ -139,11 +143,11 @@ namespace LibationAvalonia
var body = "An unrecoverable error occurred. Since this error happened before logging could be initialized, this error can not be written to the log file.";
try
{
await MessageBox.ShowAdminAlert(null, body, title, ex);
await MessageBox.ShowAdminAlert(setupDialog, body, title, ex);
}
catch
{
await MessageBox.Show($"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
await MessageBox.Show(setupDialog, $"{body}\r\n\r\n{ex.Message}\r\n\r\n{ex.StackTrace}", title, MessageBoxButtons.OK, MessageBoxIcon.Error);
}
return;
}
@@ -190,6 +194,7 @@ namespace LibationAvalonia
{
// path did not result in valid settings
var continueResult = await MessageBox.Show(
libationFilesDialog,
$"No valid settings were found at this location.\r\nWould you like to create a new install settings in this folder?\r\n\r\n{libationFilesDialog.SelectedDirectory}",
"New install?",
MessageBoxButtons.YesNo,
@@ -207,18 +212,18 @@ namespace LibationAvalonia
ShowMainWindow(desktop);
}
else
await CancelInstallation();
await CancelInstallation(libationFilesDialog);
}
else
await CancelInstallation();
await CancelInstallation(libationFilesDialog);
}
libationFilesDialog.Close();
}
static async Task CancelInstallation()
static async Task CancelInstallation(Window window)
{
await MessageBox.Show("Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
await MessageBox.Show(window, "Initial set up cancelled.", "Cancelled", MessageBoxButtons.OK, MessageBoxIcon.Warning);
Environment.Exit(0);
}

View File

@@ -92,13 +92,11 @@ namespace LibationAvalonia.Controls
base.UpdateDataValidation(property, state, error);
if (property == CommandProperty)
{
if (state == BindingValueType.BindingError)
var canExecure = !state.HasFlag(BindingValueType.HasError);
if (canExecure != _commandCanExecute)
{
if (_commandCanExecute)
{
_commandCanExecute = false;
UpdateIsEffectivelyEnabled();
}
_commandCanExecute = canExecure;
UpdateIsEffectivelyEnabled();
}
}
}

View File

@@ -61,7 +61,7 @@ namespace LibationAvalonia.Dialogs
private void Link_getlibation(object sender, Avalonia.Input.TappedEventArgs e) => Dinah.Core.Go.To.Url(AppScaffolding.LibationScaffolding.WebsiteUrl);
private void ViewReleaseNotes_Tapped(object sender, Avalonia.Input.TappedEventArgs e)
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToString(3)}");
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}");
}
public class AboutVM : ViewModelBase

View File

@@ -24,9 +24,8 @@
CanUserSortColumns="False"
AutoGenerateColumns="False"
IsReadOnly="False"
ItemsSource="{Binding Filters}"
ItemsSource="{CompiledBinding Filters}"
GridLinesVisibility="All">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Delete">
@@ -38,7 +37,7 @@
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding !IsDefault}"
IsEnabled="{CompiledBinding !IsDefault}"
Click="DeleteButton_Clicked" />
</DataTemplate>
@@ -48,14 +47,13 @@
<DataGridTextColumn
Width="*"
IsReadOnly="False"
Binding="{Binding Name, Mode=TwoWay}"
Binding="{CompiledBinding Name, Mode=TwoWay}"
Header="Name"/>
<DataGridTextColumn
Width="*"
IsReadOnly="False"
Binding="{Binding FilterString, Mode=TwoWay}"
Binding="{CompiledBinding FilterString, Mode=TwoWay}"
Header="Filter"/>
<DataGridTemplateColumn Header="Move&#xa;Up">
@@ -67,16 +65,19 @@
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding !IsDefault}"
ToolTip.Tip="Export account authorization to audible-cli"
Click="MoveUpButton_Clicked" />
Click="MoveUpButton_Clicked">
<Button.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<CompiledBinding Path="!IsTop" />
<CompiledBinding Path="!IsDefault" />
</MultiBinding>
</Button.IsEnabled>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Move&#xa;Down">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
@@ -86,15 +87,18 @@
VerticalAlignment="Stretch"
HorizontalAlignment="Stretch"
HorizontalContentAlignment="Center"
IsEnabled="{Binding !IsDefault}"
ToolTip.Tip="Export account authorization to audible-cli"
Click="MoveDownButton_Clicked" />
Click="MoveDownButton_Clicked">
<Button.IsEnabled>
<MultiBinding Converter="{x:Static BoolConverters.And}">
<CompiledBinding Path="!IsBottom" />
<CompiledBinding Path="!IsDefault" />
</MultiBinding>
</Button.IsEnabled>
</Button>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
</DataGrid.Columns>
</DataGrid>
<Grid

View File

@@ -1,15 +1,15 @@
using AudibleUtilities;
using Avalonia.Collections;
using Avalonia.Controls;
using LibationFileManager;
using ReactiveUI;
using System.Collections.ObjectModel;
using System.Linq;
namespace LibationAvalonia.Dialogs
{
public partial class EditQuickFilters : DialogWindow
{
public ObservableCollection<Filter> Filters { get; } = new();
public AvaloniaList<Filter> Filters { get; } = new();
public class Filter : ViewModels.ViewModelBase
{
@@ -17,11 +17,8 @@ namespace LibationAvalonia.Dialogs
public string Name
{
get => _name;
set
{
this.RaiseAndSetIfChanged(ref _name, value);
}
}
set => this.RaiseAndSetIfChanged(ref _name, value);
}
private string _filterString;
public string FilterString
@@ -35,6 +32,10 @@ namespace LibationAvalonia.Dialogs
}
}
public bool IsDefault { get; private set; } = true;
private bool _isTop;
private bool _isBottom;
public bool IsTop { get => _isTop; set => this.RaiseAndSetIfChanged(ref _isTop, value); }
public bool IsBottom { get => _isBottom; set => this.RaiseAndSetIfChanged(ref _isBottom, value); }
public QuickFilters.NamedFilter AsNamedFilter() => new(FilterString, Name);
@@ -44,12 +45,12 @@ namespace LibationAvalonia.Dialogs
InitializeComponent();
if (Design.IsDesignMode)
{
Filters = new ObservableCollection<Filter>([
new Filter { Name = "Filter 1", FilterString = "[filter1 string]" },
Filters = [
new Filter { Name = "Filter 1", FilterString = "[filter1 string]", IsTop = true },
new Filter { Name = "Filter 2", FilterString = "[filter2 string]" },
new Filter { Name = "Filter 3", FilterString = "[filter3 string]" },
new Filter { Name = "Filter 4", FilterString = "[filter4 string]" }
]);
new Filter { Name = "Filter 4", FilterString = "[filter4 string]", IsBottom = true },
new Filter()];
DataContext = this;
return;
}
@@ -65,6 +66,8 @@ namespace LibationAvalonia.Dialogs
ControlToFocusOnShow = this.FindControl<Button>(nameof(saveBtn));
var allFilters = QuickFilters.Filters.Select(f => new Filter { FilterString = f.Filter, Name = f.Name }).ToList();
allFilters[0].IsTop = true;
allFilters[^1].IsBottom = true;
allFilters.Add(new Filter());
foreach (var f in allFilters)
@@ -81,6 +84,7 @@ namespace LibationAvalonia.Dialogs
var newBlank = new Filter();
newBlank.PropertyChanged += Filter_PropertyChanged;
Filters.Insert(Filters.Count, newBlank);
ReIndexFilters();
}
protected override void SaveAndClose()
@@ -98,30 +102,54 @@ namespace LibationAvalonia.Dialogs
filter.PropertyChanged -= Filter_PropertyChanged;
Filters.Remove(filter);
ReIndexFilters();
}
}
public void MoveUpButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (e.Source is Button btn && btn.DataContext is Filter filter)
{
var index = Filters.IndexOf(filter);
if (index < 1) return;
if (e.Source is not Button btn || btn.DataContext is not Filter filter || filter.IsDefault)
return;
Filters.Remove(filter);
Filters.Insert(index - 1, filter);
}
var oldIndex = Filters.IndexOf(filter);
if (oldIndex < 1) return;
var filterCount = Filters.Count(f => !f.IsDefault);
MoveFilter(oldIndex, oldIndex - 1, filterCount);
}
public void MoveDownButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (e.Source is Button btn && btn.DataContext is Filter filter)
{
var index = Filters.IndexOf(filter);
if (index >= Filters.Count - 2) return;
if (e.Source is not Button btn || btn.DataContext is not Filter filter || filter.IsDefault)
return;
Filters.Remove(filter);
Filters.Insert(index + 1, filter);
var filterCount = Filters.Count(f => !f.IsDefault);
var oldIndex = Filters.IndexOf(filter);
if (oldIndex >= filterCount - 1) return;
MoveFilter(oldIndex, oldIndex + 1, filterCount);
}
private void MoveFilter(int oldIndex, int newIndex, int filterCount)
{
var filter = Filters[oldIndex];
Filters.RemoveAt(oldIndex);
Filters.Insert(newIndex, filter);
Filters[oldIndex].IsTop = oldIndex == 0;
Filters[newIndex].IsTop = newIndex == 0;
Filters[newIndex].IsBottom = newIndex == filterCount - 1;
Filters[oldIndex].IsBottom = oldIndex == filterCount - 1;
}
private void ReIndexFilters()
{
var filterCount = Filters.Count(f => !f.IsDefault);
for (int i = filterCount - 1; i >= 0; i--)
{
Filters[i].IsTop = i == 0;
Filters[i].IsBottom = i == filterCount - 1;
}
}
}

View File

@@ -6,6 +6,8 @@
MinWidth="500" MinHeight="450"
Width="500" Height="450"
x:Class="LibationAvalonia.Dialogs.EditReplacementChars"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
x:DataType="dialogs:EditReplacementChars"
Title="Illegal Character Replacement">
<Grid
@@ -23,31 +25,30 @@
BeginningEdit="ReplacementGrid_BeginningEdit"
CellEditEnding="ReplacementGrid_CellEditEnding"
KeyDown="ReplacementGrid_KeyDown"
ItemsSource="{Binding replacements}">
ItemsSource="{CompiledBinding replacements}">
<DataGrid.Columns>
<DataGridTemplateColumn Header="Char to&#xa;Replace">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox IsReadOnly="{Binding Mandatory}" Text="{Binding CharacterToReplace, Mode=TwoWay}" />
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
<TextBox IsReadOnly="{CompiledBinding Mandatory}" Text="{CompiledBinding CharacterToReplace, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Header="Replacement&#xa;Text">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox Text="{Binding ReplacementText, Mode=TwoWay}" />
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
<TextBox Text="{CompiledBinding ReplacementText, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
<DataGridTemplateColumn Width="*" Header="Description">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBox IsReadOnly="{Binding Mandatory}" Text="{Binding Description, Mode=TwoWay}" />
<DataTemplate x:DataType="dialogs:EditReplacementChars+ReplacementsExt">
<TextBox IsReadOnly="{CompiledBinding Mandatory}" Text="{CompiledBinding Description, Mode=TwoWay}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -55,21 +56,31 @@
</DataGrid.Columns>
</DataGrid>
<StackPanel
<Grid
Grid.Row="1"
Grid.Column="0"
RowDefinitions="Auto,Auto"
Margin="5"
Orientation="Horizontal">
<Button Margin="0,0,10,0" Command="{Binding Defaults}" Content="Defaults" />
<Button Margin="0,0,10,0" Command="{Binding LoFiDefaults}" Content="LoFi Defaults" />
<Button Command="{Binding Barebones}" Content="Barebones" />
</StackPanel>
ColumnDefinitions="Auto,Auto,Auto,Auto">
<TextBlock IsVisible="{CompiledBinding !EnvironmentIsWindows}" Text="This System:" Margin="0,0,10,0" VerticalAlignment="Center" />
<TextBlock IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Text="NTFS:" Margin="0,0,10,0" VerticalAlignment="Center" />
<Button Grid.Column="1" Margin="0,0,10,0" Command="{CompiledBinding Defaults}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="Defaults" />
<Button Grid.Column="2" Margin="0,0,10,0" Command="{CompiledBinding LoFiDefaults}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="LoFi Defaults" />
<Button Grid.Column="3" Command="{CompiledBinding Barebones}" CommandParameter="{CompiledBinding EnvironmentIsWindows}" Content="Barebones" />
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="1" Margin="0,10,10,0" Command="{CompiledBinding Defaults}" CommandParameter="True" Content="Defaults" />
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="2" Margin="0,10,10,0" Command="{CompiledBinding LoFiDefaults}" CommandParameter="True" Content="LoFi Defaults" />
<Button IsVisible="{CompiledBinding !EnvironmentIsWindows}" Grid.Row="1" Grid.Column="3" Margin="0,10,0,0" Command="{CompiledBinding Barebones}" CommandParameter="True" Content="Barebones" />
</Grid>
<StackPanel
Grid.Row="1"
Grid.Column="1"
Margin="5"
VerticalAlignment="Bottom"
Orientation="Horizontal">
<Button Margin="0,0,10,0" Command="{Binding Close}" Content="Cancel" />

View File

@@ -13,6 +13,8 @@ namespace LibationAvalonia.Dialogs
{
Configuration config;
public bool EnvironmentIsWindows => Configuration.IsWindows;
private readonly List<ReplacementsExt> SOURCE = new();
public DataGridCollectionView replacements { get; }
public EditReplacementChars()
@@ -23,7 +25,7 @@ namespace LibationAvalonia.Dialogs
if (Design.IsDesignMode)
{
LoadTable(ReplacementCharacters.Default.Replacements);
LoadTable(ReplacementCharacters.Default(true).Replacements);
}
DataContext = this;
@@ -35,12 +37,12 @@ namespace LibationAvalonia.Dialogs
LoadTable(config.ReplacementCharacters.Replacements);
}
public void Defaults()
=> LoadTable(ReplacementCharacters.Default.Replacements);
public void LoFiDefaults()
=> LoadTable(ReplacementCharacters.LoFiDefault.Replacements);
public void Barebones()
=> LoadTable(ReplacementCharacters.Barebones.Replacements);
public void Defaults(bool isNtfs)
=> LoadTable(ReplacementCharacters.Default(isNtfs).Replacements);
public void LoFiDefaults(bool isNtfs)
=> LoadTable(ReplacementCharacters.LoFiDefault(isNtfs).Replacements);
public void Barebones(bool isNtfs)
=> LoadTable(ReplacementCharacters.Barebones(isNtfs).Replacements);
protected override void SaveAndClose()
{

View File

@@ -6,24 +6,28 @@
Width="800" Height="450"
x:Class="LibationAvalonia.Dialogs.EditTemplateDialog"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
x:DataType="dialogs:EditTemplateDialog+EditTemplateViewModel"
Title="EditTemplateDialog">
<Grid RowDefinitions="Auto,*,Auto">
<Grid RowDefinitions="Auto,*,Auto" Margin="10">
<Grid
Grid.Row="0"
RowDefinitions="Auto,Auto"
ColumnDefinitions="*,Auto" Margin="5">
ColumnDefinitions="*,Auto"
Margin="0,0,0,10">
<TextBlock
Grid.Column="0"
Grid.Row="0"
Text="{Binding Description}" />
Margin="0,0,0,10"
Text="{CompiledBinding Description}" />
<TextBox
Grid.Column="0"
Grid.Row="1"
Name="userEditTbox"
Text="{Binding UserTemplateText, Mode=TwoWay}" />
Text="{CompiledBinding UserTemplateText, Mode=TwoWay}" />
<Button
Grid.Column="1"
@@ -32,9 +36,10 @@
VerticalAlignment="Stretch"
VerticalContentAlignment="Center"
Content="Reset to Default"
Command="{Binding ResetToDefault}"/>
Command="{CompiledBinding ResetToDefault}"/>
</Grid>
<Grid Grid.Row="1" ColumnDefinitions="Auto,*">
<Grid Grid.Row="1" ColumnDefinitions="Auto,*"
Margin="0,0,0,10">
<DataGrid
Grid.Row="0"
@@ -44,13 +49,13 @@
GridLinesVisibility="All"
AutoGenerateColumns="False"
DoubleTapped="EditTemplateViewModel_DoubleTapped"
ItemsSource="{Binding ListItems}" >
ItemsSource="{CompiledBinding ListItems}" >
<DataGrid.Columns>
<DataGridTemplateColumn Width="Auto" Header="Tag">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate>
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{Binding Item1}" />
<TextBlock Height="18" Margin="10,0,10,0" VerticalAlignment="Center" Text="{CompiledBinding Item1}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -61,7 +66,7 @@
<TextBlock
Height="18"
Margin="10,0,10,0"
VerticalAlignment="Center" Text="{Binding Item2}" />
VerticalAlignment="Center" Text="{CompiledBinding Item2}" />
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</DataGridTemplateColumn>
@@ -71,23 +76,22 @@
<Grid
Grid.Column="1"
Margin="5,0,5,0"
Margin="10,0,0,0"
RowDefinitions="Auto,*,Auto"
HorizontalAlignment="Stretch">
<TextBlock
Margin="5,5,5,10"
Margin="0,0,0,5"
Text="Example:"/>
<Border
Grid.Row="1"
Margin="5"
BorderThickness="1"
BorderBrush="{DynamicResource DataGridGridLinesBrush}">
<TextBlock
TextWrapping="WrapWithOverflow"
Inlines="{Binding Inlines}" />
Inlines="{CompiledBinding Inlines}" />
</Border>
@@ -95,13 +99,17 @@
Grid.Row="2"
Margin="5"
Foreground="Firebrick"
Text="{Binding WarningText}"
IsVisible="{Binding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
Text="{CompiledBinding WarningText}"
IsVisible="{CompiledBinding WarningText, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</Grid>
</Grid>
<controls:LinkLabel
Grid.Row="2"
VerticalAlignment="Center"
Text="Read about naming templates on the Wiki"
Command="{Binding GoToNamingTemplateWiki}" />
<Button
Grid.Row="2"
Margin="5"
Padding="30,5,30,5"
HorizontalAlignment="Right"
Content="Save"

View File

@@ -70,7 +70,7 @@ public partial class EditTemplateDialog : DialogWindow
public async void SaveButton_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
private class EditTemplateViewModel : ViewModels.ViewModelBase
internal class EditTemplateViewModel : ViewModels.ViewModelBase
{
private readonly Configuration config;
public InlineCollection Inlines { get; } = new();
@@ -96,6 +96,9 @@ public partial class EditTemplateDialog : DialogWindow
}
public void GoToNamingTemplateWiki()
=> 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
private string _userTemplateText;
public string UserTemplateText

View File

@@ -13,11 +13,12 @@ using System.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 event EventHandler<FilePathCache.CacheEntry>? FileFound;
private readonly CancellationTokenSource tokenSource = new();
private readonly List<string> foundAsins = new();
private readonly LocatedAudiobooksViewModel _viewModel;
@@ -41,7 +42,7 @@ namespace LibationAvalonia.Dialogs
}
}
private void LocateAudiobooksDialog_Closing(object sender, System.ComponentModel.CancelEventArgs e)
private void LocateAudiobooksDialog_Closing(object? sender, System.ComponentModel.CancelEventArgs e)
{
tokenSource.Cancel();
//If this dialog is closed before it's completed, Closing is fired
@@ -50,7 +51,7 @@ namespace LibationAvalonia.Dialogs
this.SaveSizeAndLocation(Configuration.Instance);
}
private void LocateAudiobooks_FileFound(object sender, FilePathCache.CacheEntry e)
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);
@@ -63,13 +64,13 @@ namespace LibationAvalonia.Dialogs
}
}
private async void LocateAudiobooksDialog_Opened(object sender, EventArgs e)
private async void LocateAudiobooksDialog_Opened(object? sender, EventArgs e)
{
var folderPicker = new FolderPickerOpenOptions
{
Title = "Select the folder to search for audiobooks",
AllowMultiple = false,
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books.PathWithoutPrefix)
SuggestedStartLocation = await StorageProvider.TryGetFolderFromPathAsync(Configuration.Instance.Books?.PathWithoutPrefix ?? "")
};
var selectedFolder = (await StorageProvider.OpenFolderPickerAsync(folderPicker))?.SingleOrDefault()?.TryGetLocalPath();
@@ -89,11 +90,13 @@ namespace LibationAvalonia.Dialogs
FilePathCache.Insert(book);
var lb = context.GetLibraryBook_Flat_NoTracking(book.Id);
if (lb?.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
if (lb is not null && lb.Book?.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await Task.Run(() => lb.UpdateBookStatus(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);

View File

@@ -2,71 +2,73 @@
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="650"
MinWidth="800" MinHeight="650"
MaxWidth="800" MaxHeight="650"
xmlns:dialogs="clr-namespace:LibationAvalonia.Dialogs"
x:DataType="dialogs:SearchSyntaxDialog"
mc:Ignorable="d" d:DesignWidth="800" d:DesignHeight="50"
MinWidth="500" MinHeight="650"
Width="800" Height="650"
x:Class="LibationAvalonia.Dialogs.SearchSyntaxDialog"
Title="Filter Options"
WindowStartupLocation="CenterOwner">
<Grid
Margin="10,0,10,10"
RowDefinitions="Auto,Auto,*"
ColumnDefinitions="Auto,Auto,Auto,Auto">
RowDefinitions="Auto,*"
ColumnDefinitions="*,*,*,*">
<Grid.Styles>
<Style Selector="Grid > Grid">
<Setter Property="Margin" Value="10,0" />
</Style>
<Style Selector="Grid > TextBlock">
<Setter Property="TextWrapping" Value="Wrap" />
</Style>
<Style Selector="TextBlock">
<Setter Property="FontSize" Value="12" />
<Setter Property="Margin" Value="10" />
<Setter Property="Margin" Value="0,5" />
</Style>
<Style Selector="ListBox">
<Setter Property="Margin" Value="0,5,0,10"/>
<Style Selector="^ > ListBoxItem">
<Setter Property="Padding" Value="0"/>
<Style Selector="^ TextBlock">
<Setter Property="Margin" Value="8,1"/>
</Style>
</Style>
</Style>
</Grid.Styles>
<TextBlock
Grid.Row="0"
Grid.Column="0"
<Grid
Grid.ColumnSpan="4"
Text="Full Lucene query syntax is supported&#xa;Fields with similar names are synomyns (eg: Author, Authors, AuthorNames)&#xa;&#xa;TAG FORMAT: [tagName]" />
<TextBlock
Grid.Row="1"
Grid.Column="0"
Text="STRING FIELDS" />
<TextBlock
Grid.Row="1"
Grid.Column="1"
Text="NUMBER FIELDS" />
<TextBlock
Grid.Row="1"
Grid.Column="2"
Text="BOOLEAN (TRUE/FALSE) FIELDS" />
<TextBlock
Grid.Row="1"
Grid.Column="3"
Text="ID FIELDS" />
<TextBlock
Grid.Row="2"
Grid.Column="0"
Text="{Binding StringFields}" />
<TextBlock
Grid.Row="2"
Grid.Column="1"
Text="{Binding NumberFields}" />
<TextBlock
Grid.Row="2"
Grid.Column="2"
Text="{Binding BoolFields}" />
<TextBlock
Grid.Row="2"
Grid.Column="3"
Text="{Binding IdFields}" />
RowDefinitions="Auto,Auto">
<TextBlock
Text="Full Lucene query syntax is supported&#xa;Fields with similar names are synomyns (eg: Author, Authors, AuthorNames)" />
<TextBlock Grid.Row="1" Text="TAG FORMAT: [tagName]" />
</Grid>
<Grid Grid.Row="1" RowDefinitions="Auto,Auto,*">
<TextBlock Text="NUMBER FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding StringUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding StringFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="1" RowDefinitions="Auto,Auto,*">
<TextBlock Text="STRING FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding NumberUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding NumberFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="2" RowDefinitions="Auto,Auto,*">
<TextBlock Text="BOOLEAN (TRUE/FALSE) FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding BoolUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding BoolFields}"/>
</Grid>
<Grid Grid.Row="1" Grid.Column="3" RowDefinitions="Auto,Auto,*">
<TextBlock Text="ID FIELDS" />
<TextBlock Grid.Row="1" Text="{CompiledBinding IdUsage}" />
<ListBox Grid.Row="2" ItemsSource="{CompiledBinding IdFields}"/>
</Grid>
</Grid>
</Window>

View File

@@ -1,59 +1,55 @@
using LibationSearchEngine;
using System.Linq;
namespace LibationAvalonia.Dialogs
{
public partial class SearchSyntaxDialog : DialogWindow
{
public string StringFields { get; init; }
public string NumberFields { get; init; }
public string BoolFields { get; init; }
public string IdFields { get; init; }
public string StringUsage { get; }
public string NumberUsage { get; }
public string BoolUsage { get; }
public string IdUsage { get; }
public string[] StringFields { get; } = SearchEngine.FieldIndexRules.StringFieldNames.ToArray();
public string[] NumberFields { get; } = SearchEngine.FieldIndexRules.NumberFieldNames.ToArray();
public string[] BoolFields { get; } = SearchEngine.FieldIndexRules.BoolFieldNames.ToArray();
public string[] IdFields { get; } = SearchEngine.FieldIndexRules.IdFieldNames.ToArray();
public SearchSyntaxDialog()
{
InitializeComponent();
StringFields = @"
Search for wizard of oz:
title:oz
title:""wizard of oz""
StringUsage = """
Search for wizard of oz:
title:oz
title:"wizard of oz"
""";
NumberUsage = """
Find books between 1-100 minutes long
length:[1 TO 100]
Find books exactly 1 hr long
length:60
Find books published from 2020-1-1 to
2023-12-31
datepublished:[20200101 TO 20231231]
""";
" + string.Join("\r\n", SearchEngine.FieldIndexRules.StringFieldNames);
BoolUsage = """
Find books that you haven't rated:
-IsRated
""";
NumberFields = @"
Find books between 1-100 minutes long
length:[1 TO 100]
Find books exactly 1 hr long
length:60
Find books published from 2020-1-1 to
2023-12-31
datepublished:[20200101 TO 20231231]
IdUsage = """
Alice's Adventures in
Wonderland (ID: B015D78L0U)
id:B015D78L0U
" + string.Join("\r\n", SearchEngine.FieldIndexRules.NumberFieldNames);
BoolFields = @"
Find books that you haven't rated:
-IsRated
" + string.Join("\r\n", SearchEngine.FieldIndexRules.BoolFieldNames);
IdFields = @"
Alice's Adventures in
Wonderland (ID: B015D78L0U)
id:B015D78L0U
All of these are synonyms
for the ID field
" + string.Join("\r\n", SearchEngine.FieldIndexRules.IdFieldNames);
All of these are synonyms
for the ID field
""";
DataContext = this;
}
}
}

View File

@@ -1,7 +1,9 @@
using Avalonia.Controls;
using FileManager;
using LibationAvalonia.ViewModels.Settings;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Threading.Tasks;
namespace LibationAvalonia.Dialogs
@@ -39,6 +41,21 @@ namespace LibationAvalonia.Dialogs
}
public async void SaveButton_Clicked(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> await SaveAndCloseAsync();
{
LongPath lonNewBooks = settingsDisp.ImportantSettings.GetBooksDirectory();
if (!System.IO.Directory.Exists(lonNewBooks))
{
try
{
System.IO.Directory.CreateDirectory(lonNewBooks);
}
catch (Exception ex)
{
await MessageBox.Show(this, $"Error creating Books Location:\n\n{ex.Message}", "Error creating directory", MessageBoxButtons.OK, MessageBoxIcon.Error);
return;
}
}
await SaveAndCloseAsync();
}
}
}

View File

@@ -1,6 +1,7 @@
using AppScaffolding;
using Avalonia.Controls;
using Dinah.Core;
using LibationFileManager;
using LibationUiBase.Forms;
namespace LibationAvalonia.Dialogs
@@ -30,7 +31,7 @@ namespace LibationAvalonia.Dialogs
public UpgradeNotificationDialog(UpgradeProperties upgradeProperties, bool canUpgrade) : this()
{
Title = $"Libation version {upgradeProperties.LatestRelease.ToString(3)} is now available.";
Title = $"Libation version {upgradeProperties.LatestRelease.ToVersionString()} is now available.";
PackageUrl = upgradeProperties.ZipUrl;
DownloadLinkText = upgradeProperties.ZipName;
ReleaseNotes = upgradeProperties.Notes;

View File

@@ -27,7 +27,6 @@ namespace LibationAvalonia.ViewModels
/// <summary> Indicates if the first quick filter is the default filter </summary>
public bool FirstFilterIsDefault { get => _firstFilterIsDefault; set => QuickFilters.UseDefault = this.RaiseAndSetIfChanged(ref _firstFilterIsDefault, value); }
private void Configure_Filters()
{
FirstFilterIsDefault = QuickFilters.UseDefault;
@@ -55,7 +54,7 @@ namespace LibationAvalonia.ViewModels
}
public void AddQuickFilterBtn() { if (SelectedNamedFilter != null) QuickFilters.Add(SelectedNamedFilter); }
public async Task FilterBtn() => await PerformFilter(SelectedNamedFilter);
public async Task FilterBtn(string filterString) => await PerformFilter(new(filterString, null));
public async Task FilterHelpBtn() => await new LibationAvalonia.Dialogs.SearchSyntaxDialog().ShowDialog(MainWindow);
public void ToggleFirstFilterIsDefault() => FirstFilterIsDefault = !FirstFilterIsDefault;
public async Task EditQuickFiltersAsync() => await new LibationAvalonia.Dialogs.EditQuickFilters().ShowDialog(MainWindow);

View File

@@ -20,6 +20,7 @@ namespace LibationAvalonia.ViewModels
private bool _removeButtonsVisible = Design.IsDesignMode;
private int _numAccountsScanning = 2;
private int _accountsCount = 0;
public string LocateAudiobooksTip => Configuration.GetHelpText("LocateAudiobooks");
/// <summary> Auto scanning accounts is enables </summary>
public bool AutoScanChecked { get => _autoScanChecked; set => Configuration.Instance.AutoScan = this.RaiseAndSetIfChanged(ref _autoScanChecked, value); }
@@ -68,7 +69,8 @@ namespace LibationAvalonia.ViewModels
MainWindow.Loaded += (_, _) =>
{
refreshImportMenu();
AccountsSettingsPersister.Saved += refreshImportMenu;
AccountsSettingsPersister.Saved += (_, _)
=> Avalonia.Threading.Dispatcher.UIThread.Invoke(refreshImportMenu);
};
AutoScanChecked = Configuration.Instance.AutoScan;
@@ -172,8 +174,19 @@ namespace LibationAvalonia.ViewModels
public async Task LocateAudiobooksAsync()
{
var locateDialog = new LibationAvalonia.Dialogs.LocateAudiobooksDialog();
await locateDialog.ShowDialog(MainWindow);
var result = await MessageBox.Show(
MainWindow,
Configuration.GetHelpText(nameof(LibationAvalonia.Dialogs.LocateAudiobooksDialog)),
"Locate Previously-Liberated Audiobook Files",
MessageBoxButtons.OKCancel,
MessageBoxIcon.Information,
MessageBoxDefaultButton.Button1);
if (result is DialogResult.OK)
{
var locateDialog = new LibationAvalonia.Dialogs.LocateAudiobooksDialog();
await locateDialog.ShowDialog(MainWindow);
}
}
private void setyNumScanningAccounts(int numScanning)
@@ -222,7 +235,7 @@ namespace LibationAvalonia.ViewModels
}
}
private void refreshImportMenu(object? _ = null, EventArgs? __ = null)
private void refreshImportMenu()
{
using var persister = AudibleApiStorage.GetAccountsSettingsPersister();
AccountsCount = persister.AccountsSettings.Accounts.Count;

View File

@@ -479,6 +479,7 @@ namespace LibationAvalonia.ViewModels
public DataGridLength MiscWidth { get => getColumnWidth("Misc", 140); set => setColumnWidth("Misc", value); }
public DataGridLength LastDownloadWidth { get => getColumnWidth("LastDownload", 100); set => setColumnWidth("LastDownload", value); }
public DataGridLength BookTagsWidth { get => getColumnWidth("BookTags", 100); set => setColumnWidth("BookTags", value); }
public DataGridLength IsSpatialWidth { get => getColumnWidth("IsSpatial", 100); set => setColumnWidth("IsSpatial", value); }
private static DataGridLength getColumnWidth(string columnName, double defaultWidth)
=> Configuration.Instance.GridColumnsWidths.TryGetValue(columnName, out var val)

View File

@@ -35,11 +35,8 @@ namespace LibationAvalonia.ViewModels.Settings
}
public void SaveSettings(Configuration config)
{
LongPath lonNewBooks = Configuration.GetKnownDirectory(BooksDirectory) is Configuration.KnownDirectories.None ? BooksDirectory : System.IO.Path.Combine(BooksDirectory, "Books");
if (!System.IO.Directory.Exists(lonNewBooks))
System.IO.Directory.CreateDirectory(lonNewBooks);
config.Books = lonNewBooks;
{
config.Books = GetBooksDirectory();
config.SavePodcastsToParentFolder = SavePodcastsToParentFolder;
config.OverwriteExisting = OverwriteExisting;
config.CreationTime = CreationTime.Value;
@@ -47,6 +44,9 @@ namespace LibationAvalonia.ViewModels.Settings
config.LogLevel = LoggingLevel;
}
public LongPath GetBooksDirectory()
=> Configuration.GetKnownDirectory(BooksDirectory) is Configuration.KnownDirectories.None ? BooksDirectory : System.IO.Path.Combine(BooksDirectory, "Books");
private static float scaleFactorToLinearRange(float scaleFactor)
=> float.Round(100 * MathF.Log2(scaleFactor));
private static float linearRangeToScaleFactor(float value)

View File

@@ -110,7 +110,7 @@
<MenuItem IsVisible="{CompiledBinding MultipleAccounts}" IsEnabled="{CompiledBinding RemoveMenuItemsEnabled}" Command="{CompiledBinding RemoveBooksSomeAsync}" Header="_Remove Books from Some Accounts" />
<Separator />
<MenuItem Command="{CompiledBinding LocateAudiobooksAsync}" Header="L_ocate Audiobooks..." />
<MenuItem Command="{CompiledBinding LocateAudiobooksAsync}" Header="L_ocate Audiobooks..." ToolTip.Tip="{CompiledBinding LocateAudiobooksTip}" />
</MenuItem>
@@ -191,10 +191,10 @@
<Button IsVisible="{CompiledBinding RemoveButtonsVisible}" Command="{CompiledBinding DoneRemovingBtn}" Content="Done Removing Books"/>
</StackPanel>
<TextBox Grid.Column="1" Margin="10,0,0,0" Name="filterSearchTb" IsVisible="{CompiledBinding !RemoveButtonsVisible}" Text="{CompiledBinding SelectedNamedFilter.Filter, Mode=TwoWay}" KeyDown="filterSearchTb_KeyPress" />
<TextBox Grid.Column="1" Margin="10,0,0,0" Name="filterSearchTb" IsVisible="{CompiledBinding !RemoveButtonsVisible}" Text="{CompiledBinding SelectedNamedFilter.Filter, Mode=OneWay}" KeyDown="filterSearchTb_KeyPress" />
<StackPanel Grid.Column="2" Height="30" Orientation="Horizontal">
<Button Name="filterBtn" Command="{CompiledBinding FilterBtn}" VerticalAlignment="Stretch" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
<Button Name="filterBtn" Command="{CompiledBinding FilterBtn}" CommandParameter="{CompiledBinding #filterSearchTb.Text}" VerticalAlignment="Stretch" HorizontalContentAlignment="Center" VerticalContentAlignment="Center" Content="Filter"/>
<Button Padding="2,6,2,6" VerticalAlignment="Stretch" Command="{CompiledBinding ToggleQueueHideBtn}">
<Path Stretch="Uniform" Fill="{DynamicResource IconFill}" Data="{StaticResource LeftArrows}">
<Path.RenderTransform>

View File

@@ -1,4 +1,5 @@
using AudibleUtilities;
using Avalonia.Controls;
using Avalonia.Input;
using Avalonia.ReactiveUI;
using Avalonia.Threading;
@@ -21,6 +22,9 @@ namespace LibationAvalonia.Views
{
public MainWindow()
{
if (Design.IsDesignMode)
_ = Configuration.Instance.LibationFiles;
DataContext = new MainVM(this);
ApiExtended.LoginChoiceFactory = account => Dispatcher.UIThread.Invoke(() => new Dialogs.Login.AvaloniaLoginChoiceEager(account));
@@ -39,6 +43,22 @@ namespace LibationAvalonia.Views
KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(ViewModel.ShowAccountsAsync), Gesture = new KeyGesture(Key.A, KeyModifiers.Control | KeyModifiers.Shift) });
KeyBindings.Add(new KeyBinding { Command = ReactiveCommand.Create(ViewModel.ExportLibraryAsync), Gesture = new KeyGesture(Key.S, KeyModifiers.Control) });
}
Configuration.Instance.PropertyChanged += Settings_PropertyChanged;
Settings_PropertyChanged(this, null);
}
[Dinah.Core.PropertyChangeFilter(nameof(Configuration.Books))]
private void Settings_PropertyChanged(object sender, Dinah.Core.PropertyChangedEventArgsEx e)
{
if (!Configuration.IsWindows)
{
//The books directory does not support filenames with windows' invalid characters.
//Tell the ReplacementCharacters configuration to treat those characters as invalid.
ReplacementCharacters.AdditionalInvalidFilenameCharacters
= Configuration.Instance.BooksCanWriteWindowsInvalidChars ? []
: FileSystemTest.AdditionalInvalidWindowsFilenameCharacters.ToArray();
}
}
private void AudibleApiStorage_LoadError(object sender, AccountSettingsLoadErrorEventArgs e)
@@ -50,7 +70,7 @@ namespace LibationAvalonia.Views
FileUtility.SaferMoveToValidPath(
e.SettingsFilePath,
e.SettingsFilePath,
ReplacementCharacters.Barebones,
Configuration.Instance.ReplacementCharacters,
"bak");
AudibleApiStorage.EnsureAccountsSettingsFileExists();
e.Handled = true;
@@ -99,6 +119,20 @@ namespace LibationAvalonia.Views
private async void MainWindow_Opened(object sender, EventArgs e)
{
if (AudibleFileStorage.BooksDirectory is null)
{
var result = await MessageBox.Show(
this,
"Please set a valid Books location in the settings dialog.",
"Books Directory Not Set",
MessageBoxButtons.OKCancel,
MessageBoxIcon.Warning,
MessageBoxDefaultButton.Button1);
if (result is DialogResult.OK)
await new SettingsDialog().ShowDialog(this);
}
if (Configuration.Instance.FirstLaunch)
{
var result = await MessageBox.Show(this, "Would you like a guided tour to get started?", "Libation Walkthrough", MessageBoxButtons.YesNo, MessageBoxIcon.Question, MessageBoxDefaultButton.Button1);
@@ -111,7 +145,7 @@ namespace LibationAvalonia.Views
Configuration.Instance.FirstLaunch = false;
}
}
private void MainWindow_Closing(object sender, System.ComponentModel.CancelEventArgs e)
{
productsDisplay?.CloseImageDisplay();
@@ -156,7 +190,7 @@ namespace LibationAvalonia.Views
{
if (e.Key == Key.Return)
{
await ViewModel.PerformFilter(ViewModel.SelectedNamedFilter);
await ViewModel.FilterBtn(filterSearchTb.Text);
// silence the 'ding'
e.Handled = true;

View File

@@ -5,14 +5,15 @@ using DataLayer;
using LibationUiBase;
using LibationUiBase.ProcessQueue;
#nullable enable
namespace LibationAvalonia.Views
{
public delegate void QueueItemPositionButtonClicked(ProcessBookViewModel item, QueuePosition queueButton);
public delegate void QueueItemCancelButtonClicked(ProcessBookViewModel item);
public delegate void QueueItemPositionButtonClicked(ProcessBookViewModel? item, QueuePosition queueButton);
public delegate void QueueItemCancelButtonClicked(ProcessBookViewModel? item);
public partial class ProcessBookControl : UserControl
{
public static event QueueItemPositionButtonClicked PositionButtonClicked;
public static event QueueItemCancelButtonClicked CancelButtonClicked;
public static event QueueItemPositionButtonClicked? PositionButtonClicked;
public static event QueueItemCancelButtonClicked? CancelButtonClicked;
public static readonly StyledProperty<ProcessBookStatus> ProcessBookStatusProperty =
AvaloniaProperty.Register<ProcessBookControl, ProcessBookStatus>(nameof(ProcessBookStatus), enableDataValidation: true);
@@ -31,12 +32,13 @@ namespace LibationAvalonia.Views
{
using var context = DbContexts.GetContext();
ViewModels.MainVM.Configure_NonUI();
DataContext = new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"));
if (context.GetLibraryBook_Flat_NoTracking("B017V4IM1G") is LibraryBook book)
DataContext = new ProcessBookViewModel(book);
return;
}
}
private ProcessBookViewModel DataItem => DataContext is null ? null : DataContext as ProcessBookViewModel;
private ProcessBookViewModel? DataItem => DataContext as ProcessBookViewModel;
public void Cancel_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
=> CancelButtonClicked?.Invoke(DataItem);

View File

@@ -34,44 +34,51 @@ namespace LibationAvalonia.Views
var vm = new ProcessQueueViewModel();
DataContext = vm;
using var context = DbContexts.GetContext();
var trialBook = context.GetLibraryBook_Flat_NoTracking("B017V4IM1G") ?? context.GetLibrary_Flat_NoTracking().FirstOrDefault();
if (trialBook is null)
return;
List<ProcessBookViewModel> testList = new()
{
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.FailedAbort,
Status = ProcessBookStatus.Failed,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.FailedSkip,
Status = ProcessBookStatus.Failed,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.FailedRetry,
Status = ProcessBookStatus.Failed,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.ValidationFail,
Status = ProcessBookStatus.Failed,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.Cancelled,
Status = ProcessBookStatus.Cancelled,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.Success,
Status = ProcessBookStatus.Completed,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.None,
Status = ProcessBookStatus.Working,
},
new ProcessBookViewModel(context.GetLibraryBook_Flat_NoTracking("B017V4IM1G"))
new ProcessBookViewModel(trialBook)
{
Result = ProcessBookResult.None,
Status = ProcessBookStatus.Queued,
@@ -99,7 +106,7 @@ namespace LibationAvalonia.Views
#region Control event handlers
private async void ProcessBookControl2_CancelButtonClicked(ProcessBookViewModel item)
private async void ProcessBookControl2_CancelButtonClicked(ProcessBookViewModel? item)
{
if (item is not null)
{
@@ -108,19 +115,20 @@ namespace LibationAvalonia.Views
}
}
private void ProcessBookControl2_ButtonClicked(ProcessBookViewModel item, QueuePosition queueButton)
private void ProcessBookControl2_ButtonClicked(ProcessBookViewModel? item, QueuePosition queueButton)
{
Queue?.MoveQueuePosition(item, queueButton);
if (item is not null)
Queue?.MoveQueuePosition(item, queueButton);
}
public async void CancelAllBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public async void CancelAllBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
Queue?.ClearQueue();
if (Queue?.Current is not null)
await Queue.Current.CancelAsync();
}
public void ClearFinishedBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public void ClearFinishedBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
Queue?.ClearCompleted();
@@ -128,12 +136,12 @@ namespace LibationAvalonia.Views
_viewModel.RunningTime = string.Empty;
}
public void ClearLogBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
public void ClearLogBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
_viewModel?.LogEntries.Clear();
}
private async void LogCopyBtn_Click(object sender, Avalonia.Interactivity.RoutedEventArgs e)
private async void LogCopyBtn_Click(object? sender, Avalonia.Interactivity.RoutedEventArgs e)
{
if (_viewModel is ProcessQueueViewModel vm)
{
@@ -143,14 +151,14 @@ namespace LibationAvalonia.Views
}
}
private async void cancelAllBtn_Click(object sender, EventArgs e)
private async void cancelAllBtn_Click(object? sender, EventArgs e)
{
Queue?.ClearQueue();
if (Queue?.Current is not null)
await Queue.Current.CancelAsync();
}
private void btnClearFinished_Click(object sender, EventArgs e)
private void btnClearFinished_Click(object? sender, EventArgs e)
{
Queue?.ClearCompleted();

View File

@@ -3,9 +3,11 @@
xmlns:d="http://schemas.microsoft.com/expression/blend/2008"
xmlns:mc="http://schemas.openxmlformats.org/markup-compatibility/2006"
xmlns:views="clr-namespace:LibationAvalonia.Views"
xmlns:vm="clr-namespace:LibationAvalonia.ViewModels"
xmlns:uibase="clr-namespace:LibationUiBase.GridView;assembly=LibationUiBase"
xmlns:controls="clr-namespace:LibationAvalonia.Controls"
mc:Ignorable="d" d:DesignWidth="1560" d:DesignHeight="400"
x:DataType="vm:ProductsDisplayViewModel"
x:Class="LibationAvalonia.Views.ProductsDisplay">
<Grid>
@@ -15,7 +17,7 @@
ClipboardCopyMode="IncludeHeader"
GridLinesVisibility="All"
AutoGenerateColumns="False"
ItemsSource="{Binding GridEntries}"
ItemsSource="{CompiledBinding GridEntries}"
CanUserSortColumns="True" BorderThickness="3"
CanUserResizeColumns="True"
LoadingRow="ProductsDisplay_LoadingRow"
@@ -51,7 +53,7 @@
<DataGridTemplateColumn
CanUserSort="True"
CanUserResize="False"
IsVisible="{Binding RemoveColumnVisible}"
IsVisible="{CompiledBinding RemoveColumnVisible}"
PropertyChanged="RemoveColumn_PropertyChanged"
Header="Remove"
IsReadOnly="False"
@@ -83,7 +85,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt CanUserResize="False" CanUserSort="False" Header="Cover" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<controls:DataGridTemplateColumnExt Header="Cover" CanUserResize="False" CanUserSort="False" SortMemberPath="Cover" ClipboardContentBinding="{Binding LibraryBook.Book.PictureLarge}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Image Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Cover_Click" Source="{CompiledBinding Cover}" ToolTip.Tip="Click to see full size" />
@@ -91,7 +93,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{Binding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<controls:DataGridTemplateColumnExt Header="Title" MinWidth="10" Width="{CompiledBinding TitleWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Title" ClipboardContentBinding="{Binding Title}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -101,7 +103,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{Binding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<controls:DataGridTemplateColumnExt Header="Authors" MinWidth="10" Width="{CompiledBinding AuthorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Authors" ClipboardContentBinding="{Binding Authors}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -111,7 +113,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{Binding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<controls:DataGridTemplateColumnExt Header="Narrators" MinWidth="10" Width="{CompiledBinding NarratorsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Narrators" ClipboardContentBinding="{Binding Narrators}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -121,7 +123,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{Binding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<controls:DataGridTemplateColumnExt Header="Length" MinWidth="10" Width="{CompiledBinding LengthWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Length" ClipboardContentBinding="{Binding Length}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -131,7 +133,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{Binding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<controls:DataGridTemplateColumnExt Header="Series" MinWidth="10" Width="{CompiledBinding SeriesWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Series" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -141,7 +143,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Series&#xA;Order" MinWidth="10" Width="{Binding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}">
<controls:DataGridTemplateColumnExt Header="Series&#xA;Order" MinWidth="10" Width="{CompiledBinding SeriesOrderWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="SeriesOrder" ClipboardContentBinding="{Binding Series}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -151,7 +153,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{Binding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<controls:DataGridTemplateColumnExt Header="Description" MinWidth="10" Width="{CompiledBinding DescriptionWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Description" ClipboardContentBinding="{Binding Description}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" Tapped="Description_Click" ToolTip.Tip="Click to see full description" >
@@ -161,7 +163,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{Binding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<controls:DataGridTemplateColumnExt Header="Category" MinWidth="10" Width="{CompiledBinding CategoryWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Category" ClipboardContentBinding="{Binding Category}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -181,7 +183,7 @@
ClipboardContentBinding="{CompiledBinding ProductRating}"
Binding="{CompiledBinding ProductRating}" />
<controls:DataGridTemplateColumnExt Header="Purchase&#xA;Date" MinWidth="10" Width="{Binding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<controls:DataGridTemplateColumnExt Header="Purchase&#xA;Date" MinWidth="10" Width="{CompiledBinding PurchaseDateWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="PurchaseDate" ClipboardContentBinding="{Binding PurchaseDate}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -201,7 +203,7 @@
ClipboardContentBinding="{CompiledBinding MyRating}"
Binding="{CompiledBinding MyRating, Mode=TwoWay}" />
<controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{Binding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<controls:DataGridTemplateColumnExt Header="Misc" MinWidth="10" Width="{CompiledBinding MiscWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="Misc" ClipboardContentBinding="{Binding Misc}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}">
@@ -211,7 +213,7 @@
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{Binding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<controls:DataGridTemplateColumnExt Header="Last&#xA;Download" MinWidth="10" Width="{CompiledBinding LastDownloadWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="LastDownload" ClipboardContentBinding="{Binding LastDownload}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}" DoubleTapped="Version_DoubleClick">
@@ -220,8 +222,18 @@
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Is&#xA;Spatial" MinWidth="10" Width="{CompiledBinding IsSpatialWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="IsSpatial" ClipboardContentBinding="{Binding IsSpatial}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Panel Opacity="{CompiledBinding Liberate.Opacity}" ToolTip.Tip="{CompiledBinding LastDownload.ToolTipText}">
<CheckBox IsChecked="{CompiledBinding IsSpatial}" IsEnabled="False" HorizontalAlignment="Center" VerticalAlignment="Center" />
</Panel>
</DataTemplate>
</DataGridTemplateColumn.CellTemplate>
</controls:DataGridTemplateColumnExt>
<controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{Binding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<controls:DataGridTemplateColumnExt Header="Tags" MinWidth="10" Width="{CompiledBinding BookTagsWidth, Mode=TwoWay}" CanUserSort="True" SortMemberPath="BookTags" ClipboardContentBinding="{Binding BookTags}">
<DataGridTemplateColumn.CellTemplate>
<DataTemplate x:DataType="uibase:GridEntry">
<Button

View File

@@ -62,25 +62,22 @@ namespace LibationAvalonia.Views
if (Design.IsDesignMode)
{
using var context = DbContexts.GetContext();
List<LibraryBook> sampleEntries;
LibraryBook?[] sampleEntries;
try
{
sampleEntries = new()
{
//context.GetLibraryBook_Flat_NoTracking("B00DCD0OXU"),try{
sampleEntries = [
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6"),
context.GetLibraryBook_Flat_NoTracking("B017V4IWVG"),
context.GetLibraryBook_Flat_NoTracking("B017V4JA2Q"),
context.GetLibraryBook_Flat_NoTracking("B017V4NUPO"),
context.GetLibraryBook_Flat_NoTracking("B017V4NMX4"),
context.GetLibraryBook_Flat_NoTracking("B017V4NOZ0"),
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")
};
context.GetLibraryBook_Flat_NoTracking("B017WJ5ZK6")];
}
catch { sampleEntries = new(); }
catch { sampleEntries = []; }
var pdvm = new ProductsDisplayViewModel();
_ = pdvm.BindToGridAsync(sampleEntries);
_ = pdvm.BindToGridAsync(sampleEntries.OfType<LibraryBook>().ToList());
DataContext = pdvm;
setGridScale(1);
@@ -426,7 +423,6 @@ namespace LibationAvalonia.Views
productsGrid.ColumnDisplayIndexChanged += ProductsGrid_ColumnDisplayIndexChanged;
var config = Configuration.Instance;
var gridColumnsVisibilities = config.GridColumnsVisibilities;
var displayIndices = config.GridColumnsDisplayIndices;
var contextMenu = new ContextMenu();
@@ -464,7 +460,7 @@ namespace LibationAvalonia.Views
if (headerCell is not null)
headerCell.ContextMenu = contextMenu;
column.IsVisible = gridColumnsVisibilities.GetValueOrDefault(itemName, true);
column.IsVisible = config.GetColumnVisibility(itemName);
}
//We must set DisplayIndex properties in ascending order

View File

@@ -5,6 +5,7 @@ using Avalonia.Controls.Presenters;
using Avalonia.Controls.Primitives;
using Avalonia.Media;
using Avalonia.Styling;
using Dinah.Core;
using Dinah.Core.StepRunner;
using LibationAvalonia.Dialogs;
using LibationAvalonia.Views;
@@ -164,8 +165,9 @@ namespace LibationAvalonia
{
//if we imported new books, wait for the grid to update before proceeding.
if (newCount > 0)
MainForm.ViewModel.ProductsDisplay.VisibleCountChanged += productsDisplay_VisibleCountChanged;
else
Avalonia.Threading.Dispatcher.UIThread.Invoke(() =>
MainForm.ViewModel.ProductsDisplay.VisibleCountChanged += productsDisplay_VisibleCountChanged);
else
tcs.SetResult();
}
void productsDisplay_VisibleCountChanged(object sender, int e) => tcs.SetResult();
@@ -176,7 +178,7 @@ namespace LibationAvalonia
var books = DbContexts.GetLibrary_Flat_NoTracking();
if (books.Count == 0) return true;
var firstAuthor = getFirstAuthor();
var firstAuthor = getFirstAuthor()?.SurroundWithQuotes();
if (firstAuthor == null) return true;
if (!await ProceedMessageBox("You can filter the grid entries by searching", "Searching"))
@@ -193,7 +195,7 @@ namespace LibationAvalonia
await displayControlAsync(MainForm.filterBtn);
MainForm.filterBtn.Command.Execute(null);
MainForm.filterBtn.Command.Execute(firstAuthor);
await Task.Delay(1000);
@@ -209,8 +211,7 @@ namespace LibationAvalonia
private async Task<bool> ShowQuickFilters()
{
var firstAuthor = getFirstAuthor();
var firstAuthor = getFirstAuthor()?.SurroundWithQuotes();
if (firstAuthor == null) return true;
if (!await ProceedMessageBox("Queries that you perform regularly can be added to 'Quick Filters'", "Quick Filters"))
@@ -222,7 +223,7 @@ namespace LibationAvalonia
await Task.Delay(750);
await displayControlAsync(MainForm.addQuickFilterBtn);
MainForm.addQuickFilterBtn.Command.Execute(null);
MainForm.addQuickFilterBtn.Command.Execute(firstAuthor);
await displayControlAsync(MainForm.quickFiltersToolStripMenuItem);
await displayControlAsync(editQuickFiltersToolStripMenuItem);

View File

@@ -1,6 +1,7 @@
using AppScaffolding;
using CommandLine;
using CommandLine.Text;
using LibationFileManager;
namespace LibationCli;
@@ -20,7 +21,7 @@ internal class HelpVerb
{
AutoVersion = false,
AutoHelp = false,
Heading = $"LibationCli v{LibationScaffolding.BuildVersion.ToString(3)}",
Heading = $"LibationCli v{LibationScaffolding.BuildVersion.ToVersionString()}",
AdditionalNewLineAfterOption = true,
MaximumDisplayWidth = 80
};

View File

@@ -1,4 +1,6 @@
using CommandLine;
using LibationFileManager;
using System;
using System.Threading.Tasks;
namespace LibationCli
@@ -6,6 +8,15 @@ namespace LibationCli
[Verb("convert", HelpText = "Convert mp4 to mp3.")]
public class ConvertOptions : ProcessableOptionsBase
{
protected override Task ProcessAsync() => RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
protected override Task ProcessAsync()
{
if (AudibleFileStorage.BooksDirectory is null)
{
Console.Error.WriteLine("Error: Books directory is not set. Please configure the 'Books' setting in Settings.json.");
return Task.CompletedTask;
}
return RunAsync(CreateProcessable<FileLiberator.ConvertToMp3>());
}
}
}

View File

@@ -1,6 +1,8 @@
using CommandLine;
using DataLayer;
using FileLiberator;
using LibationFileManager;
using System;
using System.Threading.Tasks;
namespace LibationCli
@@ -13,9 +15,17 @@ namespace LibationCli
public bool PdfOnly { get; set; }
protected override Task ProcessAsync()
=> PdfOnly
{
if (AudibleFileStorage.BooksDirectory is null)
{
Console.Error.WriteLine("Error: Books directory is not set. Please configure the 'Books' setting in Settings.json.");
return Task.CompletedTask;
}
return PdfOnly
? RunAsync(CreateProcessable<DownloadPdf>())
: RunAsync(CreateBackupBook());
}
private static Processable CreateBackupBook()
{

View File

@@ -1,5 +1,6 @@
using AppScaffolding;
using CommandLine;
using LibationFileManager;
using System;
using System.Threading.Tasks;
@@ -14,7 +15,7 @@ internal class VersionOptions : OptionsBase
protected override Task ProcessAsync()
{
const string checkingForUpgrade = "Checking for upgrade...";
Console.WriteLine($"Libation {LibationScaffolding.Variety} v{LibationScaffolding.BuildVersion.ToString(3)}");
Console.WriteLine($"Libation {LibationScaffolding.Variety} v{LibationScaffolding.BuildVersion.ToVersionString()}");
if (CheckForUpgrade)
{
@@ -34,7 +35,7 @@ internal class VersionOptions : OptionsBase
else
{
Console.ForegroundColor = ConsoleColor.Red;
ReplaceConsoleText(Console.Out, checkingForUpgrade.Length, $"Upgrade Available: v{upgradeProperties.LatestRelease.ToString(3)}");
ReplaceConsoleText(Console.Out, checkingForUpgrade.Length, $"Upgrade Available: v{upgradeProperties.LatestRelease.ToVersionString()}");
Console.WriteLine();
Console.WriteLine();
Console.WriteLine(upgradeProperties.ZipUrl);

View File

@@ -45,13 +45,24 @@ namespace LibationFileManager
public static AudioFileStorage Audio { get; } = new AudioFileStorage();
public static LongPath BooksDirectory
/// <summary>
/// The fully-qualified Books durectory path if the directory exists, otherwise null.
/// </summary>
public static LongPath? BooksDirectory
{
get
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
Configuration.Instance.Books = Configuration.DefaultBooksDirectory;
return Directory.CreateDirectory(Configuration.Instance.Books).FullName;
return null;
try
{
return Directory.CreateDirectory(Configuration.Instance.Books)?.FullName;
}
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error creating Books directory: {@BooksDirectory}", Configuration.Instance.Books);
return null;
}
}
}
#endregion
@@ -129,8 +140,9 @@ namespace LibationFileManager
protected override LongPath? GetFilePathCustom(string productId)
=> GetFilePathsCustom(productId).FirstOrDefault();
private static BackgroundFileSystem newBookDirectoryFiles()
=> new BackgroundFileSystem(BooksDirectory, "*.*", SearchOption.AllDirectories);
private static BackgroundFileSystem? newBookDirectoryFiles()
=> BooksDirectory is LongPath books ? new BackgroundFileSystem(books, "*.*", SearchOption.AllDirectories)
: null;
protected override List<LongPath> GetFilePathsCustom(string productId)
{
@@ -140,6 +152,7 @@ namespace LibationFileManager
BookDirectoryFiles = newBookDirectoryFiles();
var regex = GetBookSearchRegex(productId);
var diskFiles = BookDirectoryFiles?.FindFiles(regex) ?? [];
//Find all extant files matching the productId
//using both the file system and the file path cache
@@ -148,17 +161,17 @@ namespace LibationFileManager
.GetFiles(productId)
.Where(c => c.fileType == FileType.Audio && File.Exists(c.path))
.Select(c => c.path)
.Union(BookDirectoryFiles.FindFiles(regex))
.Union(diskFiles)
.ToList();
}
public void Refresh()
{
if (BookDirectoryFiles is null)
if (BookDirectoryFiles is null && BooksDirectory is not null)
lock (bookDirectoryFilesLocker)
BookDirectoryFiles = newBookDirectoryFiles();
else
BookDirectoryFiles?.RefreshFiles();
BookDirectoryFiles?.RefreshFiles();
}
public LongPath? GetPath(string productId) => GetFilePath(productId);

View File

@@ -16,6 +16,11 @@ namespace LibationFileManager
MacOS = 0x400000,
}
public static class Estensions
{
public static string ToVersionString(this Version version) => version.Revision > 1 ? version.ToString(4) : version.ToString(3);
}
public partial class Configuration
{
public static bool IsWindows { get; } = OperatingSystem.IsWindows();

View File

@@ -107,8 +107,27 @@ namespace LibationFileManager
don't have a spatial audio version will be download
as usual based on your other file quality settings.
""" },
}
.AsReadOnly();
{"LocateAudiobooks","""
Scan the contents a folder to find audio files that
match books in Libation's database. This is useful
if you moved your Books folder or re-installed
Libation and want it to be able to find your
already downloaded audiobooks.
Prerequisite: An audiobook must already exist in
Libation's database (through an Audible account
scan) for a matching audio file to be found.
""" },
{"LocateAudiobooksDialog","""
Libation will search all .m4b and .mp3 files in a folder, looking for audio files belonging to library books in Libation's database.
If an audiobook file is found that matches one of Libation's library books, Libation will mark that book as "Liberated" (green stoplight).
For an audio file to be identified, Libation must have that library book in its database. If you're on a fresh installation of Libation, be sure to add and scan all of your Audible accounts before running this action.
This may take a while, depending on the number of audio files in the folder and the speed of your storage device.
""" }
}.AsReadOnly();
public static string GetHelpText(string? settingName)
=> settingName != null && HelpText.TryGetValue(settingName, out var value) ? value : "";

View File

@@ -84,7 +84,7 @@ namespace LibationFileManager
ProcessDirectory,
LocalAppData,
UserProfile,
Path.Combine(Path.GetTempPath(), "Libation")
WinTemp,
};
//Try to find and validate appsettings.json in each folder
@@ -181,7 +181,7 @@ namespace LibationFileManager
}
catch (Exception e)
{
Serilog.Log.Error(e, "Failed to run shell command. {Arguments}", psi.ArgumentList);
Serilog.Log.Error(e, "Failed to run shell command. {@Arguments}", psi.ArgumentList);
return null;
}
}

View File

@@ -27,6 +27,7 @@ namespace LibationFileManager
//https://github.com/serilog/serilog-settings-configuration/issues/406
var readerOptions = new ConfigurationReaderOptions(
typeof(ILogger).Assembly, // Serilog
typeof(LoggerCallerEnrichmentConfiguration).Assembly, // Dinah.Core
typeof(LoggerEnrichmentConfigurationExtensions).Assembly, // Serilog.Exceptions
typeof(ConsoleLoggerConfigurationExtensions).Assembly, // Serilog.Sinks.Console
typeof(FileLoggerConfigurationExtensions).Assembly); // Serilog.Sinks.File

View File

@@ -36,12 +36,12 @@ namespace LibationFileManager
[return: NotNullIfNotNull(nameof(defaultValue))]
public T? GetNonString<T>(T defaultValue, [CallerMemberName] string propertyName = "")
=> Settings.GetNonString(propertyName, defaultValue);
=> Settings is null ? default : Settings.GetNonString(propertyName, defaultValue);
[return: NotNullIfNotNull(nameof(defaultValue))]
public string? GetString(string? defaultValue = null, [CallerMemberName] string propertyName = "")
=> Settings.GetString(propertyName, defaultValue);
=> Settings?.GetString(propertyName, defaultValue);
public object? GetObject([CallerMemberName] string propertyName = "") => Settings.GetObject(propertyName);
@@ -111,7 +111,34 @@ namespace LibationFileManager
public bool BetaOptIn { get => GetNonString(defaultValue: false); set => SetNonString(value); }
[Description("Location for book storage. Includes destination of newly liberated books")]
public LongPath? Books { get => GetString(); set => SetString(value); }
public LongPath? Books {
get => GetString();
set
{
if (value != Books)
{
OnPropertyChanging(nameof(Books), Books, value);
Settings.SetString(nameof(Books), value);
m_BooksCanWrite255UnicodeChars = null;
m_BooksCanWriteWindowsInvalidChars = null;
OnPropertyChanged(nameof(Books), value);
}
}
}
private bool? m_BooksCanWrite255UnicodeChars;
private bool? m_BooksCanWriteWindowsInvalidChars;
/// <summary>
/// True if the Books directory can be written to with 255 unicode character filenames
/// <para/> Does not persist. Check and set this value at runtime and whenever Books is changed.
/// </summary>
public bool BooksCanWrite255UnicodeChars => m_BooksCanWrite255UnicodeChars ??= FileSystemTest.CanWrite255UnicodeChars(AudibleFileStorage.BooksDirectory);
/// <summary>
/// True if the Books directory can be written to with filenames containing characters invalid on Windows (:, *, ?, &lt;, &gt;, |)
/// <para/> Always false on Windows platforms.
/// <para/> Does not persist. Check and set this value at runtime and whenever Books is changed.
/// </summary>
public bool BooksCanWriteWindowsInvalidChars => !IsWindows && (m_BooksCanWriteWindowsInvalidChars ??= FileSystemTest.CanWriteWindowsInvalidChars(AudibleFileStorage.BooksDirectory));
[Description("Overwrite existing files if they already exist?")]
public bool OverwriteExisting { get => GetNonString(defaultValue: false); set => SetNonString(value); }
@@ -179,12 +206,14 @@ namespace LibationFileManager
[Description("Lame target VBR quality [10,100]")]
public int LameVBRQuality { get => GetNonString(defaultValue: 2); set => SetNonString(value); }
private static readonly EquatableDictionary<string, bool> DefaultColumns = new(
new KeyValuePair<string, bool>[]
{
private static readonly EquatableDictionary<string, bool> DefaultColumns = new([
new ("SeriesOrder", false),
new ("LastDownload", false)
});
new ("LastDownload", false),
new ("IsSpatial", false)
]);
public bool GetColumnVisibility(string columnName)
=> GridColumnsVisibilities.TryGetValue(columnName, out var isVisible) ? isVisible
:DefaultColumns.GetValueOrDefault(columnName, true);
[Description("A Dictionary of GridView data property names and bool indicating its column's visibility in ProductsGrid")]
public Dictionary<string, bool> GridColumnsVisibilities { get => GetNonString(defaultValue: DefaultColumns).Clone(); set => SetNonString(value); }
@@ -317,7 +346,7 @@ namespace LibationFileManager
#region templates: custom file naming
[Description("Edit how filename characters are replaced")]
public ReplacementCharacters ReplacementCharacters { get => GetNonString(defaultValue: ReplacementCharacters.Default); set => SetNonString(value); }
public ReplacementCharacters ReplacementCharacters { get => GetNonString(defaultValue: ReplacementCharacters.Default(IsWindows)); set => SetNonString(value); }
[Description("How to format the folders in which files will be saved")]
public string FolderTemplate

View File

@@ -3,48 +3,58 @@ using System.IO;
using System.Linq;
using Dinah.Core;
using FileManager;
using Newtonsoft.Json.Linq;
#nullable enable
namespace LibationFileManager
{
public partial class Configuration : PropertyChangeFilter
{
/// <summary>
/// Returns true if <see cref="SettingsFilePath"/> exists and the <see cref="Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Books"/> directory.
/// </summary>
public bool LibationSettingsAreValid => SettingsFileIsValid(SettingsFilePath);
/// <summary>
/// Returns true if <paramref name="settingsFile"/> exists and the <see cref="Books"/> property has a non-null, non-empty value.
/// Does not verify the existence of the <see cref="Books"/> directory.
/// </summary>
/// <param name="settingsFile">File path to the settings JSON file</param>
public static bool SettingsFileIsValid(string settingsFile)
{
if (!Directory.Exists(Path.GetDirectoryName(settingsFile)) || !File.Exists(settingsFile))
return false;
var pDic = new PersistentDictionary(settingsFile, isReadOnly: false);
if (pDic.GetString(nameof(Books)) is not string booksDir)
return false;
if (!Directory.Exists(booksDir))
try
{
if (Path.GetDirectoryName(settingsFile) is not string dir)
throw new DirectoryNotFoundException(settingsFile);
//"Books" is not null, so setup has already been run.
//Since Books can't be found, try to create it
//and then revert to the default books directory
foreach (string d in new string[] { booksDir, DefaultBooksDirectory })
var settingsJson = JObject.Parse(File.ReadAllText(settingsFile));
return !string.IsNullOrWhiteSpace(settingsJson[nameof(Books)]?.Value<string>());
}
catch (Exception ex)
{
Serilog.Log.Logger.Error(ex, "Failed to load settings file: {@SettingsFile}", settingsFile);
try
{
Serilog.Log.Logger.Information("Deleting invalid settings file: {@SettingsFile}", settingsFile);
FileUtility.SaferDelete(settingsFile);
Serilog.Log.Logger.Information("Creating a new, empty setting file: {@SettingsFile}", settingsFile);
try
{
Directory.CreateDirectory(d);
pDic.SetString(nameof(Books), d);
return Directory.Exists(d);
File.WriteAllText(settingsFile, "{}");
}
catch (Exception createEx)
{
Serilog.Log.Logger.Error(createEx, "Failed to create new settings file: {@SettingsFile}", settingsFile);
}
catch { /* Do Nothing */ }
}
catch (Exception deleteEx)
{
Serilog.Log.Logger.Error(deleteEx, "Failed to delete the invalid settings file: {@SettingsFile}", settingsFile);
}
return false;
}
return true;
}
#region singleton stuff

View File

@@ -46,7 +46,9 @@ namespace LibationFileManager
public static List<(FileType fileType, LongPath path)> GetFiles(string id)
{
var matchingFiles = Cache.GetIdEntries(id);
List<CacheEntry> matchingFiles;
lock(locker)
matchingFiles = Cache.GetIdEntries(id);
bool cacheChanged = false;
@@ -68,7 +70,9 @@ namespace LibationFileManager
public static LongPath? GetFirstPath(string id, FileType type)
{
var matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList();
List<CacheEntry> matchingFiles;
lock (locker)
matchingFiles = Cache.GetIdEntries(id).Where(e => e.FileType == type).ToList();
bool cacheChanged = false;
try
@@ -96,7 +100,10 @@ namespace LibationFileManager
private static bool Remove(CacheEntry entry)
{
if (Cache.Remove(entry.Id, entry))
bool removed;
lock (locker)
removed = Cache.Remove(entry.Id, entry);
if (removed)
{
Removed?.Invoke(null, entry);
return true;
@@ -112,7 +119,8 @@ namespace LibationFileManager
public static void Insert(CacheEntry entry)
{
Cache.Add(entry.Id, entry);
lock(locker)
Cache.Add(entry.Id, entry);
Inserted?.Invoke(null, entry);
save();
}

View File

@@ -133,7 +133,16 @@ namespace LibationFileManager
try
{
var sizeStr = def.Size == PictureSize.Native ? "" : $"._SL{(int)def.Size}_";
var bytes = imageDownloadClient.GetByteArrayAsync("ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg", cancellationToken).Result;
using var requestMessage = new HttpRequestMessage(HttpMethod.Get, "ht" + $"tps://images-na.ssl-images-amazon.com/images/I/{def.PictureId}{sizeStr}.jpg");
using var response = imageDownloadClient.Send(requestMessage, HttpCompletionOption.ResponseHeadersRead, cancellationToken).EnsureSuccessStatusCode();
if (response.Content.Headers.ContentLength is not long size)
return GetDefaultImage(def.Size);
var bytes = new byte[size];
using var respStream = response.Content.ReadAsStream(cancellationToken);
respStream.ReadExactly(bytes);
// save image file. make sure to not save default image
var path = getPath(def);

View File

@@ -34,6 +34,8 @@ public class BookDto
public DateTime FileDate { get; set; } = DateTime.Now;
public DateTime? DatePublished { get; set; }
public string? Language { get; set; }
public string? LibationVersion { get; set; }
public string? FileVersion { get; set; }
}
public class LibraryBookDto : BookDto

View File

@@ -7,9 +7,9 @@ public record SeriesDto : IFormattable
{
public string Name { get; }
public float? Number { get; }
public string? Number { get; }
public string AudibleSeriesId { get; }
public SeriesDto(string name, float? number, string audibleSeriesId)
public SeriesDto(string name, string? number, string audibleSeriesId)
{
Name = name;
Number = number;

View File

@@ -68,7 +68,10 @@ namespace LibationFileManager.Templates
YearPublished = 2017,
Authors = [new("Arthur Conan Doyle", "B000AQ43GQ"), new("Stephen Fry - introductions", "B000APAGVS")],
Narrators = [new("Stephen Fry", null)],
Series = [new("Sherlock Holmes", 1, "B08376S3R2"), new("Some Other Series", 1, "B000000000")],
Series = [new("Sherlock Holmes", "1-6", "B08376S3R2"), new("Book Collection", "1", "B000000000")],
Codec = "AAC-LC",
LibationVersion = Configuration.LibationVersion?.ToVersionString(),
FileVersion = "36217811",
BitRate = 128,
SampleRate = 44100,
Channels = 2,

View File

@@ -36,10 +36,12 @@ namespace LibationFileManager.Templates
public static TemplateTags Series { get; } = new TemplateTags("series", "All series to which the book belongs (if any)");
public static TemplateTags FirstSeries { get; } = new TemplateTags("first series", "First series");
public static TemplateTags SeriesNumber { get; } = new TemplateTags("series#", "Number order in series (alias for <first series[{#}]>");
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Audiobook's source bitrate");
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Audiobook's source sample rate");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Audiobook's source audio channel count");
public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audiobook's source codec");
public static TemplateTags Bitrate { get; } = new TemplateTags("bitrate", "Bitrate (kbps) of the last downloaded audiobook");
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "Sample rate (Hz) of the last downloaded audiobook");
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels in the last downloaded audiobook");
public static TemplateTags Codec { get; } = new TemplateTags("codec", "Audio codec of the last downloaded audiobook");
public static TemplateTags FileVersion { get; } = new TemplateTags("file version", "Audible's file version number of the last downloaded audiobook");
public static TemplateTags LibationVersion { get; } = new TemplateTags("libation version", "Libation version used during last download of the audiobook");
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
public static TemplateTags AccountNickname { get; } = new TemplateTags("account nickname", "Audible account nickname of this book");
public static TemplateTags Locale { get; } = new("locale", "Region/country");

View File

@@ -157,7 +157,7 @@ namespace LibationFileManager.Templates
var maxFilenameLength = LongPath.MaxFilenameLength -
(i < pathParts.Count - 1 || string.IsNullOrEmpty(fileExtension) ? 0 : fileExtension.Length + 5);
while (part.Sum(LongPath.GetFilesystemStringLength) > maxFilenameLength)
while (part.Sum(GetFilenameLength) > maxFilenameLength)
{
int maxLength = part.Max(p => p.Length);
var maxEntry = part.First(p => p.Length == maxLength);
@@ -173,6 +173,10 @@ namespace LibationFileManager.Templates
return FileUtility.GetValidFilename(fullPath, replacements, fileExtension, returnFirstExisting);
}
private static int GetFilenameLength(string filename)
=> Configuration.Instance.BooksCanWrite255UnicodeChars ? filename.Length
: System.Text.Encoding.UTF8.GetByteCount(filename);
/// <summary>
/// Organize template parts into directories. Any Extra slashes will be
/// returned as empty directories and are taken care of by Path.Combine()
@@ -287,6 +291,8 @@ namespace LibationFileManager.Templates
{ TemplateTags.SampleRate, lb => lb.SampleRate },
{ TemplateTags.Channels, lb => lb.Channels },
{ TemplateTags.Codec, lb => lb.Codec },
{ TemplateTags.FileVersion, lb => lb.FileVersion },
{ TemplateTags.LibationVersion, lb => lb.LibationVersion },
};
private static readonly List<TagCollection> chapterPropertyTags = new()
@@ -382,7 +388,7 @@ namespace LibationFileManager.Templates
public static string Name { get; } = "Folder Template";
public static string Description { get; } = Configuration.GetDescription(nameof(Configuration.FolderTemplate)) ?? "";
public static string DefaultTemplate { get; } = "<title short> [<id>]";
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, conditionalTags, folderConditionalTags];
public static IEnumerable<TagCollection> TagCollections { get; } = [filePropertyTags, audioFilePropertyTags, conditionalTags, folderConditionalTags];
public override IEnumerable<string> Errors
=> TemplateText?.Length >= 2 && Path.IsPathFullyQualified(TemplateText) ? base.Errors.Append(ERROR_FULL_PATH_IS_INVALID) : base.Errors;

View File

@@ -50,6 +50,7 @@ namespace LibationSearchEngine
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.Rating.OverallRating > 0f).ToString(), "IsRated", "Rated" },
{ FieldType.Bool, lb => isAuthorNarrated(lb.Book).ToString(), "IsAuthorNarrated", "AuthorNarrated" },
{ FieldType.Bool, lb => lb.Book.IsAbridged.ToString(), nameof(Book.IsAbridged), "Abridged" },
{ FieldType.Bool, lb => lb.Book.IsSpatial.ToString(), nameof(Book.IsSpatial), "Spatial" },
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Liberated).ToString(), "IsLiberated", "Liberated" },
{ FieldType.Bool, lb => (lb.Book.UserDefinedItem.BookStatus == LiberatedStatus.Error).ToString(), "LiberatedError" },
{ FieldType.Bool, lb => lb.Book.IsEpisodeChild().ToString(), "Podcast", "Podcasts", "IsPodcast", "Episode", "Episodes", "IsEpisode" },

View File

@@ -48,7 +48,7 @@ namespace LibationUiBase.GridView
private Rating _productrating;
private string _bookTags;
private Rating _myRating;
private bool _isSpatial;
public abstract bool? Remove { get; set; }
public EntryStatus Liberate { get => _liberate; private set => RaiseAndSetIfChanged(ref _liberate, value); }
public string PurchaseDate { get => _purchasedate; protected set => RaiseAndSetIfChanged(ref _purchasedate, value); }
@@ -65,6 +65,7 @@ namespace LibationUiBase.GridView
public string Description { get => _description; private set => RaiseAndSetIfChanged(ref _description, value); }
public Rating ProductRating { get => _productrating; private set => RaiseAndSetIfChanged(ref _productrating, value); }
public string BookTags { get => _bookTags; private set => RaiseAndSetIfChanged(ref _bookTags, value); }
public bool IsSpatial { get => _isSpatial; protected set => RaiseAndSetIfChanged(ref _isSpatial, value); }
public Rating MyRating
{
@@ -118,6 +119,7 @@ namespace LibationUiBase.GridView
Description = GetDescriptionDisplay(Book);
SeriesIndex = Book.SeriesLink.FirstOrDefault()?.Index ?? 0;
BookTags = GetBookTags();
IsSpatial = Book.IsSpatial;
UserDefinedItem.ItemChanged += UserDefinedItem_ItemChanged;
}
@@ -205,8 +207,27 @@ namespace LibationUiBase.GridView
nameof(BookTags) => BookTags ?? string.Empty,
nameof(Liberate) => Liberate,
nameof(DateAdded) => DateAdded,
nameof(IsSpatial) => IsSpatial,
_ => null
};
public bool MemberValueIsDefault(string memberName) => memberName switch
{
nameof(Series) => Book.SeriesLink?.Any() is not true,
nameof(SeriesOrder) => string.IsNullOrWhiteSpace(SeriesOrder.OrderString),
nameof(MyRating) => RatingIsDefault(Book.UserDefinedItem.Rating),
nameof(ProductRating) => RatingIsDefault(Book.Rating),
nameof(Authors) => string.IsNullOrWhiteSpace(Authors),
nameof(Narrators) => string.IsNullOrWhiteSpace(Narrators),
nameof(Description) => string.IsNullOrWhiteSpace(Description),
nameof(Category) => string.IsNullOrWhiteSpace(Category),
nameof(Misc) => string.IsNullOrWhiteSpace(Misc),
nameof(BookTags) => string.IsNullOrWhiteSpace(BookTags),
_ => false
};
private static bool RatingIsDefault(Rating rating)
=> rating is null || (rating.OverallRating == 0 && rating.PerformanceRating == 0 && rating.StoryRating == 0);
public IComparer GetMemberComparer(Type memberType)
=> memberTypeComparers.TryGetValue(memberType, out IComparer value) ? value : memberTypeComparers[memberType.BaseType];
@@ -338,7 +359,6 @@ namespace LibationUiBase.GridView
return (await Task.WhenAll(tasks)).SelectMany(a => a).ToList();
}
~GridEntry()
{
PictureStorage.PictureCached -= PictureStorage_PictureCached;

View File

@@ -1,4 +1,5 @@
using DataLayer;
using LibationFileManager;
using System;
namespace LibationUiBase.GridView
@@ -6,25 +7,35 @@ namespace LibationUiBase.GridView
public class LastDownloadStatus : IComparable
{
public bool IsValid => LastDownloadedVersion is not null && LastDownloaded.HasValue;
public AudioFormat LastDownloadedFormat { get; }
public string LastDownloadedFileVersion { get; }
public Version LastDownloadedVersion { get; }
public DateTime? LastDownloaded { get; }
public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToString(3)} release notes" : "";
public string ToolTipText => IsValid ? $"Double click to open v{LastDownloadedVersion.ToVersionString()} release notes" : "";
public LastDownloadStatus() { }
public LastDownloadStatus(UserDefinedItem udi)
{
LastDownloadedVersion = udi.LastDownloadedVersion;
LastDownloadedFormat = udi.LastDownloadedFormat;
LastDownloadedFileVersion = udi.LastDownloadedFileVersion;
LastDownloaded = udi.LastDownloaded;
}
public void OpenReleaseUrl()
{
if (IsValid)
Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion.ToString(3)}");
Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{LastDownloadedVersion.ToVersionString()}");
}
public override string ToString()
=> IsValid ? $"{dateString()}\n\nLibation v{LastDownloadedVersion.ToString(3)}" : "";
=> IsValid ? $"""
{dateString()} {versionString()}
{LastDownloadedFormat}
Libation v{LastDownloadedVersion.ToVersionString()}
""" : "";
private string versionString() => LastDownloadedFileVersion is string ver ? $"(File v.{ver})" : "";
//Call ToShortDateString to use current culture's date format.
private string dateString() => $"{LastDownloaded.Value.ToShortDateString()} {LastDownloaded.Value:HH:mm}";

View File

@@ -21,15 +21,24 @@ namespace LibationUiBase.GridView
private int InternalCompare(GridEntry x, GridEntry y)
{
var val1 = x.GetMemberValue(PropertyName);
var val2 = y.GetMemberValue(PropertyName);
//Default values (e.g. empty strings) always sort to the end of the list.
var val1IsDefault = x.MemberValueIsDefault(PropertyName);
var val2IsDefault = y.MemberValueIsDefault(PropertyName);
var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2);
if (val1IsDefault && val2IsDefault) return 0;
else if (val1IsDefault && !val2IsDefault) return GetSortOrder() is ListSortDirection.Ascending ? 1 : -1;
else if (!val1IsDefault && val2IsDefault) return GetSortOrder() is ListSortDirection.Ascending ? -1 : 1;
else
{
var val1 = x.GetMemberValue(PropertyName);
var val2 = y.GetMemberValue(PropertyName);
var compare = x.GetMemberComparer(val1.GetType()).Compare(val1, val2);
return compare == 0 && x.Liberate.IsSeries && y.Liberate.IsSeries
//Both a and b are series parents and compare as equal, so break the tie.
? x.AudibleProductId.CompareTo(y.AudibleProductId)
: compare;
return compare == 0 && x.Liberate.IsSeries && y.Liberate.IsSeries
//Both a and b are series parents and compare as equal, so break the tie.
? x.AudibleProductId.CompareTo(y.AudibleProductId)
: compare;
}
}
public int Compare(GridEntry? geA, GridEntry? geB)

View File

@@ -9,7 +9,7 @@
</PropertyGroup>
<ItemGroup>
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.10" />
<PackageReference Include="SixLabors.ImageSharp" Version="3.1.11" />
</ItemGroup>
<ItemGroup>

View File

@@ -1,5 +1,6 @@
using ApplicationServices;
using DataLayer;
using LibationFileManager;
using LibationUiBase.Forms;
using System;
using System.Collections.Generic;
@@ -95,6 +96,9 @@ public class ProcessQueueViewModel : ReactiveObject
public bool QueueDownloadPdf(IList<LibraryBook> libraryBooks)
{
if (!IsBooksDirectoryValid())
return false;
var needsPdf = libraryBooks.Where(lb => lb.NeedsPdfDownload()).ToArray();
if (needsPdf.Length > 0)
{
@@ -107,10 +111,15 @@ public class ProcessQueueViewModel : ReactiveObject
public bool QueueConvertToMp3(IList<LibraryBook> libraryBooks)
{
if (!IsBooksDirectoryValid())
return false;
//Only Queue Liberated books for conversion. This isn't a perfect filter, but it's better than nothing.
var preLiberated = libraryBooks.Where(lb => !lb.AbsentFromLastScan && lb.Book.UserDefinedItem.BookStatus is LiberatedStatus.Liberated && lb.Book.ContentType is DataLayer.ContentType.Product).ToArray();
if (preLiberated.Length > 0)
{
if (preLiberated.Length == 1)
RemoveCompleted(preLiberated[0]);
Serilog.Log.Logger.Information("Begin convert {count} books to mp3", preLiberated.Length);
AddConvertMp3(preLiberated);
return true;
@@ -120,6 +129,9 @@ public class ProcessQueueViewModel : ReactiveObject
public bool QueueDownloadDecrypt(IList<LibraryBook> libraryBooks)
{
if (!IsBooksDirectoryValid())
return false;
if (libraryBooks.Count == 1)
{
var item = libraryBooks[0];
@@ -155,6 +167,32 @@ public class ProcessQueueViewModel : ReactiveObject
return false;
}
private bool IsBooksDirectoryValid()
{
if (string.IsNullOrWhiteSpace(Configuration.Instance.Books))
{
Serilog.Log.Logger.Error("Books location is not set in configuration.");
MessageBoxBase.Show(
"Please choose a \"Books location\" folder in the Settings menu.",
"Books Directory Not Set",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
return false;
}
else if (AudibleFileStorage.BooksDirectory is null)
{
Serilog.Log.Logger.Error("Failed to create books directory: {@booksDir}", Configuration.Instance.Books);
MessageBoxBase.Show(
$"Libation was unable to create the \"Books location\" folder at:\n{Configuration.Instance.Books}\n\nPlease change the Books location in the settings menu.",
"Failed to Create Books Directory",
MessageBoxButtons.OK,
MessageBoxIcon.Error);
return false;
}
return true;
}
private bool IsBookInQueue(LibraryBook libraryBook)
=> Queue.FirstOrDefault(b => b?.LibraryBook?.Book?.AudibleProductId == libraryBook.Book.AudibleProductId) is not ProcessBookViewModel entry ? false
: entry.Status is ProcessBookStatus.Cancelled or ProcessBookStatus.Failed ? !Queue.RemoveCompleted(entry)

View File

@@ -7,8 +7,14 @@ using System.Runtime.CompilerServices;
#nullable enable
namespace LibationUiBase;
/// <summary>
/// ReactiveObject is the base object for ViewModel classes, and it implements INotifyPropertyChanging
/// and INotifyPropertyChanged. Additionally
/// object changes.
/// </summary>
public class ReactiveObject : SynchronizeInvoker, INotifyPropertyChanged, INotifyPropertyChanging
{
// see also notes in Libation/Source/_ARCHITECTURE NOTES.txt :: MVVM
public event PropertyChangedEventHandler? PropertyChanged;
public event PropertyChangingEventHandler? PropertyChanging;

View File

@@ -117,7 +117,7 @@ namespace LibationUiBase.SeriesView
}
private void DownloadButton_ButtonEnabled(object sender, EventArgs e)
=> OnPropertyChanged(nameof(Enabled));
=> RaisePropertyChanged(nameof(Enabled));
public override int CompareTo(object ob)
{

View File

@@ -1,8 +1,6 @@
using AudibleApi.Common;
using DataLayer;
using Dinah.Core.Threading;
using System;
using System.ComponentModel;
using System.Threading.Tasks;
namespace LibationUiBase.SeriesView
@@ -10,11 +8,9 @@ namespace LibationUiBase.SeriesView
/// <summary>
/// base view model for the Series Viewer 'Availability' button column
/// </summary>
public abstract class SeriesButton : SynchronizeInvoker, IComparable, INotifyPropertyChanged
public abstract class SeriesButton : ReactiveObject, IComparable
{
public event PropertyChangedEventHandler PropertyChanged;
private bool inLibrary;
protected Item Item { get; }
public abstract string DisplayText { get; }
public abstract bool HasButtonAction { get; }
@@ -27,8 +23,8 @@ namespace LibationUiBase.SeriesView
if (inLibrary != value)
{
inLibrary = value;
OnPropertyChanged(nameof(InLibrary));
OnPropertyChanged(nameof(DisplayText));
RaisePropertyChanged(nameof(InLibrary));
RaisePropertyChanged(nameof(DisplayText));
}
}
}
@@ -41,9 +37,6 @@ namespace LibationUiBase.SeriesView
public abstract Task PerformClickAsync(LibraryBook accountBook);
protected void OnPropertyChanged(string propertyName)
=> Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
public override string ToString() => DisplayText;
public abstract int CompareTo(object ob);

View File

@@ -4,7 +4,6 @@ using AudibleApi.Common;
using AudibleUtilities;
using DataLayer;
using Dinah.Core;
using Dinah.Core.Threading;
using FileLiberator;
using LibationFileManager;
using System.Collections.Generic;
@@ -15,7 +14,7 @@ using System.Threading.Tasks;
namespace LibationUiBase.SeriesView
{
public class SeriesItem : SynchronizeInvoker, INotifyPropertyChanged
public class SeriesItem : ReactiveObject
{
public object Cover { get; private set; }
public SeriesOrder Order { get; }
@@ -23,8 +22,6 @@ namespace LibationUiBase.SeriesView
public SeriesButton Button { get; }
public Item Item { get; }
public event PropertyChangedEventHandler PropertyChanged;
private SeriesItem(Item item, string order, bool inLibrary, bool inWishList)
{
Item = item;
@@ -42,10 +39,7 @@ namespace LibationUiBase.SeriesView
}
private void DownloadButton_PropertyChanged(object sender, PropertyChangedEventArgs e)
=> OnPropertyChanged(nameof(Button));
private void OnPropertyChanged(string propertyName)
=> Invoke(() => PropertyChanged?.Invoke(this, new PropertyChangedEventArgs(propertyName)));
=> RaisePropertyChanged(nameof(Button));
private void LoadCover(string pictureId)
{
@@ -66,7 +60,7 @@ namespace LibationUiBase.SeriesView
{
Cover = BaseUtil.LoadImage(e.Picture, PictureSize._80x80);
PictureStorage.PictureCached -= PictureStorage_PictureCached;
OnPropertyChanged(nameof(Cover));
RaisePropertyChanged(nameof(Cover));
}
}
}

View File

@@ -22,14 +22,7 @@ namespace LibationUiBase.SeriesView
public override bool Enabled
{
get => instanceEnabled;
protected set
{
if (instanceEnabled != value)
{
instanceEnabled = value;
OnPropertyChanged(nameof(Enabled));
}
}
protected set => RaiseAndSetIfChanged(ref instanceEnabled, value);
}
private bool InWishList
@@ -40,8 +33,8 @@ namespace LibationUiBase.SeriesView
if (inWishList != value)
{
inWishList = value;
OnPropertyChanged(nameof(InWishList));
OnPropertyChanged(nameof(DisplayText));
RaisePropertyChanged(nameof(InWishList));
RaisePropertyChanged(nameof(DisplayText));
}
}
}

View File

@@ -1,4 +1,5 @@
using LibationUiBase;
using LibationFileManager;
using LibationUiBase;
using System;
using System.Linq;
using System.Threading.Tasks;
@@ -38,7 +39,7 @@ namespace LibationWinForms.Dialogs
}
private void releaseNotesLbl_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToString(3)}");
=> Dinah.Core.Go.To.Url($"{AppScaffolding.LibationScaffolding.RepositoryUrl}/releases/tag/v{AppScaffolding.LibationScaffolding.BuildVersion.ToVersionString()}");
private async void checkForUpgradeBtn_Click(object sender, EventArgs e)
{

View File

@@ -250,12 +250,12 @@ namespace LibationWinForms.Dialogs
}
}
private class BookRecordEntry : GridView.AsyncNotifyPropertyChanged
private class BookRecordEntry : LibationUiBase.ReactiveObject
{
private const string DateFormat = "yyyy-MM-dd HH\\:mm";
private bool _ischecked;
public IRecord Record { get; }
public bool IsChecked { get => _ischecked; set { _ischecked = value; NotifyPropertyChanged(); } }
public bool IsChecked { get => _ischecked; set => RaiseAndSetIfChanged(ref _ischecked, value); }
public string Type => Record.GetType().Name;
public string Start => formatTimeSpan(Record.Start);
public string Created => Record.Created.ToString(DateFormat);

View File

@@ -50,13 +50,13 @@ namespace LibationWinForms.Dialogs
}
private void loFiDefaultsBtn_Click(object sender, EventArgs e)
=> LoadTable(ReplacementCharacters.LoFiDefault.Replacements);
=> LoadTable(ReplacementCharacters.LoFiDefault(ntfs: true).Replacements);
private void defaultsBtn_Click(object sender, EventArgs e)
=> LoadTable(ReplacementCharacters.Default.Replacements);
=> LoadTable(ReplacementCharacters.Default(ntfs: true).Replacements);
private void minDefaultBtn_Click(object sender, EventArgs e)
=> LoadTable(ReplacementCharacters.Barebones.Replacements);
=> LoadTable(ReplacementCharacters.Barebones(ntfs: true).Replacements);
private void dataGridView1_CellEndEdit(object sender, DataGridViewCellEventArgs e)

View File

@@ -28,161 +28,168 @@
/// </summary>
private void InitializeComponent()
{
this.saveBtn = new System.Windows.Forms.Button();
this.cancelBtn = new System.Windows.Forms.Button();
this.templateTb = new System.Windows.Forms.TextBox();
this.templateLbl = new System.Windows.Forms.Label();
this.resetToDefaultBtn = new System.Windows.Forms.Button();
this.listView1 = new System.Windows.Forms.ListView();
this.columnHeader1 = new System.Windows.Forms.ColumnHeader();
this.columnHeader2 = new System.Windows.Forms.ColumnHeader();
this.richTextBox1 = new System.Windows.Forms.RichTextBox();
this.warningsLbl = new System.Windows.Forms.Label();
this.exampleLbl = new System.Windows.Forms.Label();
this.SuspendLayout();
saveBtn = new System.Windows.Forms.Button();
cancelBtn = new System.Windows.Forms.Button();
templateTb = new System.Windows.Forms.TextBox();
templateLbl = new System.Windows.Forms.Label();
resetToDefaultBtn = new System.Windows.Forms.Button();
listView1 = new System.Windows.Forms.ListView();
columnHeader1 = new System.Windows.Forms.ColumnHeader();
columnHeader2 = new System.Windows.Forms.ColumnHeader();
richTextBox1 = new System.Windows.Forms.RichTextBox();
warningsLbl = new System.Windows.Forms.Label();
exampleLbl = new System.Windows.Forms.Label();
llblGoToWiki = new System.Windows.Forms.LinkLabel();
SuspendLayout();
//
// saveBtn
//
this.saveBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.saveBtn.Location = new System.Drawing.Point(714, 345);
this.saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.saveBtn.Name = "saveBtn";
this.saveBtn.Size = new System.Drawing.Size(88, 27);
this.saveBtn.TabIndex = 98;
this.saveBtn.Text = "Save";
this.saveBtn.UseVisualStyleBackColor = true;
this.saveBtn.Click += new System.EventHandler(this.saveBtn_Click);
saveBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
saveBtn.Location = new System.Drawing.Point(714, 345);
saveBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
saveBtn.Name = "saveBtn";
saveBtn.Size = new System.Drawing.Size(88, 27);
saveBtn.TabIndex = 98;
saveBtn.Text = "Save";
saveBtn.UseVisualStyleBackColor = true;
saveBtn.Click += saveBtn_Click;
//
// cancelBtn
//
this.cancelBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right)));
this.cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
this.cancelBtn.Location = new System.Drawing.Point(832, 345);
this.cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.cancelBtn.Name = "cancelBtn";
this.cancelBtn.Size = new System.Drawing.Size(88, 27);
this.cancelBtn.TabIndex = 99;
this.cancelBtn.Text = "Cancel";
this.cancelBtn.UseVisualStyleBackColor = true;
this.cancelBtn.Click += new System.EventHandler(this.cancelBtn_Click);
cancelBtn.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Right;
cancelBtn.DialogResult = System.Windows.Forms.DialogResult.Cancel;
cancelBtn.Location = new System.Drawing.Point(832, 345);
cancelBtn.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
cancelBtn.Name = "cancelBtn";
cancelBtn.Size = new System.Drawing.Size(88, 27);
cancelBtn.TabIndex = 99;
cancelBtn.Text = "Cancel";
cancelBtn.UseVisualStyleBackColor = true;
cancelBtn.Click += cancelBtn_Click;
//
// templateTb
//
this.templateTb.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.templateTb.Location = new System.Drawing.Point(12, 27);
this.templateTb.Name = "templateTb";
this.templateTb.Size = new System.Drawing.Size(779, 23);
this.templateTb.TabIndex = 1;
this.templateTb.TextChanged += new System.EventHandler(this.templateTb_TextChanged);
templateTb.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
templateTb.Location = new System.Drawing.Point(12, 27);
templateTb.Name = "templateTb";
templateTb.Size = new System.Drawing.Size(779, 23);
templateTb.TabIndex = 1;
templateTb.TextChanged += templateTb_TextChanged;
//
// templateLbl
//
this.templateLbl.AutoSize = true;
this.templateLbl.Location = new System.Drawing.Point(12, 9);
this.templateLbl.Name = "templateLbl";
this.templateLbl.Size = new System.Drawing.Size(89, 15);
this.templateLbl.TabIndex = 0;
this.templateLbl.Text = "[template desc]";
templateLbl.AutoSize = true;
templateLbl.Location = new System.Drawing.Point(12, 9);
templateLbl.Name = "templateLbl";
templateLbl.Size = new System.Drawing.Size(89, 15);
templateLbl.TabIndex = 0;
templateLbl.Text = "[template desc]";
//
// resetToDefaultBtn
//
this.resetToDefaultBtn.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right)));
this.resetToDefaultBtn.Location = new System.Drawing.Point(797, 26);
this.resetToDefaultBtn.Name = "resetToDefaultBtn";
this.resetToDefaultBtn.Size = new System.Drawing.Size(124, 23);
this.resetToDefaultBtn.TabIndex = 2;
this.resetToDefaultBtn.Text = "Reset to default";
this.resetToDefaultBtn.UseVisualStyleBackColor = true;
this.resetToDefaultBtn.Click += new System.EventHandler(this.resetToDefaultBtn_Click);
resetToDefaultBtn.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Right;
resetToDefaultBtn.Location = new System.Drawing.Point(797, 26);
resetToDefaultBtn.Name = "resetToDefaultBtn";
resetToDefaultBtn.Size = new System.Drawing.Size(124, 23);
resetToDefaultBtn.TabIndex = 2;
resetToDefaultBtn.Text = "Reset to default";
resetToDefaultBtn.UseVisualStyleBackColor = true;
resetToDefaultBtn.Click += resetToDefaultBtn_Click;
//
// listView1
//
this.listView1.Anchor = ((System.Windows.Forms.AnchorStyles)(((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)));
this.listView1.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] {
this.columnHeader1,
this.columnHeader2});
this.listView1.FullRowSelect = true;
this.listView1.GridLines = true;
this.listView1.Location = new System.Drawing.Point(12, 56);
this.listView1.MultiSelect = false;
this.listView1.Name = "listView1";
this.listView1.Size = new System.Drawing.Size(328, 283);
this.listView1.TabIndex = 3;
this.listView1.UseCompatibleStateImageBehavior = false;
this.listView1.View = System.Windows.Forms.View.Details;
this.listView1.DoubleClick += new System.EventHandler(this.listView1_DoubleClick);
listView1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
listView1.Columns.AddRange(new System.Windows.Forms.ColumnHeader[] { columnHeader1, columnHeader2 });
listView1.FullRowSelect = true;
listView1.GridLines = true;
listView1.Location = new System.Drawing.Point(12, 56);
listView1.MultiSelect = false;
listView1.Name = "listView1";
listView1.Size = new System.Drawing.Size(328, 283);
listView1.TabIndex = 3;
listView1.UseCompatibleStateImageBehavior = false;
listView1.View = System.Windows.Forms.View.Details;
listView1.DoubleClick += listView1_DoubleClick;
//
// columnHeader1
//
this.columnHeader1.Text = "Tag";
this.columnHeader1.Width = 137;
columnHeader1.Text = "Tag";
columnHeader1.Width = 137;
//
// columnHeader2
//
this.columnHeader2.Text = "Description";
this.columnHeader2.Width = 170;
columnHeader2.Text = "Description";
columnHeader2.Width = 170;
//
// richTextBox1
//
this.richTextBox1.Anchor = ((System.Windows.Forms.AnchorStyles)((((System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom)
| System.Windows.Forms.AnchorStyles.Left)
| System.Windows.Forms.AnchorStyles.Right)));
this.richTextBox1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
this.richTextBox1.Location = new System.Drawing.Point(346, 74);
this.richTextBox1.Name = "richTextBox1";
this.richTextBox1.ReadOnly = true;
this.richTextBox1.Size = new System.Drawing.Size(574, 185);
this.richTextBox1.TabIndex = 5;
this.richTextBox1.Text = "";
richTextBox1.Anchor = System.Windows.Forms.AnchorStyles.Top | System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left | System.Windows.Forms.AnchorStyles.Right;
richTextBox1.BorderStyle = System.Windows.Forms.BorderStyle.FixedSingle;
richTextBox1.Location = new System.Drawing.Point(346, 74);
richTextBox1.Name = "richTextBox1";
richTextBox1.ReadOnly = true;
richTextBox1.Size = new System.Drawing.Size(574, 185);
richTextBox1.TabIndex = 5;
richTextBox1.Text = "";
//
// warningsLbl
//
this.warningsLbl.Anchor = ((System.Windows.Forms.AnchorStyles)((System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left)));
this.warningsLbl.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold, System.Drawing.GraphicsUnit.Point);
this.warningsLbl.ForeColor = System.Drawing.Color.Firebrick;
this.warningsLbl.Location = new System.Drawing.Point(346, 262);
this.warningsLbl.Name = "warningsLbl";
this.warningsLbl.Size = new System.Drawing.Size(574, 77);
this.warningsLbl.TabIndex = 6;
this.warningsLbl.Text = "[warnings]";
warningsLbl.Anchor = System.Windows.Forms.AnchorStyles.Bottom | System.Windows.Forms.AnchorStyles.Left;
warningsLbl.Font = new System.Drawing.Font("Segoe UI", 9F, System.Drawing.FontStyle.Bold);
warningsLbl.ForeColor = System.Drawing.Color.Firebrick;
warningsLbl.Location = new System.Drawing.Point(346, 262);
warningsLbl.Name = "warningsLbl";
warningsLbl.Size = new System.Drawing.Size(574, 77);
warningsLbl.TabIndex = 6;
warningsLbl.Text = "[warnings]";
//
// exampleLbl
//
this.exampleLbl.AutoSize = true;
this.exampleLbl.Location = new System.Drawing.Point(346, 56);
this.exampleLbl.Name = "exampleLbl";
this.exampleLbl.Size = new System.Drawing.Size(55, 15);
this.exampleLbl.TabIndex = 4;
this.exampleLbl.Text = "Example:";
exampleLbl.AutoSize = true;
exampleLbl.Location = new System.Drawing.Point(346, 56);
exampleLbl.Name = "exampleLbl";
exampleLbl.Size = new System.Drawing.Size(54, 15);
exampleLbl.TabIndex = 4;
exampleLbl.Text = "Example:";
//
// llblGoToWiki
//
llblGoToWiki.AutoSize = true;
llblGoToWiki.Location = new System.Drawing.Point(12, 357);
llblGoToWiki.Name = "llblGoToWiki";
llblGoToWiki.Size = new System.Drawing.Size(229, 15);
llblGoToWiki.TabIndex = 100;
llblGoToWiki.TabStop = true;
llblGoToWiki.Text = "Read about naming templates on the Wiki";
llblGoToWiki.LinkClicked += llblGoToWiki_LinkClicked;
//
// EditTemplateDialog
//
this.AcceptButton = this.saveBtn;
this.AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
this.AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
this.CancelButton = this.cancelBtn;
this.ClientSize = new System.Drawing.Size(933, 388);
this.Controls.Add(this.exampleLbl);
this.Controls.Add(this.warningsLbl);
this.Controls.Add(this.richTextBox1);
this.Controls.Add(this.listView1);
this.Controls.Add(this.resetToDefaultBtn);
this.Controls.Add(this.templateLbl);
this.Controls.Add(this.templateTb);
this.Controls.Add(this.cancelBtn);
this.Controls.Add(this.saveBtn);
this.FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
this.Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
this.MaximizeBox = false;
this.MinimizeBox = false;
this.Name = "EditTemplateDialog";
this.StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
this.Text = "Edit Template";
this.Load += new System.EventHandler(this.EditTemplateDialog_Load);
this.ResumeLayout(false);
this.PerformLayout();
AcceptButton = saveBtn;
AutoScaleDimensions = new System.Drawing.SizeF(96F, 96F);
AutoScaleMode = System.Windows.Forms.AutoScaleMode.Dpi;
CancelButton = cancelBtn;
ClientSize = new System.Drawing.Size(933, 388);
Controls.Add(llblGoToWiki);
Controls.Add(exampleLbl);
Controls.Add(warningsLbl);
Controls.Add(richTextBox1);
Controls.Add(listView1);
Controls.Add(resetToDefaultBtn);
Controls.Add(templateLbl);
Controls.Add(templateTb);
Controls.Add(cancelBtn);
Controls.Add(saveBtn);
FormBorderStyle = System.Windows.Forms.FormBorderStyle.FixedDialog;
Margin = new System.Windows.Forms.Padding(4, 3, 4, 3);
MaximizeBox = false;
MinimizeBox = false;
Name = "EditTemplateDialog";
StartPosition = System.Windows.Forms.FormStartPosition.CenterParent;
Text = "Edit Template";
Load += EditTemplateDialog_Load;
ResumeLayout(false);
PerformLayout();
}
@@ -198,5 +205,6 @@
private System.Windows.Forms.RichTextBox richTextBox1;
private System.Windows.Forms.Label warningsLbl;
private System.Windows.Forms.Label exampleLbl;
private System.Windows.Forms.LinkLabel llblGoToWiki;
}
}

View File

@@ -12,7 +12,7 @@ namespace LibationWinForms.Dialogs
{
private void resetTextBox(string value) => this.templateTb.Text = value;
private Configuration config { get; } = Configuration.Instance;
private ITemplateEditor templateEditor { get;}
private ITemplateEditor templateEditor { get; }
public EditTemplateDialog()
{
@@ -54,7 +54,7 @@ namespace LibationWinForms.Dialogs
private void templateTb_TextChanged(object sender, EventArgs e)
{
templateEditor.SetTemplateText(templateTb.Text);
templateEditor.SetTemplateText(templateTb.Text);
const char ZERO_WIDTH_SPACE = '\u200B';
var sing = $"{Path.DirectorySeparatorChar}";
@@ -150,5 +150,11 @@ namespace LibationWinForms.Dialogs
templateTb.Text = text.Insert(selStart, itemText);
templateTb.SelectionStart = selStart + itemText.Length;
}
private void llblGoToWiki_LinkClicked(object sender, LinkLabelLinkClickedEventArgs e)
{
Go.To.Url(@"ht" + "tps://github.com/rmcrackan/Libation/blob/master/Documentation/NamingTemplates.md");
e.Link.Visited = true;
}
}
}

View File

@@ -1,4 +1,64 @@
<root>
<?xml version="1.0" encoding="utf-8"?>
<root>
<!--
Microsoft ResX Schema
Version 2.0
The primary goals of this format is to allow a simple XML format
that is mostly human readable. The generation and parsing of the
various data types are done through the TypeConverter classes
associated with the data types.
Example:
... ado.net/XML headers & schema ...
<resheader name="resmimetype">text/microsoft-resx</resheader>
<resheader name="version">2.0</resheader>
<resheader name="reader">System.Resources.ResXResourceReader, System.Windows.Forms, ...</resheader>
<resheader name="writer">System.Resources.ResXResourceWriter, System.Windows.Forms, ...</resheader>
<data name="Name1"><value>this is my long string</value><comment>this is a comment</comment></data>
<data name="Color1" type="System.Drawing.Color, System.Drawing">Blue</data>
<data name="Bitmap1" mimetype="application/x-microsoft.net.object.binary.base64">
<value>[base64 mime encoded serialized .NET Framework object]</value>
</data>
<data name="Icon1" type="System.Drawing.Icon, System.Drawing" mimetype="application/x-microsoft.net.object.bytearray.base64">
<value>[base64 mime encoded string representing a byte array form of the .NET Framework object]</value>
<comment>This is a comment</comment>
</data>
There are any number of "resheader" rows that contain simple
name/value pairs.
Each data row contains a name, and value. The row also contains a
type or mimetype. Type corresponds to a .NET class that support
text/value conversion through the TypeConverter architecture.
Classes that don't support this are serialized and stored with the
mimetype set.
The mimetype is used for serialized objects, and tells the
ResXResourceReader how to depersist the object. This is currently not
extensible. For a given mimetype the value must be set accordingly:
Note - application/x-microsoft.net.object.binary.base64 is the format
that the ResXResourceWriter will generate, however the reader can
read any of the formats listed below.
mimetype: application/x-microsoft.net.object.binary.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Binary.BinaryFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.soap.base64
value : The object must be serialized with
: System.Runtime.Serialization.Formatters.Soap.SoapFormatter
: and then encoded with base64 encoding.
mimetype: application/x-microsoft.net.object.bytearray.base64
value : The object must be serialized into a byte array
: using a System.ComponentModel.TypeConverter
: and then encoded with base64 encoding.
-->
<xsd:schema id="root" xmlns="" xmlns:xsd="http://www.w3.org/2001/XMLSchema" xmlns:msdata="urn:schemas-microsoft-com:xml-msdata">
<xsd:import namespace="http://www.w3.org/XML/1998/namespace" />
<xsd:element name="root" msdata:IsDataSet="true">

View File

@@ -83,9 +83,11 @@ namespace LibationWinForms.Dialogs
if (lb.Book.UserDefinedItem.BookStatus is not LiberatedStatus.Liberated)
await Task.Run(() => lb.UpdateBookStatus(LiberatedStatus.Liberated));
tokenSource.Token.ThrowIfCancellationRequested();
this.Invoke(FileFound, this, book);
}
catch(Exception ex)
catch (OperationCanceledException) { }
catch (Exception ex)
{
Serilog.Log.Error(ex, "Error adding found audiobook file to Libation. {@audioFile}", book);
}

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