mirror of
https://github.com/rmcrackan/Libation.git
synced 2026-01-01 18:38:01 -05:00
Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
867085600c | ||
|
|
74290ec609 | ||
|
|
5ee555e60c | ||
|
|
a36c28d48f | ||
|
|
0877f2c042 | ||
|
|
2baf5243ea | ||
|
|
b7e71f5812 | ||
|
|
2ed1076fab | ||
|
|
0b20aa751f | ||
|
|
05a4ece8d1 | ||
|
|
25b37c6266 | ||
|
|
b668cff0ac | ||
|
|
4d6c742ae9 | ||
|
|
933f663d22 | ||
|
|
0c55f278a4 | ||
|
|
3f567ee82e | ||
|
|
8dc912c11d | ||
|
|
f1b4e2a17d | ||
|
|
630cfdeab3 | ||
|
|
7029409792 | ||
|
|
d0727b5a85 | ||
|
|
9f52ad5e0a | ||
|
|
501ae643f7 | ||
|
|
400074170e | ||
|
|
17103ed066 | ||
|
|
b6b29309c9 | ||
|
|
a04538710f | ||
|
|
01f6f5c137 | ||
|
|
b1a37cbd8c | ||
|
|
8c59e1280b | ||
|
|
00339127aa | ||
|
|
5935b40b60 | ||
|
|
6cfd2dea96 | ||
|
|
7d3a39c693 | ||
|
|
6e7a4ea475 | ||
|
|
3479dbc3f0 | ||
|
|
9309aea6d9 |
6
.github/workflows/build-linux.yml
vendored
6
.github/workflows/build-linux.yml
vendored
@@ -15,10 +15,6 @@ on:
|
||||
description: 'Skip running unit tests'
|
||||
required: false
|
||||
default: true
|
||||
outputs:
|
||||
version:
|
||||
description: "The Libation version number"
|
||||
value: ${{ jobs.build.outputs.version }}
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: 'Release'
|
||||
@@ -27,8 +23,6 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: ubuntu-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Linux, MacOS]
|
||||
|
||||
8
.github/workflows/build-windows.yml
vendored
8
.github/workflows/build-windows.yml
vendored
@@ -15,10 +15,6 @@ on:
|
||||
description: 'Skip running unit tests'
|
||||
required: false
|
||||
default: true
|
||||
outputs:
|
||||
version:
|
||||
description: "The Libation version number"
|
||||
value: ${{ jobs.build.outputs.version }}
|
||||
|
||||
env:
|
||||
DOTNET_CONFIGURATION: 'Release'
|
||||
@@ -27,8 +23,6 @@ env:
|
||||
jobs:
|
||||
build:
|
||||
runs-on: windows-latest
|
||||
outputs:
|
||||
version: ${{ steps.get_version.outputs.version }}
|
||||
strategy:
|
||||
matrix:
|
||||
os: [Windows]
|
||||
@@ -74,7 +68,7 @@ jobs:
|
||||
- name: Zip artifact
|
||||
id: zip
|
||||
working-directory: ./Source/bin/Publish
|
||||
run: |
|
||||
run: |
|
||||
$dir = "${{ matrix.os }}-${{ matrix.release_name }}\"
|
||||
$delfiles = @("libmp3lame.so", "ffmpegaac.so", "glass-with-glow_256.svg", "Libation.desktop")
|
||||
foreach ($file in $delfiles){ if (test-path $dir$file){ Remove-Item $dir$file } }
|
||||
|
||||
15
.github/workflows/build.yml
vendored
15
.github/workflows/build.yml
vendored
@@ -15,11 +15,6 @@ on:
|
||||
description: 'Skip running unit tests'
|
||||
required: false
|
||||
default: true
|
||||
build_deb:
|
||||
type: boolean
|
||||
description: 'Build Debian package'
|
||||
required: false
|
||||
default: false
|
||||
|
||||
jobs:
|
||||
windows:
|
||||
@@ -32,12 +27,4 @@ jobs:
|
||||
uses: ./.github/workflows/build-linux.yml
|
||||
with:
|
||||
version_override: ${{ inputs.version_override }}
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
|
||||
linux_deb:
|
||||
needs: [linux]
|
||||
if: inputs.build_deb
|
||||
uses: ./.github/workflows/build-deb.yml
|
||||
with:
|
||||
version: ${{ needs.linux.outputs.version }}
|
||||
|
||||
run_unit_tests: ${{ inputs.run_unit_tests }}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
# build-deb.yml
|
||||
# deb.yml
|
||||
# Reusable workflow that builds the Linux Debian package.
|
||||
---
|
||||
name: build_deb
|
||||
name: deb
|
||||
|
||||
on:
|
||||
workflow_call:
|
||||
@@ -21,15 +21,14 @@ jobs:
|
||||
- uses: actions/checkout@v3
|
||||
|
||||
- name: Download Artifact
|
||||
uses: actions/download-artifact@master
|
||||
uses: actions/download-artifact@v3
|
||||
with:
|
||||
name: "${{ env.FILE_NAME }}.tar.gz"
|
||||
|
||||
- name: Build .deb
|
||||
id: deb
|
||||
run: |
|
||||
chmod +x ./.github/workflows/scripts/targz2deb.sh
|
||||
./.github/workflows/scripts/targz2deb.sh "${{ env.FILE_NAME }}.tar.gz" ${{ inputs.version }}
|
||||
./Scripts/targz2deb.sh "${{ env.FILE_NAME }}.tar.gz" ${{ inputs.version }}
|
||||
|
||||
- name: Publish .deb
|
||||
uses: actions/upload-artifact@v3
|
||||
2
.github/workflows/docker.yml
vendored
2
.github/workflows/docker.yml
vendored
@@ -39,7 +39,7 @@ jobs:
|
||||
password: ${{ secrets.docker_token }}
|
||||
|
||||
- name: Build and push
|
||||
uses: docker/build-push-action@v3
|
||||
uses: docker/build-push-action@v4
|
||||
with:
|
||||
push: true
|
||||
build-args: 'FOLDER_NAME=Linux-chardonnay'
|
||||
|
||||
22
.github/workflows/release.yml
vendored
22
.github/workflows/release.yml
vendored
@@ -33,10 +33,15 @@ jobs:
|
||||
with:
|
||||
version_override: ${{ needs.prerelease.outputs.version }}
|
||||
run_unit_tests: false
|
||||
build_deb: true
|
||||
|
||||
deb:
|
||||
needs: [prerelease,build]
|
||||
uses: ./.github/workflows/deb.yml
|
||||
with:
|
||||
version: ${{ needs.prerelease.outputs.version }}
|
||||
|
||||
release:
|
||||
needs: [prerelease,build]
|
||||
needs: [prerelease,build,deb]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Download artifacts
|
||||
@@ -44,14 +49,11 @@ jobs:
|
||||
with:
|
||||
path: artifacts
|
||||
|
||||
- name: Create release
|
||||
id: create_release
|
||||
uses: actions/create-release@v1
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
- name: Release
|
||||
id: release
|
||||
uses: softprops/action-gh-release@v1
|
||||
with:
|
||||
tag_name: '${{ github.ref }}'
|
||||
release_name: 'Libation ${{ needs.prerelease.outputs.version }}'
|
||||
name: Libation ${{ needs.prerelease.outputs.version }}
|
||||
body: <Put a body here>
|
||||
draft: true
|
||||
prerelease: false
|
||||
@@ -61,5 +63,5 @@ jobs:
|
||||
env:
|
||||
GITHUB_TOKEN: '${{ secrets.GITHUB_TOKEN }}'
|
||||
with:
|
||||
release_id: '${{ steps.create_release.outputs.id }}'
|
||||
release_id: '${{ steps.release.outputs.id }}'
|
||||
assets_path: ./artifacts
|
||||
|
||||
36
Documentation/Docker.md
Normal file
36
Documentation/Docker.md
Normal file
@@ -0,0 +1,36 @@
|
||||
## [Download Libation](https://github.com/rmcrackan/Libation/releases/latest)
|
||||
|
||||
### If you found this useful, tell a friend. If you found this REALLY useful, you can click here to [PalPal.me](https://paypal.me/mcrackan?locale.x=en_us)
|
||||
...or just tell more friends. As long as I'm maintaining this software, it will remain **free** and **open source**.
|
||||
|
||||
|
||||
### Setup
|
||||
In order to use the docker image, you'll need to provide it with a copy of the `AccountsSettings.json`, `Settings.json`, and `LibationContext.db` files. These files can usually be found in the Libation folder in your user's home directory. If you haven't run Libation yet, you'll need to launch it to generate these files and setup your accounts. Once you have them, copy these files to a new location, such as `/opt/libation/config`. Before using them we'll need to make a couple edits so that the filepaths referenced are correct when running from the docker image.
|
||||
|
||||
In Settings.json, make the following changes:
|
||||
* Change `Books` to `/data`
|
||||
* Change `InProgress` to `/tmp`
|
||||
|
||||
### Running
|
||||
Once the configuration files are copied and edited, the docker image can be run with the following command.
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation
|
||||
```
|
||||
|
||||
By default the container will scan for new books every 30 minutes and download any new ones. This is configurable by passing in a value for the `SLEEP_TIME` environment variable. Additionally, if you pass in `-1` it will scan and download books once and then exit.
|
||||
|
||||
```
|
||||
sudo docker run -d \
|
||||
-v /opt/libation/config:/config \
|
||||
-v /opt/libation/books:/data \
|
||||
-e SLEEP_TIME='10m' \
|
||||
--name libation \
|
||||
--restart=always \
|
||||
rmcrackan/libation
|
||||
```
|
||||
|
||||
@@ -38,3 +38,7 @@ Once Gatekeeper reenabled, you can open Libation again without it being blocked.
|
||||
Thanks [joseph-holland](https://github.com/rmcrackan/Libation/issues/327#issuecomment-1268993349)!
|
||||
|
||||
Report bugs to https://github.com/rmcrackan/Libation/issues
|
||||
|
||||
## Get Libation running on Mac
|
||||
|
||||
[Run Libation on MacOS](https://user-images.githubusercontent.com/37587114/213933357-983d8ede-2738-4b32-9c6e-40de21ff09c2.mp4)
|
||||
|
||||
@@ -30,6 +30,7 @@
|
||||
- [Settings](Documentation/Advanced.md#settings)
|
||||
- [Custom File Naming](Documentation/Advanced.md#custom-file-naming)
|
||||
- [Command Line Interface](Documentation/Advanced.md#command-line-interface)
|
||||
- [Docker](Documentation/Docker.md)
|
||||
|
||||
## Getting started
|
||||
|
||||
|
||||
2
.github/workflows/scripts/targz2deb.sh → Scripts/targz2deb.sh
Normal file → Executable file
2
.github/workflows/scripts/targz2deb.sh → Scripts/targz2deb.sh
Normal file → Executable file
@@ -128,7 +128,7 @@ chmod +x "$FOLDER_DEBIAN/preinst"
|
||||
chmod +x "$FOLDER_DEBIAN/postinst"
|
||||
|
||||
echo "Creating .deb file..."
|
||||
dpkg-deb --build $FOLDER_MAIN
|
||||
dpkg-deb -Zxz --build $FOLDER_MAIN
|
||||
|
||||
rm -r "$FOLDER_MAIN"
|
||||
|
||||
@@ -13,7 +13,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.5.0" />
|
||||
<PackageReference Include="AAXClean.Codecs" Version="0.5.12" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean;
|
||||
using Dinah.Core.Net.Http;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
@@ -9,7 +9,23 @@ namespace AaxDecrypter
|
||||
{
|
||||
public event EventHandler<AppleTags> RetrievedMetadata;
|
||||
|
||||
protected AaxFile AaxFile;
|
||||
protected AaxFile AaxFile { get; private set; }
|
||||
private Mp4Operation aaxConversion;
|
||||
protected Mp4Operation AaxConversion
|
||||
{
|
||||
get => aaxConversion;
|
||||
set
|
||||
{
|
||||
if (aaxConversion is not null)
|
||||
aaxConversion.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
if (value is not null)
|
||||
{
|
||||
aaxConversion = value;
|
||||
aaxConversion.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
protected AaxcDownloadConvertBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
@@ -22,9 +38,23 @@ namespace AaxDecrypter
|
||||
AaxFile.AppleTags.Cover = coverArt;
|
||||
}
|
||||
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
await (AaxConversion?.CancelAsync() ?? Task.CompletedTask);
|
||||
FinalizeDownload();
|
||||
}
|
||||
|
||||
protected override void FinalizeDownload()
|
||||
{
|
||||
AaxConversion = null;
|
||||
base.FinalizeDownload();
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
AaxFile = new AaxFile(InputFileStream);
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
|
||||
if (DownloadOptions.StripUnabridged)
|
||||
{
|
||||
@@ -43,7 +73,6 @@ namespace AaxDecrypter
|
||||
DownloadOptions.Downsample,
|
||||
DownloadOptions.MatchSourceBitrate);
|
||||
|
||||
|
||||
OnRetrievedTitle(AaxFile.AppleTags.TitleSansUnabridged);
|
||||
OnRetrievedAuthors(AaxFile.AppleTags.FirstAuthor ?? "[unknown]");
|
||||
OnRetrievedNarrators(AaxFile.AppleTags.Narrator ?? "[unknown]");
|
||||
@@ -54,40 +83,15 @@ namespace AaxDecrypter
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected DownloadProgress Step_DownloadAudiobook_Start()
|
||||
private void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var zeroProgress = new DownloadProgress
|
||||
{
|
||||
BytesReceived = 0,
|
||||
ProgressPercentage = 0,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
};
|
||||
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
|
||||
AaxFile.SetDecryptionKey(DownloadOptions.AudibleKey, DownloadOptions.AudibleIV);
|
||||
return zeroProgress;
|
||||
}
|
||||
|
||||
protected void Step_DownloadAudiobook_End(DownloadProgress zeroProgress)
|
||||
{
|
||||
AaxFile.Close();
|
||||
|
||||
CloseInputFileStream();
|
||||
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
|
||||
protected void AaxFile_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = AaxFile.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var remainingSecsToProcess = (e.TotalDuration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
var progressPercent = (e.ProcessPosition / e.TotalDuration);
|
||||
var progressPercent = e.ProcessPosition / e.TotalDuration;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
@@ -97,14 +101,5 @@ namespace AaxDecrypter
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
}
|
||||
|
||||
public override async Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
if (AaxFile != null)
|
||||
await AaxFile.CancelAsync();
|
||||
AaxFile?.Dispose();
|
||||
CloseInputFileStream();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,81 +1,24 @@
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadMultiConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
private static TimeSpan minChapterLength { get; } = TimeSpan.FromSeconds(3);
|
||||
private List<string> multiPartFilePaths { get; } = new List<string>();
|
||||
private static readonly TimeSpan minChapterLength = TimeSpan.FromSeconds(3);
|
||||
private FileStream workingFileStream;
|
||||
|
||||
public AaxcDownloadMultiConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
public override async Task<bool> RunAsync()
|
||||
: base(outFileName, cacheDirectory, dlOptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
|
||||
//Step 1
|
||||
Serilog.Log.Information("Begin Get Aaxc Metadata");
|
||||
if (await Task.Run(Step_GetMetadata))
|
||||
Serilog.Log.Information("Completed Get Aaxc Metadata");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Get Aaxc Metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 2
|
||||
Serilog.Log.Information("Begin Download Decrypted Audiobook");
|
||||
if (await Step_DownloadAudiobookAsMultipleFilesPerChapter())
|
||||
Serilog.Log.Information("Completed Download Decrypted Audiobook");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Download Decrypted Audiobook");
|
||||
return false;
|
||||
}
|
||||
|
||||
|
||||
//Step 3
|
||||
if (DownloadOptions.DownloadClipsBookmarks)
|
||||
{
|
||||
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
|
||||
if (await Task.Run(Step_DownloadClipsBookmarks))
|
||||
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//Step 4
|
||||
Serilog.Log.Information("Begin Cleanup");
|
||||
if (await Task.Run(Step_Cleanup))
|
||||
Serilog.Log.Information("Completed Cleanup");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Cleanup");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
/*
|
||||
@@ -102,10 +45,8 @@ The book will be split into the following files:
|
||||
|
||||
That naming may not be desirable for everyone, but it's an easy change to instead use the last of the combined chapter's title in the file name.
|
||||
*/
|
||||
private async Task<bool> Step_DownloadAudiobookAsMultipleFilesPerChapter()
|
||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
var chapters = DownloadOptions.ChapterInfo.Chapters;
|
||||
|
||||
// Ensure split files are at least minChapterLength in duration.
|
||||
@@ -128,91 +69,79 @@ That naming may not be desirable for everyone, but it's an easy change to instea
|
||||
}
|
||||
}
|
||||
|
||||
// reset, just in case
|
||||
multiPartFilePaths.Clear();
|
||||
|
||||
try
|
||||
{
|
||||
ConversionResult result;
|
||||
await (AaxConversion = decryptMultiAsync(splitChapters));
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
if (DownloadOptions.OutputFormat == OutputFormat.M4b)
|
||||
result = await ConvertToMultiMp4a(splitChapters);
|
||||
else
|
||||
result = await ConvertToMultiMp3(splitChapters);
|
||||
if (AaxConversion.IsCompletedSuccessfully)
|
||||
await moveMoovToBeginning(workingFileStream?.Name);
|
||||
|
||||
return result == ConversionResult.NoErrorsDetected;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "AAXClean Error");
|
||||
workingFileStream?.Close();
|
||||
FileUtility.SaferDelete(workingFileStream.Name);
|
||||
return false;
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
workingFileStream?.Dispose();
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private Task<ConversionResult> ConvertToMultiMp4a(ChapterInfo splitChapters)
|
||||
private Mp4Operation decryptMultiAsync(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp4aAsync
|
||||
return
|
||||
DownloadOptions.OutputFormat == OutputFormat.M4b
|
||||
? AaxFile.ConvertToMultiMp4aAsync
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
);
|
||||
}
|
||||
|
||||
private Task<ConversionResult> ConvertToMultiMp3(ChapterInfo splitChapters)
|
||||
{
|
||||
var chapterCount = 0;
|
||||
return AaxFile.ConvertToMultiMp3Async
|
||||
)
|
||||
: AaxFile.ConvertToMultiMp3Async
|
||||
(
|
||||
splitChapters,
|
||||
newSplitCallback => Callback(++chapterCount, splitChapters, newSplitCallback),
|
||||
newSplitCallback => newSplit(++chapterCount, splitChapters, newSplitCallback),
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
);
|
||||
}
|
||||
|
||||
|
||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewMP3SplitCallback newSplitCallback)
|
||||
=> Callback(currentChapter, splitChapters, newSplitCallback as NewSplitCallback);
|
||||
|
||||
private void Callback(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
MultiConvertFileProperties props = new()
|
||||
void newSplit(int currentChapter, ChapterInfo splitChapters, NewSplitCallback newSplitCallback)
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
Title = newSplitCallback?.Chapter?.Title,
|
||||
};
|
||||
newSplitCallback.OutputFile = createOutputFileStream(props);
|
||||
newSplitCallback.TrackTitle = DownloadOptions.GetMultipartTitleName(props);
|
||||
newSplitCallback.TrackNumber = currentChapter;
|
||||
newSplitCallback.TrackCount = splitChapters.Count;
|
||||
MultiConvertFileProperties props = new()
|
||||
{
|
||||
OutputFileName = OutputFileName,
|
||||
PartsPosition = currentChapter,
|
||||
PartsTotal = splitChapters.Count,
|
||||
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);
|
||||
}
|
||||
|
||||
FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
{
|
||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||
FileUtility.SaferDelete(fileName);
|
||||
return File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
}
|
||||
}
|
||||
|
||||
private FileStream createOutputFileStream(MultiConvertFileProperties multiConvertFileProperties)
|
||||
private Mp4Operation moveMoovToBeginning(string filename)
|
||||
{
|
||||
var fileName = DownloadOptions.GetMultipartFileName(multiConvertFileProperties);
|
||||
var extension = Path.GetExtension(fileName);
|
||||
fileName = FileUtility.GetValidFilename(fileName, DownloadOptions.ReplacementCharacters, extension);
|
||||
|
||||
multiPartFilePaths.Add(fileName);
|
||||
|
||||
FileUtility.SaferDelete(fileName);
|
||||
|
||||
workingFileStream = File.Open(fileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(fileName);
|
||||
return workingFileStream;
|
||||
if (DownloadOptions.OutputFormat is OutputFormat.M4b
|
||||
&& DownloadOptions.MoveMoovToBeginning
|
||||
&& filename is not null
|
||||
&& File.Exists(filename))
|
||||
{
|
||||
return Mp4File.RelocateMoovAsync(filename);
|
||||
}
|
||||
else return Mp4Operation.CompletedOperation;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,137 +1,67 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using AAXClean;
|
||||
using AAXClean;
|
||||
using AAXClean.Codecs;
|
||||
using FileManager;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class AaxcDownloadSingleConverter : AaxcDownloadConvertBase
|
||||
{
|
||||
public AaxcDownloadSingleConverter(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
: base(outFileName, cacheDirectory, dlOptions) { }
|
||||
|
||||
public override async Task<bool> RunAsync()
|
||||
: base(outFileName, cacheDirectory, dlOptions)
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Information("Begin download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
|
||||
//Step 1
|
||||
Serilog.Log.Information("Begin Step 1: Get Aaxc Metadata");
|
||||
if (await Task.Run(Step_GetMetadata))
|
||||
Serilog.Log.Information("Completed Step 1: Get Aaxc Metadata");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 1: Get Aaxc Metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 2
|
||||
Serilog.Log.Information("Begin Step 2: Download Decrypted Audiobook");
|
||||
if (await Step_DownloadAudiobookAsSingleFile())
|
||||
Serilog.Log.Information("Completed Step 2: Download Decrypted Audiobook");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 2: Download Decrypted Audiobook");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 3
|
||||
Serilog.Log.Information("Begin Step 3: Create Cue");
|
||||
if (await Task.Run(Step_CreateCue))
|
||||
Serilog.Log.Information("Completed Step 3: Create Cue");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 3: Create Cue");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 4
|
||||
if (DownloadOptions.DownloadClipsBookmarks)
|
||||
{
|
||||
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
|
||||
if (await Task.Run(Step_DownloadClipsBookmarks))
|
||||
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//Step 5
|
||||
Serilog.Log.Information("Begin Step 4: Cleanup");
|
||||
if (await Task.Run(Step_Cleanup))
|
||||
Serilog.Log.Information("Completed Step 4: Cleanup");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 4: Cleanup");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return false;
|
||||
}
|
||||
AsyncSteps.Name = $"Download and Convert Aaxc To {DownloadOptions.OutputFormat}";
|
||||
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;
|
||||
AsyncSteps["Step 4: Create Cue"] = Step_CreateCueAsync;
|
||||
}
|
||||
|
||||
private async Task<bool> Step_DownloadAudiobookAsSingleFile()
|
||||
protected async override Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
var zeroProgress = Step_DownloadAudiobook_Start();
|
||||
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
|
||||
var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
using var outputFile = File.Open(OutputFileName, FileMode.OpenOrCreate, FileAccess.ReadWrite);
|
||||
OnFileCreated(OutputFileName);
|
||||
|
||||
AaxFile.ConversionProgressUpdate += AaxFile_ConversionProgressUpdate;
|
||||
|
||||
try
|
||||
{
|
||||
ConversionResult decryptionResult = await decryptAsync(outputFile);
|
||||
var success = decryptionResult == ConversionResult.NoErrorsDetected && !IsCanceled;
|
||||
if (success)
|
||||
base.OnFileCreated(OutputFileName);
|
||||
await (AaxConversion = decryptAsync(outputFile));
|
||||
|
||||
return success;
|
||||
}
|
||||
catch(Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "AAXClean Error");
|
||||
FileUtility.SaferDelete(OutputFileName);
|
||||
return false;
|
||||
if (AaxConversion.IsCompletedSuccessfully
|
||||
&& DownloadOptions.MoveMoovToBeginning
|
||||
&& DownloadOptions.OutputFormat is OutputFormat.M4b)
|
||||
{
|
||||
outputFile.Close();
|
||||
await (AaxConversion = Mp4File.RelocateMoovAsync(OutputFileName));
|
||||
}
|
||||
|
||||
return AaxConversion.IsCompletedSuccessfully;
|
||||
}
|
||||
finally
|
||||
{
|
||||
outputFile.Close();
|
||||
AaxFile.ConversionProgressUpdate -= AaxFile_ConversionProgressUpdate;
|
||||
|
||||
Step_DownloadAudiobook_End(zeroProgress);
|
||||
FinalizeDownload();
|
||||
}
|
||||
}
|
||||
|
||||
private Task<ConversionResult> decryptAsync(Stream outputFile)
|
||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3 ?
|
||||
AaxFile.ConvertToMp3Async
|
||||
private Mp4Operation decryptAsync(Stream outputFile)
|
||||
=> DownloadOptions.OutputFormat == OutputFormat.Mp3
|
||||
? AaxFile.ConvertToMp3Async
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.LameConfig,
|
||||
DownloadOptions.ChapterInfo,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
)
|
||||
: DownloadOptions.FixupFile ?
|
||||
AaxFile.ConvertToMp4aAsync
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.ChapterInfo,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
)
|
||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||
: DownloadOptions.FixupFile
|
||||
? AaxFile.ConvertToMp4aAsync
|
||||
(
|
||||
outputFile,
|
||||
DownloadOptions.ChapterInfo,
|
||||
DownloadOptions.TrimOutputToChapterLength
|
||||
)
|
||||
: AaxFile.ConvertToMp4aAsync(outputFile);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
using System;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.StepRunner;
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.IO;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
@@ -19,19 +20,16 @@ namespace AaxDecrypter
|
||||
public event EventHandler<TimeSpan> DecryptTimeRemaining;
|
||||
public event EventHandler<string> FileCreated;
|
||||
|
||||
public bool IsCanceled { get; set; }
|
||||
public string TempFilePath { get; }
|
||||
|
||||
protected string OutputFileName { get; private set; }
|
||||
public bool IsCanceled { get; protected set; }
|
||||
protected AsyncStepSequence AsyncSteps { get; } = new();
|
||||
protected string OutputFileName { get; }
|
||||
protected IDownloadOptions DownloadOptions { get; }
|
||||
protected NetworkFileStream InputFileStream => (nfsPersister ??= OpenNetworkFileStream()).NetworkFileStream;
|
||||
protected NetworkFileStream InputFileStream => nfsPersister.NetworkFileStream;
|
||||
|
||||
// Don't give the property a 'set'. This should have to be an obvious choice; not accidental
|
||||
protected void SetOutputFileName(string newOutputFileName) => OutputFileName = newOutputFileName;
|
||||
|
||||
private NetworkFileStreamPersister nfsPersister;
|
||||
|
||||
private string jsonDownloadState { get; }
|
||||
private readonly NetworkFileStreamPersister nfsPersister;
|
||||
private readonly DownloadProgress zeroProgress;
|
||||
private readonly string jsonDownloadState;
|
||||
private readonly string tempFilePath;
|
||||
|
||||
protected AudiobookDownloadBase(string outFileName, string cacheDirectory, IDownloadOptions dlOptions)
|
||||
{
|
||||
@@ -45,16 +43,39 @@ namespace AaxDecrypter
|
||||
Directory.CreateDirectory(cacheDirectory);
|
||||
|
||||
jsonDownloadState = Path.Combine(cacheDirectory, Path.GetFileName(Path.ChangeExtension(OutputFileName, ".json")));
|
||||
TempFilePath = Path.ChangeExtension(jsonDownloadState, ".aaxc");
|
||||
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,
|
||||
ProgressPercentage = 0,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
};
|
||||
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
|
||||
public async Task<bool> RunAsync()
|
||||
{
|
||||
AsyncSteps[$"Cleanup"] = CleanupAsync;
|
||||
(bool success, var elapsed) = await AsyncSteps.RunAsync();
|
||||
|
||||
var speedup = DownloadOptions.RuntimeLength / elapsed;
|
||||
Serilog.Log.Information($"Speedup is {speedup:F0}x realtime.");
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
public abstract Task CancelAsync();
|
||||
protected abstract Task<bool> Step_DownloadAndDecryptAudiobookAsync();
|
||||
|
||||
public virtual void SetCoverArt(byte[] coverArt)
|
||||
{
|
||||
@@ -62,8 +83,6 @@ namespace AaxDecrypter
|
||||
OnRetrievedCoverArt(coverArt);
|
||||
}
|
||||
|
||||
public abstract Task<bool> RunAsync();
|
||||
|
||||
protected void OnRetrievedTitle(string title)
|
||||
=> RetrievedTitle?.Invoke(this, title);
|
||||
protected void OnRetrievedAuthors(string authors)
|
||||
@@ -79,69 +98,66 @@ namespace AaxDecrypter
|
||||
protected void OnFileCreated(string path)
|
||||
=> FileCreated?.Invoke(this, path);
|
||||
|
||||
protected void CloseInputFileStream()
|
||||
protected virtual void FinalizeDownload()
|
||||
{
|
||||
nfsPersister?.NetworkFileStream?.Close();
|
||||
nfsPersister?.Dispose();
|
||||
OnDecryptProgressUpdate(zeroProgress);
|
||||
}
|
||||
|
||||
protected bool Step_CreateCue()
|
||||
protected async Task<bool> Step_DownloadClipsBookmarksAsync()
|
||||
{
|
||||
if (!DownloadOptions.CreateCueSheet) return true;
|
||||
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;
|
||||
|
||||
// not a critical step. its failure should not prevent future steps from running
|
||||
try
|
||||
{
|
||||
var path = Path.ChangeExtension(OutputFileName, ".cue");
|
||||
path = FileUtility.GetValidFilename(path, DownloadOptions.ReplacementCharacters, ".cue");
|
||||
File.WriteAllText(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||
await File.WriteAllTextAsync(path, Cue.CreateContents(Path.GetFileName(OutputFileName), DownloadOptions.ChapterInfo));
|
||||
OnFileCreated(path);
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCue)}. FAILED");
|
||||
Serilog.Log.Logger.Error(ex, $"{nameof(Step_CreateCueAsync)} Failed");
|
||||
}
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
protected bool Step_Cleanup()
|
||||
private async Task<bool> CleanupAsync()
|
||||
{
|
||||
bool success = !IsCanceled;
|
||||
if (success)
|
||||
if (IsCanceled) return false;
|
||||
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
|
||||
if (!string.IsNullOrEmpty(DownloadOptions.AudibleKey) &&
|
||||
!string.IsNullOrEmpty(DownloadOptions.AudibleIV) &&
|
||||
DownloadOptions.RetainEncryptedFile)
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
string aaxPath = Path.ChangeExtension(tempFilePath, ".aax");
|
||||
FileUtility.SaferMove(tempFilePath, aaxPath);
|
||||
|
||||
if (DownloadOptions.AudibleKey is not null &&
|
||||
DownloadOptions.AudibleIV is not null &&
|
||||
DownloadOptions.RetainEncryptedFile)
|
||||
{
|
||||
string aaxPath = Path.ChangeExtension(TempFilePath, ".aax");
|
||||
FileUtility.SaferMove(TempFilePath, aaxPath);
|
||||
//Write aax decryption key
|
||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||
FileUtility.SaferDelete(keyPath);
|
||||
await File.WriteAllTextAsync(keyPath, $"Key={DownloadOptions.AudibleKey}{Environment.NewLine}IV={DownloadOptions.AudibleIV}");
|
||||
|
||||
//Write aax decryption key
|
||||
string keyPath = Path.ChangeExtension(aaxPath, ".key");
|
||||
FileUtility.SaferDelete(keyPath);
|
||||
File.WriteAllText(keyPath, $"Key={DownloadOptions.AudibleKey}\r\nIV={DownloadOptions.AudibleIV}");
|
||||
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
}
|
||||
else
|
||||
FileUtility.SaferDelete(TempFilePath);
|
||||
OnFileCreated(aaxPath);
|
||||
OnFileCreated(keyPath);
|
||||
}
|
||||
else
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
|
||||
return success;
|
||||
}
|
||||
|
||||
protected async Task<bool> Step_DownloadClipsBookmarks()
|
||||
{
|
||||
if (!IsCanceled && DownloadOptions.DownloadClipsBookmarks)
|
||||
{
|
||||
var recordsFile = await DownloadOptions.SaveClipsAndBookmarks(OutputFileName);
|
||||
|
||||
if (File.Exists(recordsFile))
|
||||
OnFileCreated(recordsFile);
|
||||
}
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
@@ -151,31 +167,30 @@ namespace AaxDecrypter
|
||||
try
|
||||
{
|
||||
if (!File.Exists(jsonDownloadState))
|
||||
return nfsp = NewNetworkFilePersister();
|
||||
return nfsp = newNetworkFilePersister();
|
||||
|
||||
nfsp = new NetworkFileStreamPersister(jsonDownloadState);
|
||||
// If More than ~1 hour has elapsed since getting the download url, it will expire.
|
||||
// The new url will be to the same file.
|
||||
// The download url expires after 1 hour.
|
||||
// The new url points to the same file.
|
||||
nfsp.NetworkFileStream.SetUriForSameFile(new Uri(DownloadOptions.DownloadUrl));
|
||||
return nfsp;
|
||||
}
|
||||
catch
|
||||
{
|
||||
FileUtility.SaferDelete(jsonDownloadState);
|
||||
FileUtility.SaferDelete(TempFilePath);
|
||||
return nfsp = NewNetworkFilePersister();
|
||||
FileUtility.SaferDelete(tempFilePath);
|
||||
return nfsp = newNetworkFilePersister();
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (nfsp?.NetworkFileStream is not null)
|
||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||
nfsp.NetworkFileStream.SpeedLimit = DownloadOptions.DownloadSpeedBps;
|
||||
}
|
||||
}
|
||||
|
||||
private NetworkFileStreamPersister NewNetworkFilePersister()
|
||||
{
|
||||
var networkFileStream = new NetworkFileStream(TempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
NetworkFileStreamPersister newNetworkFilePersister()
|
||||
{
|
||||
var networkFileStream = new NetworkFileStream(tempFilePath, new Uri(DownloadOptions.DownloadUrl), 0, new() { { "User-Agent", DownloadOptions.UserAgent } });
|
||||
return new NetworkFileStreamPersister(networkFileStream, jsonDownloadState);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
using System;
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
using System.IO;
|
||||
using System.Text;
|
||||
using AAXClean;
|
||||
using Dinah.Core;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
@@ -16,15 +15,14 @@ namespace AaxDecrypter
|
||||
|
||||
var startOffset = chapters.StartOffset;
|
||||
|
||||
var trackCount = 0;
|
||||
var trackCount = 1;
|
||||
foreach (var c in chapters.Chapters)
|
||||
{
|
||||
var startTime = c.StartOffset - startOffset;
|
||||
trackCount++;
|
||||
|
||||
stringBuilder.AppendLine($"TRACK {trackCount} AUDIO");
|
||||
stringBuilder.AppendLine($"TRACK {trackCount++} AUDIO");
|
||||
stringBuilder.AppendLine($" TITLE \"{c.Title}\"");
|
||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds / 1000d * 75)}");
|
||||
stringBuilder.AppendLine($" INDEX 01 {(int)startTime.TotalMinutes}:{startTime:ss}:{(int)(startTime.Milliseconds * 75d / 1000):D2}");
|
||||
}
|
||||
|
||||
return stringBuilder.ToString();
|
||||
@@ -46,7 +44,7 @@ namespace AaxDecrypter
|
||||
for (var i = 0; i < cueContents.Length; i++)
|
||||
{
|
||||
var line = cueContents[i];
|
||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(" "))
|
||||
if (!line.Trim().StartsWith("FILE") || !line.Contains(' '))
|
||||
continue;
|
||||
|
||||
var fileTypeBegins = line.LastIndexOf(" ") + 1;
|
||||
|
||||
@@ -5,13 +5,13 @@ using System.Threading.Tasks;
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public interface IDownloadOptions
|
||||
{
|
||||
{
|
||||
event EventHandler<long> DownloadSpeedChanged;
|
||||
FileManager.ReplacementCharacters ReplacementCharacters { get; }
|
||||
string DownloadUrl { get; }
|
||||
string UserAgent { get; }
|
||||
string AudibleKey { get; }
|
||||
string AudibleIV { get; }
|
||||
TimeSpan RuntimeLength { get; }
|
||||
OutputFormat OutputFormat { get; }
|
||||
bool TrimOutputToChapterLength { get; }
|
||||
bool RetainEncryptedFile { get; }
|
||||
@@ -24,8 +24,9 @@ namespace AaxDecrypter
|
||||
NAudio.Lame.LameConfig LameConfig { get; }
|
||||
bool Downsample { get; }
|
||||
bool MatchSourceBitrate { get; }
|
||||
bool MoveMoovToBeginning { get; }
|
||||
string GetMultipartFileName(MultiConvertFileProperties props);
|
||||
string GetMultipartTitleName(MultiConvertFileProperties props);
|
||||
Task<string> SaveClipsAndBookmarks(string fileName);
|
||||
}
|
||||
string GetMultipartTitle(MultiConvertFileProperties props);
|
||||
Task<string> SaveClipsAndBookmarksAsync(string fileName);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
using AAXClean;
|
||||
using NAudio.Lame;
|
||||
using System;
|
||||
using System.Linq;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
|
||||
@@ -1,6 +1,4 @@
|
||||
using System;
|
||||
using System.IO;
|
||||
using FileManager;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
@@ -10,6 +8,6 @@ namespace AaxDecrypter
|
||||
public int PartsPosition { get; set; }
|
||||
public int PartsTotal { get; set; }
|
||||
public string Title { get; set; }
|
||||
|
||||
public DateTime FileDate { get; } = DateTime.Now;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using Newtonsoft.Json;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
using System.Net;
|
||||
using System.Net.Http;
|
||||
using System.Threading;
|
||||
@@ -83,16 +82,13 @@ namespace AaxDecrypter
|
||||
/// <param name="requestHeaders">Http headers to be sent to the server with the <see cref="HttpWebRequest"/>.</param>
|
||||
public NetworkFileStream(string saveFilePath, Uri uri, long writePosition = 0, Dictionary<string, string> requestHeaders = null)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(uri?.AbsoluteUri, nameof(uri));
|
||||
ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||
SaveFilePath = ArgumentValidator.EnsureNotNullOrWhiteSpace(saveFilePath, nameof(saveFilePath));
|
||||
Uri = ArgumentValidator.EnsureNotNull(uri, nameof(uri));
|
||||
WritePosition = ArgumentValidator.EnsureGreaterThan(writePosition, nameof(writePosition), -1);
|
||||
|
||||
if (!Directory.Exists(Path.GetDirectoryName(saveFilePath)))
|
||||
throw new ArgumentException($"Specified {nameof(saveFilePath)} directory \"{Path.GetDirectoryName(saveFilePath)}\" does not exist.");
|
||||
|
||||
SaveFilePath = saveFilePath;
|
||||
Uri = uri;
|
||||
WritePosition = writePosition;
|
||||
RequestHeaders = requestHeaders ?? new();
|
||||
|
||||
_writeFile = new FileStream(SaveFilePath, FileMode.OpenOrCreate, FileAccess.Write, FileShare.ReadWrite)
|
||||
@@ -109,8 +105,8 @@ namespace AaxDecrypter
|
||||
|
||||
#region Downloader
|
||||
|
||||
/// <summary> Update the <see cref="JsonFilePersister"/>. </summary>
|
||||
private void Update()
|
||||
/// <summary> Update the <see cref="Dinah.Core.IO.JsonFilePersister{T}"/>. </summary>
|
||||
private void OnUpdate()
|
||||
{
|
||||
RequestHeaders["Range"] = $"bytes={WritePosition}-";
|
||||
try
|
||||
@@ -167,7 +163,7 @@ namespace AaxDecrypter
|
||||
_downloadedPiece = new EventWaitHandle(false, EventResetMode.AutoReset);
|
||||
|
||||
//Download the file in the background.
|
||||
return Task.Run(async () => await DownloadFile(networkStream), _cancellationSource.Token);
|
||||
return Task.Run(() => DownloadFile(networkStream), _cancellationSource.Token);
|
||||
}
|
||||
|
||||
/// <summary> Download <see cref="Uri"/> to <see cref="SaveFilePath"/>.</summary>
|
||||
@@ -184,7 +180,7 @@ namespace AaxDecrypter
|
||||
int bytesRead;
|
||||
do
|
||||
{
|
||||
bytesRead = await networkStream.ReadAsync(buff, 0, DOWNLOAD_BUFF_SZ, _cancellationSource.Token);
|
||||
bytesRead = await networkStream.ReadAsync(buff, _cancellationSource.Token);
|
||||
await _writeFile.WriteAsync(buff, 0, bytesRead, _cancellationSource.Token);
|
||||
|
||||
downloadPosition += bytesRead;
|
||||
@@ -193,7 +189,7 @@ namespace AaxDecrypter
|
||||
{
|
||||
await _writeFile.FlushAsync(_cancellationSource.Token);
|
||||
WritePosition = downloadPosition;
|
||||
Update();
|
||||
OnUpdate();
|
||||
nextFlush = downloadPosition + DATA_FLUSH_SZ;
|
||||
_downloadedPiece.Set();
|
||||
}
|
||||
@@ -233,19 +229,12 @@ namespace AaxDecrypter
|
||||
networkStream.Close();
|
||||
_writeFile.Close();
|
||||
_downloadedPiece.Set();
|
||||
Update();
|
||||
OnUpdate();
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
|
||||
#region Json Connverters
|
||||
|
||||
public static JsonSerializerSettings GetJsonSerializerSettings()
|
||||
=> new JsonSerializerSettings();
|
||||
|
||||
#endregion
|
||||
|
||||
#region Download Stream Reader
|
||||
|
||||
[JsonIgnore]
|
||||
@@ -289,7 +278,7 @@ namespace AaxDecrypter
|
||||
|
||||
var toRead = Math.Min(count, Length - Position);
|
||||
WaitToPosition(Position + toRead);
|
||||
return IsCancelled ? 0: _readFile.Read(buffer, offset, count);
|
||||
return IsCancelled ? 0 : _readFile.Read(buffer, offset, count);
|
||||
}
|
||||
|
||||
public override long Seek(long offset, SeekOrigin origin)
|
||||
@@ -306,7 +295,7 @@ namespace AaxDecrypter
|
||||
}
|
||||
|
||||
/// <summary>Blocks until the file has downloaded to at least <paramref name="requiredPosition"/>, then returns. </summary>
|
||||
/// <param name="requiredPosition">The minimum required flished data length in <see cref="SaveFilePath"/>.</param>
|
||||
/// <param name="requiredPosition">The minimum required flushed data length in <see cref="SaveFilePath"/>.</param>
|
||||
private void WaitToPosition(long requiredPosition)
|
||||
{
|
||||
while (WritePosition < requiredPosition
|
||||
@@ -317,20 +306,31 @@ namespace AaxDecrypter
|
||||
}
|
||||
}
|
||||
|
||||
public override void Close()
|
||||
{
|
||||
_cancellationSource.Cancel();
|
||||
_backgroundDownloadTask?.Wait();
|
||||
private bool disposed = false;
|
||||
|
||||
_readFile.Close();
|
||||
_writeFile.Close();
|
||||
Update();
|
||||
/*
|
||||
* https://learn.microsoft.com/en-us/dotnet/api/system.io.stream.dispose?view=net-7.0
|
||||
*
|
||||
* In derived classes, do not override the Close() method, instead, put all of the
|
||||
* Stream cleanup logic in the Dispose(Boolean) method.
|
||||
*/
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing && !disposed)
|
||||
{
|
||||
_cancellationSource.Cancel();
|
||||
_backgroundDownloadTask?.GetAwaiter().GetResult();
|
||||
_downloadedPiece?.Dispose();
|
||||
_cancellationSource?.Dispose();
|
||||
_readFile.Dispose();
|
||||
_writeFile.Dispose();
|
||||
OnUpdate();
|
||||
}
|
||||
|
||||
disposed = true;
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
|
||||
#endregion
|
||||
~NetworkFileStream()
|
||||
{
|
||||
_downloadedPiece?.Close();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
using Dinah.Core.IO;
|
||||
using Newtonsoft.Json;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
||||
{
|
||||
|
||||
internal class NetworkFileStreamPersister : JsonFilePersister<NetworkFileStream>
|
||||
{
|
||||
/// <summary>Alias for Target </summary>
|
||||
public NetworkFileStream NetworkFileStream => Target;
|
||||
|
||||
@@ -17,7 +15,11 @@ namespace AaxDecrypter
|
||||
public NetworkFileStreamPersister(string path, string jsonPath = null)
|
||||
: base(path, jsonPath) { }
|
||||
|
||||
protected override JsonSerializerSettings GetSerializerSettings() => NetworkFileStream.GetJsonSerializerSettings();
|
||||
|
||||
}
|
||||
protected override void Dispose(bool disposing)
|
||||
{
|
||||
if (disposing)
|
||||
NetworkFileStream?.Dispose();
|
||||
base.Dispose(disposing);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,97 +1,35 @@
|
||||
using System;
|
||||
using System.Threading;
|
||||
using System.Threading.Tasks;
|
||||
using Dinah.Core.Net.Http;
|
||||
using Dinah.Core.Net.Http;
|
||||
using FileManager;
|
||||
using System;
|
||||
using System.Threading.Tasks;
|
||||
|
||||
namespace AaxDecrypter
|
||||
{
|
||||
public class UnencryptedAudiobookDownloader : AudiobookDownloadBase
|
||||
{
|
||||
|
||||
public UnencryptedAudiobookDownloader(string outFileName, string cacheDirectory, IDownloadOptions dlLic)
|
||||
: base(outFileName, cacheDirectory, dlLic) { }
|
||||
|
||||
public override async Task<bool> RunAsync()
|
||||
: base(outFileName, cacheDirectory, dlLic)
|
||||
{
|
||||
try
|
||||
{
|
||||
Serilog.Log.Information("Begin downloading unencrypted audiobook.");
|
||||
|
||||
//Step 1
|
||||
Serilog.Log.Information("Begin Step 1: Get Mp3 Metadata");
|
||||
if (await Task.Run(Step_GetMetadata))
|
||||
Serilog.Log.Information("Completed Step 1: Get Mp3 Metadata");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 1: Get Mp3 Metadata");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 2
|
||||
Serilog.Log.Information("Begin Step 2: Download Audiobook");
|
||||
if (await Task.Run(Step_DownloadAudiobookAsSingleFile))
|
||||
Serilog.Log.Information("Completed Step 2: Download Audiobook");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 2: Download Audiobook");
|
||||
return false;
|
||||
}
|
||||
|
||||
//Step 3
|
||||
if (DownloadOptions.DownloadClipsBookmarks)
|
||||
{
|
||||
Serilog.Log.Information("Begin Downloading Clips and Bookmarks");
|
||||
if (await Task.Run(Step_DownloadClipsBookmarks))
|
||||
Serilog.Log.Information("Completed Downloading Clips and Bookmarks");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Download Clips and Bookmarks");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
//Step 4
|
||||
Serilog.Log.Information("Begin Step 3: Cleanup");
|
||||
if (await Task.Run(Step_Cleanup))
|
||||
Serilog.Log.Information("Completed Step 3: Cleanup");
|
||||
else
|
||||
{
|
||||
Serilog.Log.Information("Failed to Complete Step 3: Cleanup");
|
||||
return false;
|
||||
}
|
||||
|
||||
Serilog.Log.Information("Completed download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return true;
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
Serilog.Log.Error(ex, "Error encountered in download and convert Aaxc To {format}", DownloadOptions.OutputFormat);
|
||||
return false;
|
||||
}
|
||||
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;
|
||||
}
|
||||
|
||||
public override Task CancelAsync()
|
||||
{
|
||||
IsCanceled = true;
|
||||
CloseInputFileStream();
|
||||
FinalizeDownload();
|
||||
return Task.CompletedTask;
|
||||
}
|
||||
|
||||
protected bool Step_GetMetadata()
|
||||
{
|
||||
OnRetrievedCoverArt(null);
|
||||
|
||||
return !IsCanceled;
|
||||
}
|
||||
|
||||
private bool Step_DownloadAudiobookAsSingleFile()
|
||||
protected override async Task<bool> Step_DownloadAndDecryptAudiobookAsync()
|
||||
{
|
||||
DateTime startTime = DateTime.Now;
|
||||
|
||||
// MUST put InputFileStream.Length first, because it starts background downloader.
|
||||
|
||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
||||
while (InputFileStream.Length > InputFileStream.WritePosition && !InputFileStream.IsCancelled)
|
||||
{
|
||||
var rate = InputFileStream.WritePosition / (DateTime.Now - startTime).TotalSeconds;
|
||||
|
||||
@@ -100,25 +38,28 @@ namespace AaxDecrypter
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnDecryptTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
var progressPercent = (double)InputFileStream.WritePosition / InputFileStream.Length;
|
||||
var progressPercent = 100d * InputFileStream.WritePosition / InputFileStream.Length;
|
||||
|
||||
OnDecryptProgressUpdate(
|
||||
new DownloadProgress
|
||||
{
|
||||
ProgressPercentage = 100 * progressPercent,
|
||||
BytesReceived = (long)(InputFileStream.Length * progressPercent),
|
||||
ProgressPercentage = progressPercent,
|
||||
BytesReceived = InputFileStream.WritePosition,
|
||||
TotalBytesToReceive = InputFileStream.Length
|
||||
});
|
||||
Thread.Sleep(200);
|
||||
|
||||
await Task.Delay(200);
|
||||
}
|
||||
|
||||
CloseInputFileStream();
|
||||
|
||||
var realOutputFileName = FileUtility.SaferMoveToValidPath(InputFileStream.SaveFilePath, OutputFileName, DownloadOptions.ReplacementCharacters);
|
||||
SetOutputFileName(realOutputFileName);
|
||||
OnFileCreated(realOutputFileName);
|
||||
|
||||
return !IsCanceled;
|
||||
if (IsCanceled)
|
||||
return false;
|
||||
else
|
||||
{
|
||||
FinalizeDownload();
|
||||
FileUtility.SaferMove(InputFileStream.SaveFilePath, OutputFileName);
|
||||
OnFileCreated(OutputFileName);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@
|
||||
<Project Sdk="Microsoft.NET.Sdk">
|
||||
<PropertyGroup>
|
||||
<TargetFramework>net7.0</TargetFramework>
|
||||
<Version>9.0.1.1</Version>
|
||||
<Version>9.2.0.1</Version>
|
||||
</PropertyGroup>
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Octokit" Version="4.0.3" />
|
||||
<PackageReference Include="Octokit" Version="5.0.0" />
|
||||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\ApplicationServices\ApplicationServices.csproj" />
|
||||
|
||||
@@ -103,7 +103,10 @@ namespace ApplicationServices
|
||||
|
||||
[Name("Audio Format")]
|
||||
public string AudioFormat { get; set; }
|
||||
}
|
||||
|
||||
[Name("Language")]
|
||||
public string Language { get; set; }
|
||||
}
|
||||
public static class LibToDtos
|
||||
{
|
||||
public static List<ExportDto> ToDtos(this IEnumerable<LibraryBook> library)
|
||||
@@ -136,7 +139,8 @@ namespace ApplicationServices
|
||||
BookStatus = a.Book.UserDefinedItem.BookStatus.ToString(),
|
||||
PdfStatus = a.Book.UserDefinedItem.PdfStatus.ToString(),
|
||||
ContentType = a.Book.ContentType.ToString(),
|
||||
AudioFormat = a.Book.AudioFormat.ToString()
|
||||
AudioFormat = a.Book.AudioFormat.ToString(),
|
||||
Language = a.Book.Language
|
||||
}).ToList();
|
||||
}
|
||||
public static class LibraryExporter
|
||||
@@ -207,8 +211,9 @@ namespace ApplicationServices
|
||||
nameof(ExportDto.BookStatus),
|
||||
nameof(ExportDto.PdfStatus),
|
||||
nameof(ExportDto.ContentType),
|
||||
nameof(ExportDto.AudioFormat)
|
||||
};
|
||||
nameof(ExportDto.AudioFormat),
|
||||
nameof(ExportDto.Language)
|
||||
};
|
||||
var col = 0;
|
||||
foreach (var c in columns)
|
||||
{
|
||||
@@ -273,9 +278,10 @@ namespace ApplicationServices
|
||||
row.CreateCell(col++).SetCellValue(dto.BookStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.PdfStatus);
|
||||
row.CreateCell(col++).SetCellValue(dto.ContentType);
|
||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||
row.CreateCell(col++).SetCellValue(dto.AudioFormat);
|
||||
row.CreateCell(col++).SetCellValue(dto.Language);
|
||||
|
||||
rowIndex++;
|
||||
rowIndex++;
|
||||
}
|
||||
|
||||
using var fileData = new System.IO.FileStream(saveFilePath, System.IO.FileMode.Create);
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="AudibleApi" Version="7.3.1.1" />
|
||||
<PackageReference Include="AudibleApi" Version="7.3.2.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.1">
|
||||
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
|
||||
<PackageReference Include="Dinah.EntityFrameworkCore" Version="7.1.1.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="7.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.1">
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="7.0.2" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="7.0.2">
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
</PackageReference>
|
||||
|
||||
@@ -50,6 +50,7 @@ namespace DataLayer
|
||||
// book details
|
||||
public bool IsAbridged { get; private set; }
|
||||
public DateTime? DatePublished { get; private set; }
|
||||
public string Language { get; private set; }
|
||||
|
||||
// non-null. use "empty pattern"
|
||||
internal int CategoryId { get; private set; }
|
||||
@@ -215,11 +216,12 @@ namespace DataLayer
|
||||
public void UpdateProductRating(float overallRating, float performanceRating, float storyRating)
|
||||
=> Rating.Update(overallRating, performanceRating, storyRating);
|
||||
|
||||
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished)
|
||||
public void UpdateBookDetails(bool isAbridged, DateTime? datePublished, string language)
|
||||
{
|
||||
// don't overwrite with default values
|
||||
IsAbridged |= isAbridged;
|
||||
DatePublished = datePublished ?? DatePublished;
|
||||
Language = language?.FirstCharToUpper() ?? Language;
|
||||
}
|
||||
|
||||
public void UpdateCategory(Category category, DbContext context = null)
|
||||
|
||||
404
Source/DataLayer/Migrations/20230201162454_AddBookLanguage.Designer.cs
generated
Normal file
404
Source/DataLayer/Migrations/20230201162454_AddBookLanguage.Designer.cs
generated
Normal file
@@ -0,0 +1,404 @@
|
||||
// <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("20230201162454_AddBookLanguage")]
|
||||
partial class AddBookLanguage
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
b.Property<int>("BookId")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("AudibleProductId")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("CategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
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<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>("Title")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<long>("_audioFormat")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("BookId");
|
||||
|
||||
b.HasIndex("AudibleProductId");
|
||||
|
||||
b.HasIndex("CategoryId");
|
||||
|
||||
b.ToTable("Books");
|
||||
});
|
||||
|
||||
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.Property<int?>("ParentCategoryCategoryId")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.HasKey("CategoryId");
|
||||
|
||||
b.HasIndex("AudibleCategoryId");
|
||||
|
||||
b.HasIndex("ParentCategoryCategoryId");
|
||||
|
||||
b.ToTable("Categories");
|
||||
|
||||
b.HasData(
|
||||
new
|
||||
{
|
||||
CategoryId = -1,
|
||||
AudibleCategoryId = "",
|
||||
Name = ""
|
||||
});
|
||||
});
|
||||
|
||||
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<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("DataLayer.Book", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "Category")
|
||||
.WithMany()
|
||||
.HasForeignKey("CategoryId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
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<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("Category");
|
||||
|
||||
b.Navigation("Rating");
|
||||
|
||||
b.Navigation("Supplements");
|
||||
|
||||
b.Navigation("UserDefinedItem");
|
||||
});
|
||||
|
||||
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.Category", b =>
|
||||
{
|
||||
b.HasOne("DataLayer.Category", "ParentCategory")
|
||||
.WithMany()
|
||||
.HasForeignKey("ParentCategoryCategoryId");
|
||||
|
||||
b.Navigation("ParentCategory");
|
||||
});
|
||||
|
||||
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("ContributorsLink");
|
||||
|
||||
b.Navigation("SeriesLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Contributor", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("DataLayer.Series", b =>
|
||||
{
|
||||
b.Navigation("BooksLink");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace DataLayer.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class AddBookLanguage : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<string>(
|
||||
name: "Language",
|
||||
table: "Books",
|
||||
type: "TEXT",
|
||||
nullable: true);
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "Language",
|
||||
table: "Books");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ namespace DataLayer.Migrations
|
||||
protected override void BuildModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.0");
|
||||
modelBuilder.HasAnnotation("ProductVersion", "7.0.2");
|
||||
|
||||
modelBuilder.Entity("DataLayer.Book", b =>
|
||||
{
|
||||
@@ -41,6 +41,9 @@ namespace DataLayer.Migrations
|
||||
b.Property<bool>("IsAbridged")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
b.Property<string>("Language")
|
||||
.HasColumnType("TEXT");
|
||||
|
||||
b.Property<int>("LengthInMinutes")
|
||||
.HasColumnType("INTEGER");
|
||||
|
||||
|
||||
@@ -152,9 +152,9 @@ namespace DtoImporterService
|
||||
book.ReplacePublisher(publisher);
|
||||
}
|
||||
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished);
|
||||
book.UpdateBookDetails(item.IsAbridged, item.DatePublished, item.Language);
|
||||
|
||||
if (item.PdfUrl is not null)
|
||||
if (item.PdfUrl is not null)
|
||||
book.AddSupplementDownloadUrl(item.PdfUrl.ToString());
|
||||
|
||||
return book;
|
||||
@@ -174,7 +174,12 @@ namespace DtoImporterService
|
||||
if (item.PictureLarge is not null)
|
||||
book.PictureLarge = item.PictureLarge;
|
||||
|
||||
book.UpdateProductRating(
|
||||
// 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);
|
||||
|
||||
book.UpdateProductRating(
|
||||
(float)(item.Rating?.OverallDistribution?.AverageRating ?? 0),
|
||||
(float)(item.Rating?.PerformanceDistribution?.AverageRating ?? 0),
|
||||
(float)(item.Rating?.StoryDistribution?.AverageRating ?? 0));
|
||||
|
||||
@@ -15,12 +15,12 @@ namespace FileLiberator
|
||||
public class ConvertToMp3 : AudioDecodable
|
||||
{
|
||||
public override string Name => "Convert to Mp3";
|
||||
private Mp4File m4bBook;
|
||||
|
||||
private Mp4Operation Mp4Operation;
|
||||
private TimeSpan bookDuration;
|
||||
private long fileSize;
|
||||
private static string Mp3FileName(string m4bPath) => Path.ChangeExtension(m4bPath ?? "", ".mp3");
|
||||
|
||||
public override Task CancelAsync() => m4bBook?.CancelAsync() ?? Task.CompletedTask;
|
||||
public override Task CancelAsync() => Mp4Operation?.CancelAsync() ?? Task.CompletedTask;
|
||||
|
||||
public static bool ValidateMp3(LibraryBook libraryBook)
|
||||
{
|
||||
@@ -43,9 +43,9 @@ namespace FileLiberator
|
||||
var proposedMp3Path = Mp3FileName(m4bPath);
|
||||
if (File.Exists(proposedMp3Path) || !File.Exists(m4bPath)) continue;
|
||||
|
||||
m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||
m4bBook.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
var m4bBook = await Task.Run(() => new Mp4File(m4bPath, FileAccess.Read));
|
||||
|
||||
bookDuration = m4bBook.Duration;
|
||||
fileSize = m4bBook.InputStream.Length;
|
||||
|
||||
OnTitleDiscovered(m4bBook.AppleTags.Title);
|
||||
@@ -66,20 +66,20 @@ namespace FileLiberator
|
||||
using var mp3File = File.OpenWrite(Path.GetTempFileName());
|
||||
try
|
||||
{
|
||||
var result = await m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
||||
Mp4Operation = m4bBook.ConvertToMp3Async(mp3File, lameConfig);
|
||||
Mp4Operation.ConversionProgressUpdate += M4bBook_ConversionProgressUpdate;
|
||||
await Mp4Operation;
|
||||
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters);
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
|
||||
if (result == ConversionResult.Failed)
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
}
|
||||
else if (result == ConversionResult.Cancelled)
|
||||
if (Mp4Operation.IsCanceled)
|
||||
{
|
||||
FileUtility.SaferDelete(mp3File.Name);
|
||||
return new StatusHandler { "Cancelled" };
|
||||
}
|
||||
else
|
||||
{
|
||||
var realMp3Path = FileUtility.SaferMoveToValidPath(mp3File.Name, proposedMp3Path, Configuration.Instance.ReplacementCharacters, "mp3");
|
||||
OnFileCreated(libraryBook, realMp3Path);
|
||||
}
|
||||
}
|
||||
catch (Exception ex)
|
||||
{
|
||||
@@ -88,6 +88,9 @@ namespace FileLiberator
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (Mp4Operation is not null)
|
||||
Mp4Operation.ConversionProgressUpdate -= M4bBook_ConversionProgressUpdate;
|
||||
|
||||
m4bBook.InputStream.Close();
|
||||
mp3File.Close();
|
||||
}
|
||||
@@ -102,14 +105,13 @@ namespace FileLiberator
|
||||
|
||||
private void M4bBook_ConversionProgressUpdate(object sender, ConversionProgressEventArgs e)
|
||||
{
|
||||
var duration = m4bBook.Duration;
|
||||
var remainingSecsToProcess = (duration - e.ProcessPosition).TotalSeconds;
|
||||
var remainingSecsToProcess = (bookDuration - e.ProcessPosition).TotalSeconds;
|
||||
var estTimeRemaining = remainingSecsToProcess / e.ProcessSpeed;
|
||||
|
||||
|
||||
if (double.IsNormal(estTimeRemaining))
|
||||
OnStreamingTimeRemaining(TimeSpan.FromSeconds(estTimeRemaining));
|
||||
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / duration.TotalSeconds;
|
||||
double progressPercent = 100 * e.ProcessPosition.TotalSeconds / bookDuration.TotalSeconds;
|
||||
|
||||
OnStreamingProgressChanged(
|
||||
new DownloadProgress
|
||||
|
||||
@@ -5,7 +5,6 @@ using System.Linq;
|
||||
using System.Threading.Tasks;
|
||||
using AaxDecrypter;
|
||||
using ApplicationServices;
|
||||
using AudibleApi;
|
||||
using DataLayer;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.ErrorHandling;
|
||||
@@ -138,40 +137,27 @@ namespace FileLiberator
|
||||
|
||||
private DownloadOptions BuildDownloadOptions(LibraryBook libraryBook, Configuration config, AudibleApi.Common.ContentLicense contentLic)
|
||||
{
|
||||
//I assume if ContentFormat == "MPEG" that the delivered file is an unencrypted mp3.
|
||||
//I also assume that if DrmType != Adrm, the file will be an mp3.
|
||||
//These assumptions may be wrong, and only time and bug reports will tell.
|
||||
//If DrmType != Adrm the delivered file is an unencrypted mp3.
|
||||
|
||||
bool encrypted = contentLic.DrmType == AudibleApi.Common.DrmType.Adrm;
|
||||
var outputFormat
|
||||
= contentLic.DrmType != AudibleApi.Common.DrmType.Adrm || (config.AllowLibationFixup && config.DecryptToLossy)
|
||||
? OutputFormat.Mp3
|
||||
: OutputFormat.M4b;
|
||||
|
||||
var outputFormat = !encrypted || (config.AllowLibationFixup && config.DecryptToLossy) ?
|
||||
OutputFormat.Mp3 : OutputFormat.M4b;
|
||||
long chapterStartMs
|
||||
= config.StripAudibleBrandAudio
|
||||
? contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs
|
||||
: 0;
|
||||
|
||||
long chapterStartMs = config.StripAudibleBrandAudio ?
|
||||
contentLic.ContentMetadata.ChapterInfo.BrandIntroDurationMs : 0;
|
||||
|
||||
var dlOptions = new DownloadOptions
|
||||
(
|
||||
libraryBook,
|
||||
contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl,
|
||||
Resources.USER_AGENT
|
||||
)
|
||||
var dlOptions = new DownloadOptions(config, libraryBook, contentLic?.ContentMetadata?.ContentUrl?.OfflineUrl)
|
||||
{
|
||||
AudibleKey = contentLic?.Voucher?.Key,
|
||||
AudibleIV = contentLic?.Voucher?.Iv,
|
||||
OutputFormat = outputFormat,
|
||||
TrimOutputToChapterLength = config.AllowLibationFixup && config.StripAudibleBrandAudio,
|
||||
RetainEncryptedFile = config.RetainAaxFile && encrypted,
|
||||
StripUnabridged = config.AllowLibationFixup && config.StripUnabridged,
|
||||
Downsample = config.AllowLibationFixup && config.LameDownsampleMono,
|
||||
MatchSourceBitrate = config.AllowLibationFixup && config.LameMatchSourceBR && config.LameTargetBitrate,
|
||||
CreateCueSheet = config.CreateCueSheet,
|
||||
DownloadClipsBookmarks = config.DownloadClipsBookmarks,
|
||||
DownloadSpeedBps = config.DownloadSpeedLimit,
|
||||
LameConfig = GetLameOptions(config),
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
FixupFile = config.AllowLibationFixup
|
||||
};
|
||||
OutputFormat = outputFormat,
|
||||
LameConfig = GetLameOptions(config),
|
||||
ChapterInfo = new AAXClean.ChapterInfo(TimeSpan.FromMilliseconds(chapterStartMs)),
|
||||
RuntimeLength = TimeSpan.FromMilliseconds(contentLic?.ContentMetadata?.ChapterInfo?.RuntimeLengthMs ?? 0),
|
||||
};
|
||||
|
||||
var chapters = flattenChapters(contentLic.ContentMetadata.ChapterInfo.Chapters).OrderBy(c => c.StartOffsetMs).ToList();
|
||||
|
||||
@@ -276,8 +262,10 @@ namespace FileLiberator
|
||||
|
||||
foreach (var c in chapters)
|
||||
{
|
||||
if (c.Chapters is not null)
|
||||
{
|
||||
if (c.Chapters is null)
|
||||
chaps.Add(c);
|
||||
else
|
||||
{
|
||||
if (c.LengthMs < 10000)
|
||||
{
|
||||
c.Chapters[0].StartOffsetMs = c.StartOffsetMs;
|
||||
@@ -295,8 +283,6 @@ namespace FileLiberator
|
||||
chaps.AddRange(children);
|
||||
c.Chapters = null;
|
||||
}
|
||||
else
|
||||
chaps.Add(c);
|
||||
}
|
||||
return chaps;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@ using AAXClean;
|
||||
using Dinah.Core;
|
||||
using DataLayer;
|
||||
using LibationFileManager;
|
||||
using FileManager;
|
||||
using System.Threading.Tasks;
|
||||
using System;
|
||||
using System.IO;
|
||||
@@ -17,34 +16,35 @@ namespace FileLiberator
|
||||
public LibraryBook LibraryBook { get; }
|
||||
public LibraryBookDto LibraryBookDto { get; }
|
||||
public string DownloadUrl { get; }
|
||||
public string UserAgent { get; }
|
||||
public string AudibleKey { get; init; }
|
||||
public string AudibleIV { get; init; }
|
||||
public AaxDecrypter.OutputFormat OutputFormat { get; init; }
|
||||
public bool TrimOutputToChapterLength { get; init; }
|
||||
public bool RetainEncryptedFile { get; init; }
|
||||
public bool StripUnabridged { get; init; }
|
||||
public bool CreateCueSheet { get; init; }
|
||||
public bool DownloadClipsBookmarks { get; init; }
|
||||
public long DownloadSpeedBps { get; init; }
|
||||
public TimeSpan RuntimeLength { get; init; }
|
||||
public OutputFormat OutputFormat { get; init; }
|
||||
public ChapterInfo ChapterInfo { get; init; }
|
||||
public bool FixupFile { get; init; }
|
||||
public NAudio.Lame.LameConfig LameConfig { get; init; }
|
||||
public bool Downsample { get; init; }
|
||||
public bool MatchSourceBitrate { get; init; }
|
||||
public ReplacementCharacters ReplacementCharacters => Configuration.Instance.ReplacementCharacters;
|
||||
public string UserAgent => AudibleApi.Resources.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;
|
||||
public bool MoveMoovToBeginning => config.MoveMoovToBeginning;
|
||||
|
||||
public string GetMultipartFileName(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterFile.GetFilename(LibraryBookDto, props);
|
||||
|
||||
public string GetMultipartTitleName(MultiConvertFileProperties props)
|
||||
public string GetMultipartTitle(MultiConvertFileProperties props)
|
||||
=> Templates.ChapterTitle.GetTitle(LibraryBookDto, props);
|
||||
|
||||
public async Task<string> SaveClipsAndBookmarks(string fileName)
|
||||
public async Task<string> SaveClipsAndBookmarksAsync(string fileName)
|
||||
{
|
||||
if (DownloadClipsBookmarks)
|
||||
{
|
||||
var format = Configuration.Instance.ClipsBookmarksFileFormat;
|
||||
var format = config.ClipsBookmarksFileFormat;
|
||||
|
||||
var formatExtension = format.ToString().ToLowerInvariant();
|
||||
var filePath = Path.ChangeExtension(fileName, formatExtension);
|
||||
@@ -69,20 +69,21 @@ namespace FileLiberator
|
||||
return string.Empty;
|
||||
}
|
||||
|
||||
private readonly Configuration config;
|
||||
private readonly IDisposable cancellation;
|
||||
public void Dispose() => cancellation?.Dispose();
|
||||
|
||||
public DownloadOptions(LibraryBook libraryBook, string downloadUrl, string userAgent)
|
||||
public DownloadOptions(Configuration config, LibraryBook libraryBook, string downloadUrl)
|
||||
{
|
||||
this.config = ArgumentValidator.EnsureNotNull(config, nameof(config));
|
||||
LibraryBook = ArgumentValidator.EnsureNotNull(libraryBook, nameof(libraryBook));
|
||||
DownloadUrl = ArgumentValidator.EnsureNotNullOrEmpty(downloadUrl, nameof(downloadUrl));
|
||||
UserAgent = ArgumentValidator.EnsureNotNullOrEmpty(userAgent, nameof(userAgent));
|
||||
// no null/empty check for key/iv. unencrypted files do not have them
|
||||
|
||||
LibraryBookDto = LibraryBook.ToDto();
|
||||
|
||||
cancellation =
|
||||
Configuration.Instance
|
||||
config
|
||||
.ObservePropertyChanged<long>(
|
||||
nameof(Configuration.DownloadSpeedLimit),
|
||||
newVal => DownloadSpeedChanged?.Invoke(this, newVal));
|
||||
|
||||
@@ -27,11 +27,13 @@ namespace FileLiberator
|
||||
public static LibraryBookDto ToDto(this LibraryBook libraryBook) => new()
|
||||
{
|
||||
Account = libraryBook.Account,
|
||||
DateAdded = libraryBook.DateAdded,
|
||||
|
||||
AudibleProductId = libraryBook.Book.AudibleProductId,
|
||||
Title = libraryBook.Book.Title ?? "",
|
||||
Locale = libraryBook.Book.Locale,
|
||||
YearPublished = libraryBook.Book.DatePublished?.Year,
|
||||
DatePublished = libraryBook.Book.DatePublished,
|
||||
|
||||
Authors = libraryBook.Book.Authors.Select(c => c.Name).ToList(),
|
||||
|
||||
@@ -43,6 +45,7 @@ namespace FileLiberator
|
||||
BitRate = libraryBook.Book.AudioFormat.Bitrate,
|
||||
SampleRate = libraryBook.Book.AudioFormat.SampleRate,
|
||||
Channels = libraryBook.Book.AudioFormat.Channels,
|
||||
Language = libraryBook.Book.Language
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,7 +5,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
|
||||
<PackageReference Include="Polly" Version="7.2.3" />
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -9,11 +9,15 @@ namespace FileManager
|
||||
/// <summary>Get valid filename. Advanced features incl. parameterized template</summary>
|
||||
public class FileNamingTemplate : NamingTemplate
|
||||
{
|
||||
public ReplacementCharacters ReplacementCharacters { get; }
|
||||
/// <param name="template">Proposed file name with optional html-styled template tags.</param>
|
||||
public FileNamingTemplate(string template) : base(template) { }
|
||||
public FileNamingTemplate(string template, ReplacementCharacters replacement) : base(template)
|
||||
{
|
||||
ReplacementCharacters = replacement ?? ReplacementCharacters.Default;
|
||||
}
|
||||
|
||||
/// <summary>Generate a valid path for this file or directory</summary>
|
||||
public LongPath GetFilePath(ReplacementCharacters replacements, string fileExtension, bool returnFirstExisting = false)
|
||||
public LongPath GetFilePath(string fileExtension, bool returnFirstExisting = false)
|
||||
{
|
||||
string fileName =
|
||||
Template.EndsWith(Path.DirectorySeparatorChar) || Template.EndsWith(Path.AltDirectorySeparatorChar) ?
|
||||
@@ -22,7 +26,7 @@ namespace FileManager
|
||||
|
||||
List<string> pathParts = new();
|
||||
|
||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, replacements));
|
||||
var paramReplacements = ParameterReplacements.ToDictionary(r => $"<{formatKey(r.Key)}>", r => formatValue(r.Value, ReplacementCharacters));
|
||||
|
||||
while (!string.IsNullOrEmpty(fileName))
|
||||
{
|
||||
@@ -54,7 +58,7 @@ namespace FileManager
|
||||
return FileUtility
|
||||
.GetValidFilename(
|
||||
Path.Join(directory, replaceFileName(fileNamePart, paramReplacements, LongPath.MaxFilenameLength - fileExtension.Length - 5)) + fileExtension,
|
||||
replacements,
|
||||
ReplacementCharacters,
|
||||
fileExtension,
|
||||
returnFirstExisting
|
||||
);
|
||||
|
||||
@@ -151,9 +151,9 @@ namespace FileManager
|
||||
/// <br/>- Perform <see cref="SaferMove"/>
|
||||
/// <br/>- Return valid path
|
||||
/// </summary>
|
||||
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements)
|
||||
public static string SaferMoveToValidPath(LongPath source, LongPath destination, ReplacementCharacters replacements, string extension = null)
|
||||
{
|
||||
var extension = Path.GetExtension(source);
|
||||
extension = extension ?? Path.GetExtension(source);
|
||||
destination = GetValidFilename(destination, replacements, extension);
|
||||
SaferMove(source, destination);
|
||||
return destination;
|
||||
|
||||
@@ -40,6 +40,7 @@ namespace FileManager
|
||||
}
|
||||
}
|
||||
|
||||
[JsonConstructor]
|
||||
private LongPath(string path)
|
||||
{
|
||||
if (IsWindows && path.Length > MaxPathLength)
|
||||
@@ -56,9 +57,8 @@ namespace FileManager
|
||||
///a choice made by the linux kernel. As best as I can tell, pretty
|
||||
//much everyone uses UTF-8.
|
||||
public static int GetFilesystemStringLength(StringBuilder filename)
|
||||
=> LongPath.IsWindows ?
|
||||
filename.Length
|
||||
: Encoding.UTF8.GetByteCount(filename.ToString());
|
||||
=> IsWindows ? filename.Length
|
||||
: Encoding.UTF8.GetByteCount(filename.ToString());
|
||||
|
||||
public static implicit operator LongPath(string path)
|
||||
{
|
||||
|
||||
@@ -105,6 +105,8 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
public BookDetailsDialogViewModel(LibraryBook libraryBook)
|
||||
{
|
||||
var Book = libraryBook.Book;
|
||||
|
||||
//init tags
|
||||
Tags = libraryBook.Book.UserDefinedItem.Tags;
|
||||
|
||||
@@ -115,14 +117,15 @@ namespace LibationAvalonia.Dialogs
|
||||
|
||||
//init book details
|
||||
DetailsText = @$"
|
||||
Title: {libraryBook.Book.Title}
|
||||
Author(s): {libraryBook.Book.AuthorNames()}
|
||||
Narrator(s): {libraryBook.Book.NarratorNames()}
|
||||
Length: {(libraryBook.Book.LengthInMinutes == 0 ? "" : $"{libraryBook.Book.LengthInMinutes / 60} hr {libraryBook.Book.LengthInMinutes % 60} min")}
|
||||
Audio Bitrate: {libraryBook.Book.AudioFormat}
|
||||
Category: {string.Join(" > ", libraryBook.Book.CategoriesNames())}
|
||||
Title: {Book.Title}
|
||||
Author(s): {Book.AuthorNames()}
|
||||
Narrator(s): {Book.NarratorNames()}
|
||||
Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Book.LengthInMinutes % 60} min")}
|
||||
Audio Bitrate: {Book.AudioFormat}
|
||||
Category: {string.Join(" > ", Book.CategoriesNames())}
|
||||
Purchase Date: {libraryBook.DateAdded:d}
|
||||
Audible ID: {libraryBook.Book.AudibleProductId}
|
||||
Language: {Book.Language}
|
||||
Audible ID: {Book.AudibleProductId}
|
||||
".Trim();
|
||||
|
||||
var seriesNames = libraryBook.Book.SeriesNames();
|
||||
|
||||
@@ -50,7 +50,9 @@ namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
var dataGrid = sender as DataGrid;
|
||||
|
||||
var item = (dataGrid.SelectedItem as Tuple<string, string>).Item1.Replace("\x200C", "").Replace("...", "");
|
||||
var item = (dataGrid.SelectedItem as Tuple<string, string, string>).Item3;
|
||||
if (string.IsNullOrWhiteSpace(item)) return;
|
||||
|
||||
var text = userEditTbox.Text;
|
||||
|
||||
userEditTbox.Text = text.Insert(Math.Min(Math.Max(0, userEditTbox.CaretIndex), text.Length), item);
|
||||
@@ -84,13 +86,14 @@ namespace LibationAvalonia.Dialogs
|
||||
Template = templates;
|
||||
Description = templates.Description;
|
||||
ListItems
|
||||
= new AvaloniaList<Tuple<string, string>>(
|
||||
= new AvaloniaList<Tuple<string, string, string>>(
|
||||
Template
|
||||
.GetTemplateTags()
|
||||
.Select(
|
||||
t => new Tuple<string, string>(
|
||||
t => new Tuple<string, string, string>(
|
||||
$"<{t.TagName.Replace("->", "-\x200C>").Replace("<-", "<\x200C-")}>",
|
||||
t.Description)
|
||||
t.Description,
|
||||
t.DefaultValue)
|
||||
)
|
||||
);
|
||||
|
||||
@@ -108,13 +111,13 @@ namespace LibationAvalonia.Dialogs
|
||||
}
|
||||
}
|
||||
|
||||
public string workingTemplateText => Template.Sanitize(UserTemplateText);
|
||||
public string workingTemplateText => Template.Sanitize(UserTemplateText, Configuration.Instance.ReplacementCharacters);
|
||||
private string _warningText;
|
||||
public string WarningText { get => _warningText; set => this.RaiseAndSetIfChanged(ref _warningText, value); }
|
||||
|
||||
public string Description { get; }
|
||||
|
||||
public AvaloniaList<Tuple<string, string>> ListItems { get; set; }
|
||||
public AvaloniaList<Tuple<string, string, string>> ListItems { get; set; }
|
||||
|
||||
public void resetTextBox(string value) => UserTemplateText = value;
|
||||
|
||||
@@ -138,6 +141,8 @@ namespace LibationAvalonia.Dialogs
|
||||
var libraryBookDto = new LibraryBookDto
|
||||
{
|
||||
Account = "my account",
|
||||
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
|
||||
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
|
||||
AudibleProductId = "123456789",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
@@ -148,8 +153,9 @@ namespace LibationAvalonia.Dialogs
|
||||
SeriesNumber = "1",
|
||||
BitRate = 128,
|
||||
SampleRate = 44100,
|
||||
Channels = 2
|
||||
};
|
||||
Channels = 2,
|
||||
Language = "English"
|
||||
};
|
||||
var chapterName = "A Flight for Life";
|
||||
var chapterNumber = 4;
|
||||
var chaptersTotal = 10;
|
||||
|
||||
@@ -526,15 +526,27 @@
|
||||
Margin="0,5,0,5"
|
||||
IsChecked="{Binding !AudioSettings.DecryptToLossy, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Download my books in the original audio format (Lossless)" />
|
||||
<StackPanel >
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="Download my books in the original audio format (Lossless)" />
|
||||
<CheckBox
|
||||
Margin="0,0,0,5"
|
||||
IsEnabled="{Binding !AudioSettings.DecryptToLossy}"
|
||||
IsChecked="{Binding AudioSettings.MoveMoovToBeginning, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
TextWrapping="Wrap"
|
||||
Text="{Binding AudioSettings.MoveMoovToBeginningText}" />
|
||||
|
||||
</CheckBox>
|
||||
|
||||
</StackPanel>
|
||||
</RadioButton>
|
||||
|
||||
<RadioButton
|
||||
Margin="0,5,0,5"
|
||||
IsEnabled="{Binding AudioSettings.IsMp3Supported}"
|
||||
IsChecked="{Binding AudioSettings.DecryptToLossy, Mode=TwoWay}">
|
||||
|
||||
<TextBlock
|
||||
@@ -548,7 +560,6 @@
|
||||
|
||||
<StackPanel
|
||||
Grid.Row="0"
|
||||
IsVisible="{Binding AudioSettings.IsMp3Supported}"
|
||||
Grid.Column="1">
|
||||
|
||||
<controls:GroupBox
|
||||
|
||||
@@ -383,6 +383,7 @@ namespace LibationAvalonia.Dialogs
|
||||
{
|
||||
|
||||
private bool _downloadClipsBookmarks;
|
||||
private bool _decryptToLossy;
|
||||
private bool _splitFilesByChapter;
|
||||
private bool _allowLibationFixup;
|
||||
private bool _lameTargetBitrate;
|
||||
@@ -391,8 +392,6 @@ namespace LibationAvalonia.Dialogs
|
||||
private int _lameVBRQuality;
|
||||
private string _chapterTitleTemplate;
|
||||
|
||||
public bool IsMp3Supported => Configuration.IsLinux || Configuration.IsWindows;
|
||||
|
||||
public AudioSettings(Configuration config)
|
||||
{
|
||||
LoadSettings(config);
|
||||
@@ -411,6 +410,7 @@ namespace LibationAvalonia.Dialogs
|
||||
StripUnabridged = config.StripUnabridged;
|
||||
ChapterTitleTemplate = config.ChapterTitleTemplate;
|
||||
DecryptToLossy = config.DecryptToLossy;
|
||||
MoveMoovToBeginning = config.MoveMoovToBeginning;
|
||||
LameTargetBitrate = config.LameTargetBitrate;
|
||||
LameDownsampleMono = config.LameDownsampleMono;
|
||||
LameConstantBitrate = config.LameConstantBitrate;
|
||||
@@ -433,6 +433,7 @@ namespace LibationAvalonia.Dialogs
|
||||
config.StripUnabridged = StripUnabridged;
|
||||
config.ChapterTitleTemplate = ChapterTitleTemplate;
|
||||
config.DecryptToLossy = DecryptToLossy;
|
||||
config.MoveMoovToBeginning = MoveMoovToBeginning;
|
||||
config.LameTargetBitrate = LameTargetBitrate;
|
||||
config.LameDownsampleMono = LameDownsampleMono;
|
||||
config.LameConstantBitrate = LameConstantBitrate;
|
||||
@@ -453,6 +454,7 @@ namespace LibationAvalonia.Dialogs
|
||||
public string StripAudibleBrandingText { get; } = Configuration.GetDescription(nameof(Configuration.StripAudibleBrandAudio));
|
||||
public string StripUnabridgedText { get; } = Configuration.GetDescription(nameof(Configuration.StripUnabridged));
|
||||
public string ChapterTitleTemplateText { get; } = Configuration.GetDescription(nameof(Configuration.ChapterTitleTemplate));
|
||||
public string MoveMoovToBeginningText { get; } = Configuration.GetDescription(nameof(Configuration.MoveMoovToBeginning));
|
||||
|
||||
public bool CreateCueSheet { get; set; }
|
||||
public bool DownloadCoverArt { get; set; }
|
||||
@@ -462,7 +464,8 @@ namespace LibationAvalonia.Dialogs
|
||||
public bool MergeOpeningAndEndCredits { get; set; }
|
||||
public bool StripAudibleBrandAudio { get; set; }
|
||||
public bool StripUnabridged { get; set; }
|
||||
public bool DecryptToLossy { get; set; }
|
||||
public bool DecryptToLossy { get => _decryptToLossy; set => this.RaiseAndSetIfChanged(ref _decryptToLossy, value); }
|
||||
public bool MoveMoovToBeginning { get; set; }
|
||||
|
||||
public bool LameDownsampleMono { get; set; } = Design.IsDesignMode;
|
||||
public bool LameConstantBitrate { get; set; } = Design.IsDesignMode;
|
||||
|
||||
@@ -115,6 +115,9 @@ namespace LibationFileManager
|
||||
[Description("Decrypt to lossy format?")]
|
||||
public bool DecryptToLossy { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
[Description("Move the mp4 moov atom to the beginning of the file?")]
|
||||
public bool MoveMoovToBeginning { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
[Description("Lame encoder target. true = Bitrate, false = Quality")]
|
||||
public bool LameTargetBitrate { get => GetNonString(defaultValue: false); set => SetNonString(value); }
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public partial class Configuration : PropertyChangeFilter
|
||||
public partial class Configuration
|
||||
{
|
||||
/*
|
||||
* Use this type in the getter for any Dictionary<TKey, TValue> settings,
|
||||
|
||||
@@ -7,7 +7,7 @@ using FileManager;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
public partial class Configuration
|
||||
public partial class Configuration : PropertyChangeFilter
|
||||
{
|
||||
public bool LibationSettingsAreValid
|
||||
=> File.Exists(APPSETTINGS_JSON)
|
||||
|
||||
@@ -25,10 +25,14 @@ namespace LibationFileManager
|
||||
public int BitRate { get; set; }
|
||||
public int SampleRate { get; set; }
|
||||
public int Channels { get; set; }
|
||||
}
|
||||
public DateTime FileDate { get; set; } = DateTime.Now;
|
||||
public DateTime? DatePublished { get; set; }
|
||||
public string Language { get; set; }
|
||||
}
|
||||
|
||||
public class LibraryBookDto : BookDto
|
||||
{
|
||||
public string Account { get; set; }
|
||||
{
|
||||
public DateTime? DateAdded { get; set; }
|
||||
public string Account { get; set; }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,326 +0,0 @@
|
||||
using Dinah.Core;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.ComponentModel;
|
||||
using System.Linq;
|
||||
using System.Reflection;
|
||||
|
||||
namespace LibationFileManager
|
||||
{
|
||||
#region Useage
|
||||
|
||||
/*
|
||||
* USEAGE
|
||||
|
||||
*************************
|
||||
* *
|
||||
* Event Filter Mode *
|
||||
* *
|
||||
*************************
|
||||
|
||||
|
||||
propertyChangeFilter.PropertyChanged += MyPropertiesChanged;
|
||||
|
||||
[PropertyChangeFilter("MyProperty1")]
|
||||
[PropertyChangeFilter("MyProperty2")]
|
||||
void MyPropertiesChanged(object sender, PropertyChangedEventArgsEx e)
|
||||
{
|
||||
// Only properties whose names match either "MyProperty1"
|
||||
// or "MyProperty2" will fire this event handler.
|
||||
}
|
||||
|
||||
******
|
||||
* OR *
|
||||
******
|
||||
|
||||
propertyChangeFilter.PropertyChanged +=
|
||||
[PropertyChangeFilter("MyProperty1")]
|
||||
[PropertyChangeFilter("MyProperty2")]
|
||||
(_, _) =>
|
||||
{
|
||||
// Only properties whose names match either "MyProperty1"
|
||||
// or "MyProperty2" will fire this event handler.
|
||||
};
|
||||
|
||||
|
||||
*************************
|
||||
* *
|
||||
* Observable Mode *
|
||||
* *
|
||||
*************************
|
||||
|
||||
using var cancellation = propertyChangeFilter.ObservePropertyChanging<int>("MyProperty", MyPropertyChanging);
|
||||
|
||||
void MyPropertyChanging(int oldValue, int newValue)
|
||||
{
|
||||
// Only the property whose name match
|
||||
// "MyProperty" will fire this method.
|
||||
}
|
||||
|
||||
//The observer is delisted when cancellation is disposed
|
||||
|
||||
******
|
||||
* OR *
|
||||
******
|
||||
|
||||
using var cancellation = propertyChangeFilter.ObservePropertyChanged<bool>("MyProperty", s =>
|
||||
{
|
||||
// Only the property whose name match
|
||||
// "MyProperty" will fire this action.
|
||||
});
|
||||
|
||||
//The observer is delisted when cancellation is disposed
|
||||
|
||||
*/
|
||||
|
||||
#endregion
|
||||
|
||||
public abstract class PropertyChangeFilter
|
||||
{
|
||||
private readonly Dictionary<string, List<Delegate>> propertyChangedActions = new();
|
||||
private readonly Dictionary<string, List<Delegate>> propertyChangingActions = new();
|
||||
|
||||
private readonly List<(PropertyChangedEventHandlerEx subscriber, PropertyChangedEventHandlerEx wrapper)> changedFilters = new();
|
||||
private readonly List<(PropertyChangingEventHandlerEx subscriber, PropertyChangingEventHandlerEx wrapper)> changingFilters = new();
|
||||
|
||||
protected void OnPropertyChanged(string propertyName, object newValue)
|
||||
{
|
||||
if (propertyChangedActions.ContainsKey(propertyName))
|
||||
{
|
||||
//Invoke observables registered for propertyName
|
||||
foreach (var action in propertyChangedActions[propertyName])
|
||||
action.DynamicInvoke(newValue);
|
||||
}
|
||||
|
||||
_propertyChanged?.Invoke(this, new(propertyName, newValue));
|
||||
}
|
||||
|
||||
protected void OnPropertyChanging(string propertyName, object oldValue, object newValue)
|
||||
{
|
||||
if (propertyChangingActions.ContainsKey(propertyName))
|
||||
{
|
||||
//Invoke observables registered for propertyName
|
||||
foreach (var action in propertyChangingActions[propertyName])
|
||||
action.DynamicInvoke(oldValue, newValue);
|
||||
}
|
||||
|
||||
_propertyChanging?.Invoke(this, new(propertyName, oldValue, newValue));
|
||||
}
|
||||
|
||||
#region Events
|
||||
|
||||
private PropertyChangedEventHandlerEx _propertyChanged;
|
||||
private PropertyChangingEventHandlerEx _propertyChanging;
|
||||
|
||||
public event PropertyChangedEventHandlerEx PropertyChanged
|
||||
{
|
||||
add
|
||||
{
|
||||
var attributes = getAttributes<PropertyChangeFilterAttribute>(value.Method);
|
||||
|
||||
if (attributes.Any())
|
||||
{
|
||||
var matches = attributes.Select(a => a.PropertyName).ToArray();
|
||||
|
||||
void filterer(object s, PropertyChangedEventArgsEx e)
|
||||
{
|
||||
if (e.PropertyName.In(matches)) value(s, e);
|
||||
}
|
||||
|
||||
changedFilters.Add((value, filterer));
|
||||
|
||||
_propertyChanged += filterer;
|
||||
}
|
||||
else
|
||||
_propertyChanged += value;
|
||||
}
|
||||
remove
|
||||
{
|
||||
var del = changedFilters.LastOrDefault(d => d.subscriber == value);
|
||||
if (del == default)
|
||||
_propertyChanged -= value;
|
||||
else
|
||||
{
|
||||
_propertyChanged -= del.wrapper;
|
||||
changedFilters.Remove(del);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
public event PropertyChangingEventHandlerEx PropertyChanging
|
||||
{
|
||||
add
|
||||
{
|
||||
var attributes = getAttributes<PropertyChangeFilterAttribute>(value.Method);
|
||||
|
||||
if (attributes.Any())
|
||||
{
|
||||
var matches = attributes.Select(a => a.PropertyName).ToArray();
|
||||
|
||||
void filterer(object s, PropertyChangingEventArgsEx e)
|
||||
{
|
||||
if (e.PropertyName.In(matches)) value(s, e);
|
||||
}
|
||||
|
||||
changingFilters.Add((value, filterer));
|
||||
|
||||
_propertyChanging += filterer;
|
||||
|
||||
}
|
||||
else
|
||||
_propertyChanging += value;
|
||||
}
|
||||
remove
|
||||
{
|
||||
var del = changingFilters.LastOrDefault(d => d.subscriber == value);
|
||||
if (del == default)
|
||||
_propertyChanging -= value;
|
||||
else
|
||||
{
|
||||
_propertyChanging -= del.wrapper;
|
||||
changingFilters.Remove(del);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private static T[] getAttributes<T>(MethodInfo methodInfo) where T : Attribute
|
||||
=> Attribute.GetCustomAttributes(methodInfo, typeof(T)) as T[];
|
||||
|
||||
#endregion
|
||||
|
||||
#region Observables
|
||||
|
||||
/// <summary>
|
||||
/// Clear all subscriptions to Property<b>Changed</b> for <paramref name="propertyName"/>
|
||||
/// </summary>
|
||||
public void ClearChangedSubscriptions(string propertyName)
|
||||
{
|
||||
if (propertyChangedActions.ContainsKey(propertyName)
|
||||
&& propertyChangedActions[propertyName] is not null)
|
||||
propertyChangedActions[propertyName].Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Clear all subscriptions to Property<b>Changing</b> for <paramref name="propertyName"/>
|
||||
/// </summary>
|
||||
public void ClearChangingSubscriptions(string propertyName)
|
||||
{
|
||||
if (propertyChangingActions.ContainsKey(propertyName)
|
||||
&& propertyChangingActions[propertyName] is not null)
|
||||
propertyChangingActions[propertyName].Clear();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an action to be executed when a property's value has changed
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The <paramref name="propertyName"/>'s <see cref="Type"/></typeparam>
|
||||
/// <param name="propertyName">Name of the property whose change triggers the <paramref name="action"/></param>
|
||||
/// <param name="action">Action to be executed with the NewValue as a parameter</param>
|
||||
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
|
||||
public IDisposable ObservePropertyChanged<T>(string propertyName, Action<T> action)
|
||||
{
|
||||
validateSubscriber<T>(propertyName, action);
|
||||
|
||||
if (!propertyChangedActions.ContainsKey(propertyName))
|
||||
propertyChangedActions.Add(propertyName, new List<Delegate>());
|
||||
|
||||
var actionlist = propertyChangedActions[propertyName];
|
||||
|
||||
if (!actionlist.Contains(action))
|
||||
actionlist.Add(action);
|
||||
|
||||
return new Unsubscriber(actionlist, action);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Add an action to be executed when a property's value is changing
|
||||
/// </summary>
|
||||
/// <typeparam name="T">The <paramref name="propertyName"/>'s <see cref="Type"/></typeparam>
|
||||
/// <param name="propertyName">Name of the property whose change triggers the <paramref name="action"/></param>
|
||||
/// <param name="action">Action to be executed with OldValue and NewValue as parameters</param>
|
||||
/// <returns>A reference to an interface that allows observers to stop receiving notifications before the provider has finished sending them.</returns>
|
||||
public IDisposable ObservePropertyChanging<T>(string propertyName, Action<T, T> action)
|
||||
{
|
||||
validateSubscriber<T>(propertyName, action);
|
||||
|
||||
if (!propertyChangingActions.ContainsKey(propertyName))
|
||||
propertyChangingActions.Add(propertyName, new List<Delegate>());
|
||||
|
||||
var actionlist = propertyChangingActions[propertyName];
|
||||
|
||||
if (!actionlist.Contains(action))
|
||||
actionlist.Add(action);
|
||||
|
||||
return new Unsubscriber(actionlist, action);
|
||||
}
|
||||
|
||||
private void validateSubscriber<T>(string propertyName, Delegate action)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(propertyName, nameof(propertyName));
|
||||
ArgumentValidator.EnsureNotNull(action, nameof(action));
|
||||
|
||||
var propertyInfo = GetType().GetProperty(propertyName);
|
||||
|
||||
if (propertyInfo is null)
|
||||
throw new MissingMemberException($"{nameof(Configuration)}.{propertyName} does not exist.");
|
||||
|
||||
if (propertyInfo.PropertyType != typeof(T))
|
||||
throw new InvalidCastException($"{nameof(Configuration)}.{propertyName} is {propertyInfo.PropertyType}, but parameter is {typeof(T)}.");
|
||||
}
|
||||
|
||||
private class Unsubscriber : IDisposable
|
||||
{
|
||||
private List<Delegate> _observers;
|
||||
private Delegate _observer;
|
||||
|
||||
internal Unsubscriber(List<Delegate> observers, Delegate observer)
|
||||
{
|
||||
_observers = observers;
|
||||
_observer = observer;
|
||||
}
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
if (_observers.Contains(_observer))
|
||||
_observers.Remove(_observer);
|
||||
}
|
||||
}
|
||||
|
||||
#endregion
|
||||
}
|
||||
|
||||
public delegate void PropertyChangedEventHandlerEx(object sender, PropertyChangedEventArgsEx e);
|
||||
public delegate void PropertyChangingEventHandlerEx(object sender, PropertyChangingEventArgsEx e);
|
||||
|
||||
public class PropertyChangedEventArgsEx : PropertyChangedEventArgs
|
||||
{
|
||||
public object NewValue { get; }
|
||||
|
||||
public PropertyChangedEventArgsEx(string propertyName, object newValue) : base(propertyName)
|
||||
{
|
||||
NewValue = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
public class PropertyChangingEventArgsEx : PropertyChangingEventArgs
|
||||
{
|
||||
public object OldValue { get; }
|
||||
public object NewValue { get; }
|
||||
|
||||
public PropertyChangingEventArgsEx(string propertyName, object oldValue, object newValue) : base(propertyName)
|
||||
{
|
||||
OldValue = oldValue;
|
||||
NewValue = newValue;
|
||||
}
|
||||
}
|
||||
|
||||
[AttributeUsage(AttributeTargets.Method, AllowMultiple = true)]
|
||||
public class PropertyChangeFilterAttribute : Attribute
|
||||
{
|
||||
public string PropertyName { get; }
|
||||
public PropertyChangeFilterAttribute(string propertyName)
|
||||
{
|
||||
PropertyName = propertyName;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,16 +7,19 @@ namespace LibationFileManager
|
||||
{
|
||||
public sealed class TemplateTags : Enumeration<TemplateTags>
|
||||
{
|
||||
public string TagName => DisplayName;
|
||||
public string TagName => DisplayName;
|
||||
public string DefaultValue { get; }
|
||||
public string Description { get; }
|
||||
public bool IsChapterOnly { get; }
|
||||
|
||||
private static int value = 0;
|
||||
private TemplateTags(string tagName, string description, bool isChapterOnly = false) : base(value++, tagName)
|
||||
private TemplateTags(string tagName, string description, bool isChapterOnly = false, string defaultValue = null) : base(value++, tagName)
|
||||
{
|
||||
Description = description;
|
||||
IsChapterOnly = isChapterOnly;
|
||||
}
|
||||
DefaultValue = defaultValue ?? $"<{tagName}>";
|
||||
|
||||
}
|
||||
|
||||
// putting these first is the incredibly lazy way to make them show up first in the EditTemplateDialog
|
||||
public static TemplateTags ChCount { get; } = new TemplateTags("ch count", "Number of chapters", true);
|
||||
@@ -38,11 +41,16 @@ namespace LibationFileManager
|
||||
public static TemplateTags SampleRate { get; } = new TemplateTags("samplerate", "File's orig. sample rate");
|
||||
public static TemplateTags Channels { get; } = new TemplateTags("channels", "Number of audio channels");
|
||||
public static TemplateTags Account { get; } = new TemplateTags("account", "Audible account of this book");
|
||||
public static TemplateTags Locale { get; } = new TemplateTags("locale", "Region/country");
|
||||
public static TemplateTags YearPublished { get; } = new TemplateTags("year", "Year published");
|
||||
public static TemplateTags Locale { get; } = new("locale", "Region/country");
|
||||
public static TemplateTags YearPublished { get; } = new("year", "Year published");
|
||||
public static TemplateTags Language { get; } = new("language", "Book's language");
|
||||
public static TemplateTags LanguageShort { get; } = new("language short", "Book's language abbreviated. Eg: ENG");
|
||||
|
||||
// Special case. Isn't mapped to a replacement in Templates.cs
|
||||
// Special cases. Aren't mapped to replacements in Templates.cs
|
||||
// Included here for display by EditTemplateDialog
|
||||
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series");
|
||||
public static TemplateTags FileDate { get; } = new TemplateTags("file date [...]", "File date/time. e.g. yyyy-MM-dd HH-mm", false, $"<file date [{Templates.DEFAULT_DATE_FORMAT}]>");
|
||||
public static TemplateTags DatePublished { get; } = new TemplateTags("pub date [...]", "Publication date. e.g. yyyy-MM-dd", false, $"<pub date [{Templates.DEFAULT_DATE_FORMAT}]>");
|
||||
public static TemplateTags DateAdded { get; } = new TemplateTags("date added [...]", "Date added to your Audible account. e.g. yyyy-MM-dd", false, $"<date added [{Templates.DEFAULT_DATE_FORMAT}]>");
|
||||
public static TemplateTags IfSeries { get; } = new TemplateTags("if series->...<-if series", "Only include if part of a series", false, "<if series-><-if series>");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,6 +4,7 @@ using System.IO;
|
||||
using System.Linq;
|
||||
using System.Text.RegularExpressions;
|
||||
using Dinah.Core;
|
||||
using Dinah.Core.Collections.Generic;
|
||||
using FileManager;
|
||||
|
||||
namespace LibationFileManager
|
||||
@@ -102,16 +103,32 @@ namespace LibationFileManager
|
||||
public string GetPortionFilename(LibraryBookDto libraryBookDto, string template, string fileExtension)
|
||||
=> string.IsNullOrWhiteSpace(template)
|
||||
? ""
|
||||
: getFileNamingTemplate(libraryBookDto, template, null, fileExtension)
|
||||
.GetFilePath(Configuration.Instance.ReplacementCharacters, fileExtension).PathWithoutPrefix;
|
||||
: getFileNamingTemplate(libraryBookDto, template, null, fileExtension, Configuration.Instance.ReplacementCharacters)
|
||||
.GetFilePath(fileExtension).PathWithoutPrefix;
|
||||
|
||||
public const string DEFAULT_DATE_FORMAT = "yyyy-MM-dd";
|
||||
private static Regex fileDateTagRegex { get; } = new Regex(@"<file\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static Regex dateAddedTagRegex { get; } = new Regex(@"<date\s*?added\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static Regex datePublishedTagRegex { get; } = new Regex(@"<pub\s*?date\s*?(?:\[([^\[\]]*?)\]\s*?)?>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
private static Regex ifSeriesRegex { get; } = new Regex("<if series->(.*?)<-if series>", RegexOptions.Compiled | RegexOptions.IgnoreCase);
|
||||
|
||||
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension)
|
||||
private static string getLanguageShort(string language)
|
||||
{
|
||||
if (language is null)
|
||||
return null;
|
||||
|
||||
language = language.Trim();
|
||||
if (language.Length <= 3)
|
||||
return language.ToUpper();
|
||||
return language[..3].ToUpper();
|
||||
}
|
||||
|
||||
internal static FileNamingTemplate getFileNamingTemplate(LibraryBookDto libraryBookDto, string template, string dirFullPath, string extension, ReplacementCharacters replacements)
|
||||
{
|
||||
ArgumentValidator.EnsureNotNullOrWhiteSpace(template, nameof(template));
|
||||
ArgumentValidator.EnsureNotNull(libraryBookDto, nameof(libraryBookDto));
|
||||
|
||||
replacements ??= Configuration.Instance.ReplacementCharacters;
|
||||
dirFullPath = dirFullPath?.Trim() ?? "";
|
||||
|
||||
// for non-series, remove <if series-> and <-if series> tags and everything in between
|
||||
@@ -120,10 +137,16 @@ namespace LibationFileManager
|
||||
template,
|
||||
string.IsNullOrWhiteSpace(libraryBookDto.SeriesName) ? "" : "$1");
|
||||
|
||||
//Get date replacement parameters. Sanitizes the format text and replaces
|
||||
//the template with the sanitized text before creating FileNamingTemplate
|
||||
var fileDateParams = getSanitizeDateReplacementParameters(fileDateTagRegex, ref template, replacements, libraryBookDto.FileDate);
|
||||
var dateAddedParams = getSanitizeDateReplacementParameters(dateAddedTagRegex, ref template, replacements, libraryBookDto.DateAdded);
|
||||
var pubDateParams = getSanitizeDateReplacementParameters(datePublishedTagRegex, ref template, replacements, libraryBookDto.DatePublished);
|
||||
|
||||
var t = template + FileUtility.GetStandardizedExtension(extension);
|
||||
var fullfilename = dirFullPath == "" ? t : Path.Combine(dirFullPath, t);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename);
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename, replacements);
|
||||
|
||||
var title = libraryBookDto.Title ?? "";
|
||||
var titleShort = title.IndexOf(':') < 1 ? title : title.Substring(0, title.IndexOf(':'));
|
||||
@@ -143,8 +166,76 @@ namespace LibationFileManager
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Account, libraryBookDto.Account);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Locale, libraryBookDto.Locale);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.YearPublished, libraryBookDto.YearPublished?.ToString() ?? "1900");
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.Language, libraryBookDto.Language);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.LanguageShort, getLanguageShort(libraryBookDto.Language));
|
||||
|
||||
return fileNamingTemplate;
|
||||
//Add the sanitized replacement parameters
|
||||
foreach (var param in fileDateParams)
|
||||
fileNamingTemplate.ParameterReplacements.AddIfNotContains(param);
|
||||
foreach (var param in dateAddedParams)
|
||||
fileNamingTemplate.ParameterReplacements.AddIfNotContains(param);
|
||||
foreach (var param in pubDateParams)
|
||||
fileNamingTemplate.ParameterReplacements.AddIfNotContains(param);
|
||||
|
||||
return fileNamingTemplate;
|
||||
}
|
||||
#endregion
|
||||
|
||||
#region DateTime Tags
|
||||
|
||||
/// <param name="template">the file naming template. Any found date tags will be sanitized,
|
||||
/// and the template's original date tag will be replaced with the sanitized tag.</param>
|
||||
/// <returns>A list of parameter replacement key-value pairs</returns>
|
||||
private static List<KeyValuePair<string, object>> getSanitizeDateReplacementParameters(Regex datePattern, ref string template, ReplacementCharacters replacements, DateTime? dateTime)
|
||||
{
|
||||
List<KeyValuePair<string, object>> dateParams = new();
|
||||
|
||||
foreach (Match dateTag in datePattern.Matches(template))
|
||||
{
|
||||
var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out var sanitizedFormatter);
|
||||
if (tryFormatDateTime(dateTime, sanitizedFormatter, replacements, out var formattedDateString))
|
||||
{
|
||||
dateParams.Add(new(sanitizedTag, formattedDateString));
|
||||
template = template.Replace(dateTag.Value, sanitizedTag);
|
||||
}
|
||||
}
|
||||
return dateParams;
|
||||
}
|
||||
|
||||
/// <returns>a date parameter replacement tag with the format string sanitized</returns>
|
||||
private static string sanitizeDateParameterTag(Match dateTag, ReplacementCharacters replacements, out string sanitizedFormatter)
|
||||
{
|
||||
if (dateTag.Groups.Count != 2 || string.IsNullOrWhiteSpace(dateTag.Groups[1].Value))
|
||||
{
|
||||
sanitizedFormatter = DEFAULT_DATE_FORMAT;
|
||||
return dateTag.Value;
|
||||
}
|
||||
|
||||
var formatter = dateTag.Groups[1].Value;
|
||||
|
||||
sanitizedFormatter = replacements.ReplaceFilenameChars(formatter).Trim();
|
||||
|
||||
return dateTag.Value.Replace(formatter, sanitizedFormatter);
|
||||
}
|
||||
|
||||
private static bool tryFormatDateTime(DateTime? dateTime, string sanitizedFormatter, ReplacementCharacters replacements, out string formattedDateString)
|
||||
{
|
||||
if (!dateTime.HasValue)
|
||||
{
|
||||
formattedDateString = string.Empty;
|
||||
return true;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
formattedDateString = replacements.ReplaceFilenameChars(dateTime.Value.ToString(sanitizedFormatter)).Trim();
|
||||
return true;
|
||||
}
|
||||
catch
|
||||
{
|
||||
formattedDateString = null;
|
||||
return false;
|
||||
}
|
||||
}
|
||||
#endregion
|
||||
|
||||
@@ -153,10 +244,17 @@ namespace LibationFileManager
|
||||
// yeah, this line is a little funky but it works when you think through it. also: trust the unit tests
|
||||
.Where(t => IsChapterized || !t.IsChapterOnly);
|
||||
|
||||
public string Sanitize(string template)
|
||||
public string Sanitize(string template, ReplacementCharacters replacements)
|
||||
{
|
||||
var value = template ?? "";
|
||||
|
||||
// Replace invalid filename characters in the DateTime format provider so we don't trip any alarms.
|
||||
// Illegal filename characters in the formatter are allowed because they will be replaced by
|
||||
// getFileNamingTemplate()
|
||||
value = fileDateTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _));
|
||||
value = dateAddedTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _));
|
||||
value = datePublishedTagRegex.Replace(value, m => sanitizeDateParameterTag(m, replacements, out _));
|
||||
|
||||
// don't use alt slash
|
||||
value = value.Replace(Path.AltDirectorySeparatorChar, Path.DirectorySeparatorChar);
|
||||
|
||||
@@ -203,7 +301,7 @@ namespace LibationFileManager
|
||||
|
||||
// must be relative. no colons. all other path chars are valid enough to pass this check and will be handled on final save.
|
||||
if (ReplacementCharacters.ContainsInvalidPathChar(template.Replace("<", "").Replace(">", "")))
|
||||
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
|
||||
return new[] { ERROR_INVALID_FILE_NAME_CHAR };
|
||||
|
||||
return Valid;
|
||||
}
|
||||
@@ -214,8 +312,8 @@ namespace LibationFileManager
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto, string baseDir = null)
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null)
|
||||
.GetFilePath(Configuration.Instance.ReplacementCharacters, string.Empty);
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FolderTemplate, baseDir ?? AudibleFileStorage.BooksDirectory, null, Configuration.Instance.ReplacementCharacters)
|
||||
.GetFilePath(string.Empty);
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -237,8 +335,8 @@ namespace LibationFileManager
|
||||
#region to file name
|
||||
/// <summary>USES LIVE CONFIGURATION VALUES</summary>
|
||||
public string GetFilename(LibraryBookDto libraryBookDto, string dirFullPath, string extension, bool returnFirstExisting = false)
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension)
|
||||
.GetFilePath(Configuration.Instance.ReplacementCharacters, extension, returnFirstExisting);
|
||||
=> getFileNamingTemplate(libraryBookDto, Configuration.Instance.FileTemplate, dirFullPath, extension, Configuration.Instance.ReplacementCharacters)
|
||||
.GetFilePath(extension, returnFirstExisting);
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -279,14 +377,21 @@ namespace LibationFileManager
|
||||
|
||||
replacements ??= Configuration.Instance.ReplacementCharacters;
|
||||
var fileExtension = Path.GetExtension(props.OutputFileName);
|
||||
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, fileExtension);
|
||||
var fileNamingTemplate = getFileNamingTemplate(libraryBookDto, template, fullDirPath, fileExtension, replacements);
|
||||
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChCount, props.PartsTotal);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber, props.PartsPosition);
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChNumber0, FileUtility.GetSequenceFormatted(props.PartsPosition, props.PartsTotal));
|
||||
fileNamingTemplate.AddParameterReplacement(TemplateTags.ChTitle, props.Title ?? "");
|
||||
|
||||
return fileNamingTemplate.GetFilePath(replacements, fileExtension).PathWithoutPrefix;
|
||||
foreach (Match dateTag in fileDateTagRegex.Matches(fileNamingTemplate.Template))
|
||||
{
|
||||
var sanitizedTag = sanitizeDateParameterTag(dateTag, replacements, out string sanitizedFormatter);
|
||||
if (tryFormatDateTime(props.FileDate, sanitizedFormatter, replacements, out var formattedDateString))
|
||||
fileNamingTemplate.ParameterReplacements[sanitizedTag] = formattedDateString;
|
||||
}
|
||||
|
||||
return fileNamingTemplate.GetFilePath(fileExtension).PathWithoutPrefix;
|
||||
}
|
||||
#endregion
|
||||
}
|
||||
|
||||
@@ -9,5 +9,8 @@ namespace LibationFileManager
|
||||
{
|
||||
public static void AddParameterReplacement(this NamingTemplate fileNamingTemplate, TemplateTags templateTags, object value)
|
||||
=> fileNamingTemplate.AddParameterReplacement(templateTags.TagName, value);
|
||||
|
||||
public static void AddUniqueParameterReplacement(this NamingTemplate namingTemplate, string key, object value)
|
||||
=> namingTemplate.ParameterReplacements[key] = value;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,7 @@ Length: {(Book.LengthInMinutes == 0 ? "" : $"{Book.LengthInMinutes / 60} hr {Boo
|
||||
Audio Bitrate: {Book.AudioFormat}
|
||||
Category: {string.Join(" > ", Book.CategoriesNames())}
|
||||
Purchase Date: {_libraryBook.DateAdded:d}
|
||||
Language: {Book.Language}
|
||||
Audible ID: {Book.AudibleProductId}
|
||||
".Trim();
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ namespace LibationWinForms.Dialogs
|
||||
private string workingTemplateText
|
||||
{
|
||||
get => _workingTemplateText;
|
||||
set => _workingTemplateText = template.Sanitize(value);
|
||||
set => _workingTemplateText = template.Sanitize(value, Configuration.Instance.ReplacementCharacters);
|
||||
}
|
||||
|
||||
private void resetTextBox(string value) => this.templateTb.Text = workingTemplateText = value;
|
||||
@@ -59,7 +59,7 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
// populate list view
|
||||
foreach (var tag in template.GetTemplateTags())
|
||||
listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }));
|
||||
listView1.Items.Add(new ListViewItem(new[] { $"<{tag.TagName}>", tag.Description }) { Tag = tag.DefaultValue });
|
||||
}
|
||||
|
||||
private void resetToDefaultBtn_Click(object sender, EventArgs e) => resetTextBox(template.DefaultTemplate);
|
||||
@@ -73,6 +73,8 @@ namespace LibationWinForms.Dialogs
|
||||
var libraryBookDto = new LibraryBookDto
|
||||
{
|
||||
Account = "my account",
|
||||
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
|
||||
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
|
||||
AudibleProductId = "123456789",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
@@ -83,7 +85,8 @@ namespace LibationWinForms.Dialogs
|
||||
SeriesNumber = "1",
|
||||
BitRate = 128,
|
||||
SampleRate = 44100,
|
||||
Channels = 2
|
||||
Channels = 2,
|
||||
Language = "English"
|
||||
};
|
||||
var chapterName = "A Flight for Life";
|
||||
var chapterNumber = 4;
|
||||
@@ -207,9 +210,11 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
private void listView1_DoubleClick(object sender, EventArgs e)
|
||||
{
|
||||
var itemText = listView1.SelectedItems[0].Text.Replace("...", "");
|
||||
var text = templateTb.Text;
|
||||
var itemText = listView1.SelectedItems[0].Tag as string;
|
||||
|
||||
if (string.IsNullOrEmpty(itemText)) return;
|
||||
|
||||
var text = templateTb.Text;
|
||||
var selStart = Math.Min(Math.Max(0, templateTb.SelectionStart), text.Length);
|
||||
|
||||
templateTb.Text = text.Insert(selStart, itemText);
|
||||
|
||||
@@ -16,6 +16,7 @@ namespace LibationWinForms.Dialogs
|
||||
this.mergeOpeningEndCreditsCbox.Text = desc(nameof(config.MergeOpeningAndEndCredits));
|
||||
this.stripAudibleBrandingCbox.Text = desc(nameof(config.StripAudibleBrandAudio));
|
||||
this.stripUnabridgedCbox.Text = desc(nameof(config.StripUnabridged));
|
||||
this.moveMoovAtomCbox.Text = desc(nameof(config.MoveMoovToBeginning));
|
||||
|
||||
clipsBookmarksFormatCb.Items.AddRange(
|
||||
new object[]
|
||||
@@ -37,6 +38,7 @@ namespace LibationWinForms.Dialogs
|
||||
stripAudibleBrandingCbox.Checked = config.StripAudibleBrandAudio;
|
||||
convertLosslessRb.Checked = !config.DecryptToLossy;
|
||||
convertLossyRb.Checked = config.DecryptToLossy;
|
||||
moveMoovAtomCbox.Checked = config.MoveMoovToBeginning;
|
||||
|
||||
lameTargetBitrateRb.Checked = config.LameTargetBitrate;
|
||||
lameTargetQualityRb.Checked = !config.LameTargetBitrate;
|
||||
@@ -70,6 +72,7 @@ namespace LibationWinForms.Dialogs
|
||||
config.StripUnabridged = stripUnabridgedCbox.Checked;
|
||||
config.StripAudibleBrandAudio = stripAudibleBrandingCbox.Checked;
|
||||
config.DecryptToLossy = convertLossyRb.Checked;
|
||||
config.MoveMoovToBeginning = moveMoovAtomCbox.Checked;
|
||||
|
||||
config.LameTargetBitrate = lameTargetBitrateRb.Checked;
|
||||
config.LameDownsampleMono = lameDownsampleMonoCbox.Checked;
|
||||
@@ -107,6 +110,7 @@ namespace LibationWinForms.Dialogs
|
||||
|
||||
private void convertFormatRb_CheckedChanged(object sender, EventArgs e)
|
||||
{
|
||||
moveMoovAtomCbox.Enabled = convertLosslessRb.Checked;
|
||||
lameTargetRb_CheckedChanged(sender, e);
|
||||
LameMatchSourceBRCbox_CheckedChanged(sender, e);
|
||||
}
|
||||
|
||||
@@ -76,6 +76,7 @@
|
||||
this.clipsBookmarksFormatCb = new System.Windows.Forms.ComboBox();
|
||||
this.downloadClipsBookmarksCbox = new System.Windows.Forms.CheckBox();
|
||||
this.audiobookFixupsGb = new System.Windows.Forms.GroupBox();
|
||||
this.moveMoovAtomCbox = new System.Windows.Forms.CheckBox();
|
||||
this.stripUnabridgedCbox = new System.Windows.Forms.CheckBox();
|
||||
this.chapterTitleTemplateGb = new System.Windows.Forms.GroupBox();
|
||||
this.chapterTitleTemplateBtn = new System.Windows.Forms.Button();
|
||||
@@ -294,7 +295,7 @@
|
||||
// convertLossyRb
|
||||
//
|
||||
this.convertLossyRb.AutoSize = true;
|
||||
this.convertLossyRb.Location = new System.Drawing.Point(13, 136);
|
||||
this.convertLossyRb.Location = new System.Drawing.Point(13, 158);
|
||||
this.convertLossyRb.Name = "convertLossyRb";
|
||||
this.convertLossyRb.Size = new System.Drawing.Size(329, 19);
|
||||
this.convertLossyRb.TabIndex = 12;
|
||||
@@ -675,6 +676,7 @@
|
||||
//
|
||||
// audiobookFixupsGb
|
||||
//
|
||||
this.audiobookFixupsGb.Controls.Add(this.moveMoovAtomCbox);
|
||||
this.audiobookFixupsGb.Controls.Add(this.splitFilesByChapterCbox);
|
||||
this.audiobookFixupsGb.Controls.Add(this.stripUnabridgedCbox);
|
||||
this.audiobookFixupsGb.Controls.Add(this.convertLosslessRb);
|
||||
@@ -682,11 +684,21 @@
|
||||
this.audiobookFixupsGb.Controls.Add(this.stripAudibleBrandingCbox);
|
||||
this.audiobookFixupsGb.Location = new System.Drawing.Point(6, 169);
|
||||
this.audiobookFixupsGb.Name = "audiobookFixupsGb";
|
||||
this.audiobookFixupsGb.Size = new System.Drawing.Size(403, 160);
|
||||
this.audiobookFixupsGb.Size = new System.Drawing.Size(403, 185);
|
||||
this.audiobookFixupsGb.TabIndex = 19;
|
||||
this.audiobookFixupsGb.TabStop = false;
|
||||
this.audiobookFixupsGb.Text = "Audiobook Fix-ups";
|
||||
//
|
||||
// moveMoovAtomCbox
|
||||
//
|
||||
this.moveMoovAtomCbox.AutoSize = true;
|
||||
this.moveMoovAtomCbox.Location = new System.Drawing.Point(23, 133);
|
||||
this.moveMoovAtomCbox.Name = "moveMoovAtomCbox";
|
||||
this.moveMoovAtomCbox.Size = new System.Drawing.Size(188, 19);
|
||||
this.moveMoovAtomCbox.TabIndex = 14;
|
||||
this.moveMoovAtomCbox.Text = "[MoveMoovToBeginning desc]";
|
||||
this.moveMoovAtomCbox.UseVisualStyleBackColor = true;
|
||||
//
|
||||
// stripUnabridgedCbox
|
||||
//
|
||||
this.stripUnabridgedCbox.AutoSize = true;
|
||||
@@ -701,7 +713,7 @@
|
||||
//
|
||||
this.chapterTitleTemplateGb.Controls.Add(this.chapterTitleTemplateBtn);
|
||||
this.chapterTitleTemplateGb.Controls.Add(this.chapterTitleTemplateTb);
|
||||
this.chapterTitleTemplateGb.Location = new System.Drawing.Point(6, 335);
|
||||
this.chapterTitleTemplateGb.Location = new System.Drawing.Point(6, 360);
|
||||
this.chapterTitleTemplateGb.Name = "chapterTitleTemplateGb";
|
||||
this.chapterTitleTemplateGb.Size = new System.Drawing.Size(842, 54);
|
||||
this.chapterTitleTemplateGb.TabIndex = 18;
|
||||
@@ -738,7 +750,7 @@
|
||||
this.lameOptionsGb.Controls.Add(this.groupBox2);
|
||||
this.lameOptionsGb.Location = new System.Drawing.Point(415, 6);
|
||||
this.lameOptionsGb.Name = "lameOptionsGb";
|
||||
this.lameOptionsGb.Size = new System.Drawing.Size(433, 323);
|
||||
this.lameOptionsGb.Size = new System.Drawing.Size(433, 348);
|
||||
this.lameOptionsGb.TabIndex = 14;
|
||||
this.lameOptionsGb.TabStop = false;
|
||||
this.lameOptionsGb.Text = "Mp3 Encoding Options";
|
||||
@@ -1240,5 +1252,6 @@
|
||||
private System.Windows.Forms.CheckBox useCoverAsFolderIconCb;
|
||||
private System.Windows.Forms.ComboBox clipsBookmarksFormatCb;
|
||||
private System.Windows.Forms.CheckBox downloadClipsBookmarksCbox;
|
||||
private System.Windows.Forms.CheckBox moveMoovAtomCbox;
|
||||
}
|
||||
}
|
||||
@@ -45,7 +45,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Autoupdater.NET.Official" Version="1.7.6" />
|
||||
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.0.1" />
|
||||
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.0.1" />
|
||||
<PackageReference Include="Dinah.Core.WindowsDesktop" Version="7.2.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
|
||||
@@ -19,7 +19,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Dinah.Core" Version="7.2.0.1" />
|
||||
<PackageReference Include="Dinah.Core" Version="7.2.1.1" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.0.2" />
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.0.2" />
|
||||
|
||||
@@ -36,10 +36,10 @@ namespace FileNamingTemplateTests
|
||||
extension = FileUtility.GetStandardizedExtension(extension);
|
||||
var fullfilename = Path.Combine(dirFullPath, template + extension);
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename);
|
||||
var fileNamingTemplate = new FileNamingTemplate(fullfilename, Replacements);
|
||||
fileNamingTemplate.AddParameterReplacement("title", filename);
|
||||
fileNamingTemplate.AddParameterReplacement("id", metadataSuffix);
|
||||
return fileNamingTemplate.GetFilePath(Replacements, extension).PathWithoutPrefix;
|
||||
return fileNamingTemplate.GetFilePath(extension).PathWithoutPrefix;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -61,10 +61,10 @@ namespace FileNamingTemplateTests
|
||||
var estension = Path.GetExtension(originalPath);
|
||||
var t = Path.ChangeExtension(originalPath, null) + " - <chapter> - <title>" + estension;
|
||||
|
||||
var fileNamingTemplate = new FileNamingTemplate(t);
|
||||
var fileNamingTemplate = new FileNamingTemplate(t, Replacements);
|
||||
fileNamingTemplate.AddParameterReplacement("chapter", chapterCountLeadingZeros);
|
||||
fileNamingTemplate.AddParameterReplacement("title", suffix);
|
||||
return fileNamingTemplate.GetFilePath(Replacements, estension).PathWithoutPrefix;
|
||||
return fileNamingTemplate.GetFilePath(estension).PathWithoutPrefix;
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
@@ -74,9 +74,9 @@ namespace FileNamingTemplateTests
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
var fileNamingTemplate = new FileNamingTemplate(inStr);
|
||||
var fileNamingTemplate = new FileNamingTemplate(inStr, Replacements);
|
||||
fileNamingTemplate.AddParameterReplacement("title", @"s\l/a\s/h\e/s");
|
||||
fileNamingTemplate.GetFilePath(Replacements, "txt").PathWithoutPrefix.Should().Be(outStr);
|
||||
fileNamingTemplate.GetFilePath("txt").PathWithoutPrefix.Should().Be(outStr);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
|
||||
<PackageReference Include="MSTest.TestFramework" Version="3.0.2" />
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
using System;
|
||||
using System;
|
||||
using System.Collections.Generic;
|
||||
using System.IO;
|
||||
using System.Linq;
|
||||
@@ -26,18 +26,41 @@ namespace TemplatesTests
|
||||
=> new()
|
||||
{
|
||||
Account = "my account",
|
||||
DateAdded = new DateTime(2022, 6, 9, 0, 0, 0),
|
||||
DatePublished = new DateTime(2017, 2, 27, 0, 0, 0),
|
||||
FileDate = new DateTime(2023, 1, 28, 0, 0, 0),
|
||||
AudibleProductId = "asin",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
YearPublished = 2017,
|
||||
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
|
||||
YearPublished = 2017,
|
||||
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
|
||||
Narrators = new List<string> { "Stephen Fry" },
|
||||
SeriesName = seriesName ?? "",
|
||||
SeriesNumber = "1",
|
||||
BitRate = 128,
|
||||
SampleRate = 44100,
|
||||
Channels = 2
|
||||
};
|
||||
Channels = 2,
|
||||
Language = "English"
|
||||
};
|
||||
|
||||
public static LibraryBookDto GetLibraryBookWithNullDates(string seriesName = "Sherlock Holmes")
|
||||
=> new()
|
||||
{
|
||||
Account = "my account",
|
||||
FileDate = new DateTime(2023, 1, 28, 0, 0, 0),
|
||||
AudibleProductId = "asin",
|
||||
Title = "A Study in Scarlet: A Sherlock Holmes Novel",
|
||||
Locale = "us",
|
||||
YearPublished = 2017,
|
||||
Authors = new List<string> { "Arthur Conan Doyle", "Stephen Fry - introductions" },
|
||||
Narrators = new List<string> { "Stephen Fry" },
|
||||
SeriesName = seriesName ?? "",
|
||||
SeriesNumber = "1",
|
||||
BitRate = 128,
|
||||
SampleRate = 44100,
|
||||
Channels = 2,
|
||||
Language = "English"
|
||||
};
|
||||
}
|
||||
|
||||
[TestClass]
|
||||
@@ -71,47 +94,156 @@ namespace TemplatesTests
|
||||
[DataRow(null, @"C:\", "ext")]
|
||||
[ExpectedException(typeof(ArgumentNullException))]
|
||||
public void arg_null_exception(string template, string dirFullPath, string extension)
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension);
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("", @"C:\foo\bar", "ext")]
|
||||
[DataRow(" ", @"C:\foo\bar", "ext")]
|
||||
[ExpectedException(typeof(ArgumentException))]
|
||||
public void arg_exception(string template, string dirFullPath, string extension)
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension);
|
||||
=> Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements);
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("f.txt", @"C:\foo\bar", "", @"C:\foo\bar\f.txt", PlatformID.Win32NT)]
|
||||
[DataRow("f.txt", @"/foo/bar", "", @"/foo/bar/f.txt", PlatformID.Unix)]
|
||||
[DataRow("f.txt", @"C:\foo\bar", ".ext", @"C:\foo\bar\f.txt.ext", PlatformID.Win32NT)]
|
||||
[DataRow("f.txt", @"/foo/bar", ".ext", @"/foo/bar/f.txt.ext", PlatformID.Unix)]
|
||||
[DataRow("f", @"C:\foo\bar", ".ext", @"C:\foo\bar\f.ext", PlatformID.Win32NT)]
|
||||
[DataRow("f", @"/foo/bar", ".ext", @"/foo/bar/f.ext", PlatformID.Unix)]
|
||||
[DataRow("<id>", @"C:\foo\bar", ".ext", @"C:\foo\bar\asin.ext", PlatformID.Win32NT)]
|
||||
[DataRow("<id>", @"/foo/bar", ".ext", @"/foo/bar/asin.ext", PlatformID.Unix)]
|
||||
[DataRow("<bitrate> - <samplerate> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\128 - 44100 - 2.ext", PlatformID.Win32NT)]
|
||||
[DataRow("<bitrate> - <samplerate> - <channels>", @"/foo/bar", ".ext", @"/foo/bar/128 - 44100 - 2.ext", PlatformID.Unix)]
|
||||
[DataRow("<year> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\2017 - 2.ext", PlatformID.Win32NT)]
|
||||
[DataRow("<year> - <channels>", @"/foo/bar", ".ext", @"/foo/bar/2017 - 2.ext", PlatformID.Unix)]
|
||||
[DataRow("(000.0) <year> - <channels>", @"C:\foo\bar", "ext", @"C:\foo\bar\(000.0) 2017 - 2.ext", PlatformID.Win32NT)]
|
||||
[DataRow("(000.0) <year> - <channels>", @"/foo/bar", ".ext", @"/foo/bar/(000.0) 2017 - 2.ext", PlatformID.Unix)]
|
||||
public void Tests(string template, string dirFullPath, string extension, string expected, PlatformID platformID)
|
||||
[DataRow("f.txt", @"C:\foo\bar", "", @"C:\foo\bar\f.txt")]
|
||||
[DataRow("f.txt", @"C:\foo\bar", ".ext", @"C:\foo\bar\f.txt.ext")]
|
||||
[DataRow("f", @"C:\foo\bar", ".ext", @"C:\foo\bar\f.ext")]
|
||||
[DataRow("<id>", @"C:\foo\bar", ".ext", @"C:\foo\bar\asin.ext")]
|
||||
[DataRow("<bitrate> - <samplerate> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\128 - 44100 - 2.ext")]
|
||||
[DataRow("<year> - <channels>", @"C:\foo\bar", ".ext", @"C:\foo\bar\2017 - 2.ext")]
|
||||
[DataRow("(000.0) <year> - <channels>", @"C:\foo\bar", "ext", @"C:\foo\bar\(000.0) 2017 - 2.ext")]
|
||||
public void Tests(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension)
|
||||
.GetFilePath(Replacements, extension)
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
{
|
||||
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/');
|
||||
expected = expected.Replace("C:", "").Replace('\\', '/');
|
||||
}
|
||||
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
|
||||
.GetFilePath(extension)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<id> - <filedate[yy-MM-dd]>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
|
||||
[DataRow("<id> - <filedate [ yy-MM-dd ] >", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
|
||||
[DataRow("<id> - <file date [yy-MM-dd] >", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
|
||||
[DataRow("<id> - <file date[yy-MM-dd]>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 23-01-28.m4b")]
|
||||
[DataRow("<id> - <file date[]>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
[DataRow("<id> - <filedate[]>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
[DataRow("<id> - <filedate [ ] >", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
[DataRow("<id> - <filedate>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
[DataRow("<id> - <filedate >", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
[DataRow("<id> - <file date>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
[DataRow("<id> - <file date>", @"C:\foo\bar", "m4b", @"C:\foo\bar\asin - 2023-01-28.m4b")]
|
||||
public void DateFormat_pattern(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
{
|
||||
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/');
|
||||
expected = expected.Replace("C:", "").Replace('\\', '/');
|
||||
}
|
||||
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
|
||||
.GetFilePath(extension)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<filedate[h]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[h]>.m4b")]
|
||||
[DataRow("< filedate[yyyy]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\< filedate[yyyy]>.m4b")]
|
||||
[DataRow("<filedate[yyyy][]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[yyyy][]>.m4b")]
|
||||
[DataRow("<filedate[[yyyy]]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[[yyyy]]>.m4b")]
|
||||
[DataRow("<filedate[yyyy[]]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[yyyy[]]>.m4b")]
|
||||
[DataRow("<filedate yyyy]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate yyyy]>.m4b")]
|
||||
[DataRow("<filedate ]yyyy]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate ]yyyy]>.m4b")]
|
||||
[DataRow("<filedate [yyyy>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate [yyyy>.m4b")]
|
||||
[DataRow("<filedate [yyyy[>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate [yyyy[>.m4b")]
|
||||
[DataRow("<filedate yyyy>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate yyyy>.m4b")]
|
||||
[DataRow("<filedate[yyyy]", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filedate[yyyy].m4b")]
|
||||
[DataRow("<fil edate[yyyy]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<fil edate[yyyy]>.m4b")]
|
||||
[DataRow("<filed ate[yyyy]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\<filed ate[yyyy]>.m4b")]
|
||||
public void DateFormat_invalid(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
{
|
||||
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/');
|
||||
expected = expected.Replace("C:", "").Replace('\\', '/').Replace('<', '<').Replace('>','>');
|
||||
}
|
||||
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
|
||||
.GetFilePath(extension)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<filedate[yy-MM-dd]> <date added[yy-MM-dd]> <pubdate[yy-MM]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\23-01-28 22-06-09 17-02.m4b")]
|
||||
[DataRow("<filedate[yy-MM-dd]> <filedate[yy-MM-dd]> <filedate[yy-MM-dd]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\23-01-28 23-01-28 23-01-28.m4b")]
|
||||
[DataRow("<file date [ yy-MM-dd ] > <filedate [ yy-MM-dd ] > <file date [ yy-MM-dd] >", @"C:\foo\bar", ".m4b", @"C:\foo\bar\23-01-28 23-01-28 23-01-28.m4b")]
|
||||
public void DateFormat_multiple(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
{
|
||||
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/');
|
||||
expected = expected.Replace("C:", "").Replace('\\', '/');
|
||||
}
|
||||
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
|
||||
.GetFilePath(extension)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<id> - <pubdate[MM/dd/yy HH:mm]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\asin - 02∕27∕17 00꞉00.m4b", PlatformID.Win32NT)]
|
||||
[DataRow("<id> - <pubdate[MM/dd/yy HH:mm]>", @"/foo/bar", ".m4b", @"/foo/bar/asin - 02∕27∕17 00:00.m4b", PlatformID.Unix)]
|
||||
[DataRow("<id> - <filedate[MM/dd/yy HH:mm]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\asin - 01∕28∕23 00꞉00.m4b", PlatformID.Win32NT)]
|
||||
[DataRow("<id> - <filedate[MM/dd/yy HH:mm]>", @"/foo/bar", ".m4b", @"/foo/bar/asin - 01∕28∕23 00:00.m4b", PlatformID.Unix)]
|
||||
[DataRow("<id> - <date added[MM/dd/yy HH:mm]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\asin - 06∕09∕22 00꞉00.m4b", PlatformID.Win32NT)]
|
||||
[DataRow("<id> - <date added[MM/dd/yy HH:mm]>", @"/foo/bar", ".m4b", @"/foo/bar/asin - 06∕09∕22 00:00.m4b", PlatformID.Unix)]
|
||||
public void DateFormat_illegal(string template, string dirFullPath, string extension, string expected, PlatformID platformID)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
{
|
||||
Templates.File.HasWarnings(template).Should().BeTrue();
|
||||
Templates.File.HasWarnings(Templates.File.Sanitize(template, Replacements)).Should().BeFalse();
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), template, dirFullPath, extension, Replacements)
|
||||
.GetFilePath(extension)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
[TestMethod]
|
||||
[DataRow("<filedate[yy-MM-dd]> <date added[yy-MM-dd]> <pubdate[yy-MM]>", @"C:\foo\bar", ".m4b", @"C:\foo\bar\23-01-28.m4b")]
|
||||
public void DateFormat_null(string template, string dirFullPath, string extension, string expected)
|
||||
{
|
||||
if (Environment.OSVersion.Platform is not PlatformID.Win32NT)
|
||||
{
|
||||
dirFullPath = dirFullPath.Replace("C:", "").Replace('\\', '/');
|
||||
expected = expected.Replace("C:", "").Replace('\\', '/');
|
||||
}
|
||||
|
||||
Templates.getFileNamingTemplate(GetLibraryBookWithNullDates(), template, dirFullPath, extension, Replacements)
|
||||
.GetFilePath(extension)
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
|
||||
}
|
||||
|
||||
[TestMethod]
|
||||
[DataRow(@"C:\a\b", @"C:\a\b\foobar.ext", PlatformID.Win32NT)]
|
||||
[DataRow(@"/a/b", @"/a/b/foobar.ext", PlatformID.Unix)]
|
||||
public void IfSeries_empty(string directory, string expected, PlatformID platformID)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series-><-if series>bar", directory, "ext")
|
||||
.GetFilePath(Replacements, ".ext")
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series-><-if series>bar", directory, "ext", Replacements)
|
||||
.GetFilePath(".ext")
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -122,8 +254,8 @@ namespace TemplatesTests
|
||||
public void IfSeries_no_series(string directory, string expected, PlatformID platformID)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(null), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext")
|
||||
.GetFilePath(Replacements, ".ext")
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(null), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext", Replacements)
|
||||
.GetFilePath(".ext")
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
@@ -134,8 +266,8 @@ namespace TemplatesTests
|
||||
public void IfSeries_with_series(string directory, string expected, PlatformID platformID)
|
||||
{
|
||||
if (Environment.OSVersion.Platform == platformID)
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext")
|
||||
.GetFilePath(Replacements, ".ext")
|
||||
Templates.getFileNamingTemplate(GetLibraryBook(), "foo<if series->-<series>-<id>-<-if series>bar", directory, "ext", Replacements)
|
||||
.GetFilePath(".ext")
|
||||
.PathWithoutPrefix
|
||||
.Should().Be(expected);
|
||||
}
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="FluentAssertions" Version="6.8.0" />
|
||||
<PackageReference Include="FluentAssertions" Version="6.9.0" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.4.1" />
|
||||
<PackageReference Include="Moq" Version="4.18.4" />
|
||||
<PackageReference Include="MSTest.TestAdapter" Version="3.0.2" />
|
||||
|
||||
Reference in New Issue
Block a user